Merge "download-commands: use unqiue constants for each download command"
diff --git a/.bazelrc b/.bazelrc
index bf3aa6c..b9189c1 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -2,7 +2,7 @@
build --repository_cache=~/.gerritcodereview/bazel-cache/repository
build --action_env=PATH
build --disk_cache=~/.gerritcodereview/bazel-cache/cas
-build --java_toolchain //tools:error_prone_warnings_toolchain
+build --java_toolchain=//tools:error_prone_warnings_toolchain_java11
# Enable strict_action_env flag to. For more information on this feature see
# https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
@@ -13,5 +13,6 @@
test --build_tests_only
test --test_output=errors
+test --java_toolchain=//tools:error_prone_warnings_toolchain_java11
import %workspace%/tools/remote-bazelrc
diff --git a/.bazelversion b/.bazelversion
index 47b322c..7c69a55d 100644
--- a/.bazelversion
+++ b/.bazelversion
@@ -1 +1 @@
-3.4.1
+3.7.0
diff --git a/.gitignore b/.gitignore
index 8a41786..00a6217 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@
*.swp
*~
.DS_Store
+js-to-ts.sh
/.apt_generated
/.apt_generated_tests
/.bazel_path
@@ -48,5 +49,8 @@
!/plugins/webhooks
/test_site
/tools/format
+/tools/maven/gerrit-*_pom.xml.asc
+/tools/node_tools
+/tools/polygerrit-updater
/.ts-out/*
!/.ts-out/README.md
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 8bb5d54..529718a 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -93,14 +93,14 @@
== Predefined Groups
Predefined groups differs from system groups by the fact that they
-exist in the ACCOUNT_GROUPS table (like normal groups) but predefined groups
-are created on Gerrit site initialization and unique UUIDs are assigned
+exist in NoteDb under refs/meta/group-names (like normal groups) but predefined
+groups are created on Gerrit site initialization and unique UUIDs are assigned
to those groups. These UUIDs are different on different Gerrit sites.
Gerrit comes with two predefined groups:
* Administrators
-* Non-Interactive Users
+* Service Users
[[administrators]]
@@ -117,7 +117,7 @@
[[non-interactive_users]]
-=== Non-Interactive Users
+=== Service Users
This is the Gerrit "batch" identity. The capabilities
link:access-control.html#capability_priority['Priority BATCH'] and
@@ -131,7 +131,7 @@
order to prevent it from grabbing threads from the interactive users.
These users live in a second thread pool, which separates operations
-made by the non-interactive users from the ones made by the interactive
+made by the service users from the ones made by the interactive
users. This ensures that the interactive users can keep working when
resources are tight.
@@ -1323,7 +1323,7 @@
This capability allows the granted group members to create non-interactive
service accounts. These service accounts are generally used for automation
and made to be members of the
-link:access-control.html#non-interactive_users['Non-Interactive users'] group.
+link:access-control.html#service_users['Service users'] group.
[[capability_createGroup]]
@@ -1402,7 +1402,7 @@
This capability allows users to use
link:config-gerrit.html#sshd.batchThreads[the thread pool reserved] for
-link:access-control.html#non-interactive_users['Non-Interactive Users'].
+link:access-control.html#service_users['Service Users'].
It's a binary value in that granted users either have access to the thread
pool, or they don't.
@@ -1414,7 +1414,7 @@
default the user is then in the 'INTERACTIVE' thread pool.
'BATCH'::
-If there's a thread pool configured for 'Non-Interactive Users' and a user is
+If there's a thread pool configured for 'Service Users' and a user is
granted the priority capability with the 'BATCH' mode selected, the user ends
up in the separate batch user thread pool. This is true unless the user is
also granted the below 'INTERACTIVE' option.
diff --git a/Documentation/cmd-create-account.txt b/Documentation/cmd-create-account.txt
index 617191f..502b7a7 100644
--- a/Documentation/cmd-create-account.txt
+++ b/Documentation/cmd-create-account.txt
@@ -23,7 +23,7 @@
or event monitoring over link:cmd-stream-events.html[gerrit stream-events].
Note, however, that in this case the account is not implicitly added
-to the 'Non-Interactive Users' group. The account must be explicitly
+to the 'Service Users' group. The account must be explicitly
added to the group with the `--group` option.
If LDAP authentication is being used, the user account is created
@@ -66,10 +66,10 @@
== EXAMPLES
Create a new batch/role access user account called `watcher` in
-the 'Non-Interactive Users' group.
+the 'Service Users' group.
----
-$ cat ~/.ssh/id_watcher.pub | ssh -p 29418 review.example.com gerrit create-account --group "'Non-Interactive Users'" --ssh-key - watcher
+$ cat ~/.ssh/id_watcher.pub | ssh -p 29418 review.example.com gerrit create-account --group "'Service Users'" --ssh-key - watcher
----
GERRIT
diff --git a/Documentation/cmd-hook-commit-msg.txt b/Documentation/cmd-hook-commit-msg.txt
index 2b6d7af..e547822 100644
--- a/Documentation/cmd-hook-commit-msg.txt
+++ b/Documentation/cmd-hook-commit-msg.txt
@@ -56,6 +56,26 @@
The `Change-Id` will not be added if `gerrit.createChangeId` is set
to `false` in the git config.
+If `gerrit.reviewUrl` is set to the base URL of the Gerrit server that
+changes are uploaded to (e.g. `https://gerrit-review.googlesource.com/`)
+in the git config, then instead of adding a `Change-Id` trailer, a `Link`
+trailer will be inserted that will look like this:
+
+----
+Improve foo widget by attaching a bar.
+
+We want a bar, because it improves the foo by providing more
+wizbangery to the dowhatimeanery.
+
+Link: https://gerrit-review.googlesource.com/id/Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
+Signed-off-by: A. U. Thor <author@example.com>
+----
+
+This link will become a valid link to the review page once the change is
+uploaded to the Gerrit server. Newer versions of the Gerrit server will read
+the change identifier out of the appropriate `Link` trailer and treat it in
+the same way as the change identifier in a `Change-Id` trailer.
+
== OBTAINING
To obtain the `commit-msg` script use `scp`, `wget` or `curl` to download
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 7611f53..8a970c5 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -181,6 +181,9 @@
link:cmd-set-account.html[gerrit set-account]::
Change an account's settings.
+link:cmd-sequence-set.html[gerrit sequence set]::
+ Set new sequence value.
+
link:cmd-sequence-show.html[gerrit sequence show]::
Display current sequence value.
diff --git a/Documentation/concept-changes.txt b/Documentation/concept-changes.txt
index 1d275b4..6de787c 100644
--- a/Documentation/concept-changes.txt
+++ b/Documentation/concept-changes.txt
@@ -14,7 +14,7 @@
* Current and previous patch sets
* <<Change properties>>, such as owner, project, and target branch
-* link:CONCEPT-comments.html[Comments]
+* Comments
* Votes on link:config-labels.html[Review Labels]
* The <<change-id>>
@@ -203,6 +203,34 @@
method uses git's link:cmd-hook-commit-msg.html[commit-msg hook]
to automatically add the Change-Id to each new commit.
+== The Link footer
+
+Gerrit also supports the Link footer as an alternative to the Change-Id
+footer. A Link footer looks like this:
+
+....
+ Link: https://gerrit-review.googlesource.com/id/Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
+....
+
+The advantage of this style of footer is that it usually acts
+as a link directly to the change's review page, provided that
+the change has been uploaded to Gerrit. Projects such as the
+link:https://www.kernel.org/doc/html/latest/maintainer/configure-git.html#creating-commit-links-to-lore-kernel-org[Linux kernel]
+have a convention of adding links to commit messages using the
+Link footer.
+
+If multiple changes have been uploaded to Gerrit with the same
+change ID, for example if a change has been cherry-picked to multiple
+branches, the link will take the user to a list of changes.
+
+The base URL in the footer is required to match the server's base URL.
+If the URL does not match, the server will not recognize the footer
+as a change ID footer.
+
+The link:cmd-hook-commit-msg.html[commit-msg hook] can be configured
+to insert Link footers instead of Change-Id footers by setting the
+property `gerrit.reviewUrl` to the base URL of the Gerrit server.
+
GERRIT
------
Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index ceef953..5ed8ccf 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -824,6 +824,7 @@
* `"change_notes"`: disk storage is disabled by default
* `"diff_summary"`: default is `1g` (1 GiB of disk space)
* `"external_ids_map"`: disk storage is disabled by default
+* `"persisted_projects"`: default is `1g` (1 GiB of disk space)
+
If 0 or negative, disk storage for the cache is disabled.
@@ -972,6 +973,11 @@
be expensive to compute (60 or more seconds for a large history
like the Linux kernel repository).
+cache `"comment_context"`::
++
+Caches the context lines of comments, which are the lines of the source file
+highlighted by the user when the comment was written.
+
cache `"groups"`::
+
Caches the basic group information of internal groups by group ID,
@@ -1065,10 +1071,17 @@
has been converted from Markdown to HTML. The memoryLimit refers to
the bytes of memory dedicated to storing the documentation.
+cache `"persisted_projects"`::
++
+Caches the project description records, from the `refs/meta/config`
+branch of each project. This is the persisted variant of the
+`projects` cache. The intention is for this cache to have an in-memory
+size of 0.
+
cache `"projects"`::
+
-Caches the project description records, from the `projects` table
-in the database. If a project record is updated or deleted, this
+Caches the project description records, from the `refs/meta/config`
+branch of each project. If a project record is updated or deleted, this
cache should be flushed. Newly inserted projects do not require
a cache flush, as they will be read upon first reference.
@@ -1276,14 +1289,14 @@
If set to true, then all UI features for using and interacting with the
attention set are enabled.
+
-The default is false for now, but will be changed to true in Q2 2020.
+The default is true.
[[change.enableAssignee]]change.enableAssignee::
+
If set to true, then all UI features for using and interacting with the
assignee are enabled.
+
-The default is true for now, but will be changed to false in Q2 2020.
+The default is false.
[[change.largeChange]]change.largeChange::
+
@@ -1392,6 +1405,14 @@
+
The default limit is 1MiB.
+[[change.sendNewPatchsetEmails]]change.sendNewPatchsetEmails::
++
+When false, emails will not be sent to owners, reviewers, and cc for
+creating a new patchset unless they are project watchers or have starred
+the change.
++
+Default is true.
+
[[change.showAssigneeInChangesTable]]change.showAssigneeInChangesTable::
+
Show assignee field in changes table. If set to false, assignees will
@@ -2796,7 +2817,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.
@@ -3283,9 +3305,10 @@
When security is enabled in Elasticsearch, the username and password must be provided.
Note that the same username and password are used for all servers.
-For further information about Elasticsearch security, please refer to the documentation:
-
-* link:https://www.elastic.co/guide/en/elastic-stack-overview/6.6/security-getting-started.html[Elasticsearch 6.6,role=external,window=_blank]
+For further information about Elasticsearch security, please refer to
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/security-getting-started.html[the documentation,role=external,window=_blank].
+This is the current documentation link. Select another Elasticsearch version
+from the dropdown menu available on that page if need be.
[[elasticsearch.username]]elasticsearch.username::
+
@@ -3374,6 +3397,11 @@
If `auth.type` is `LDAP` this setting should use `ldaps://` to
ensure the end user's plaintext password is transmitted only over
an encrypted connection.
++
+If you want to configure multiple ldap servers you can try to put
+multiple ldap urls separated by a space:
+`server = ldaps://ldap1 ldaps://ldap2`
+See https://bugs.chromium.org/p/gerrit/issues/detail?id=10841[issue 10841].
[[ldap.startTls]]ldap.startTls::
+
@@ -4405,19 +4433,25 @@
[[receiveemail.filter.mode]]receiveemail.filter.mode::
+
-A black- and whitelist filter to filter incoming emails.
+An allow and block filter to filter incoming emails.
+
If `OFF`, emails are not filtered by the list filter.
+
-If `WHITELIST`, only emails where a pattern from
+If `ALLOW`, only emails where a pattern from
<<receiveemail.filter.patterns,receiveemail.filter.patterns>>
matches 'From' will be processed.
+
-If `BLACKLIST`, only emails where no pattern from
+If `BLOCK`, only emails where no pattern from
<<receiveemail.filter.patterns,receiveemail.filter.patterns>>
matches 'From' will be processed.
+
Defaults to `OFF`.
++
+The previous filter-names 'BLACKLIST' and 'WHITELIST' have been deprecated
+since they may be considered disrespectful and there's no technical or
+practical reason to use these exact terms for the filters.
+For backwards compatibility they are still supported but support for these
+deprecated terms will be removed in future releases.
[[receiveemail.filter.patterns]]receiveemail.filter.patterns::
+
@@ -4548,9 +4582,10 @@
[[sendemail.allowrcpt]]sendemail.allowrcpt::
+
-If present, each value adds one entry to the whitelist of email
-addresses that Gerrit can send email to. If set to a complete
-email address, that one address is added to the white list.
+If present, each value adds one entry to the list of allowed email
+addresses that Gerrit can send emails to. If set to a complete
+email address, that one address is added to the list of allowed
+emails.
If set to a domain name, any address at that domain can receive
email from Gerrit.
+
@@ -4561,9 +4596,10 @@
[[sendemail.denyrcpt]]sendemail.denyrcpt::
+
-If present, each value adds one entry to the blacklist of email
-addresses that Gerrit can send email to. If set to a complete
-email address, that one address is added to the blacklist.
+If present, each value adds one entry to the list of email
+addresses that Gerrit can't send emails to. If set to a complete
+email address, that one address is added to the list of blocked
+emails.
If set to a domain name, any address at that domain can *not* receive
email from Gerrit.
+
@@ -4762,16 +4798,16 @@
[[sshd.batchThreads]]sshd.batchThreads::
+
Number of threads to allocate for SSH command requests from
-link:access-control.html#non-interactive_users[non-interactive users].
+link:access-control.html#service_users[service users].
If equals to 0, then all non-interactive requests are executed in the same
queue as interactive requests.
+
Any other value will remove the number of threads from the queue
allocated to interactive users, and create a separate thread pool
of the requested size, which will be used to run commands from
-non-interactive users.
+service users.
+
-If the number of threads requested for non-interactive users is larger
+If the number of threads requested for service users is larger
than the total number of threads allocated in sshd.threads, then the
value of sshd.threads is increased to accommodate the requested value.
+
@@ -4852,6 +4888,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
@@ -4977,6 +5028,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.
@@ -5471,6 +5523,76 @@
trustFolderStat = false
----
+[[jgit-gc]]
+=== Section gc
+
+Options in section gc are used when command link:cmd-gc.html[gerrit gc] is used
+or scheduled via options link:cmd-gc.html#gc.startTime[gc.startTime] and
+link:cmd-gc.html#gc.interval[gc.interval].
+
+[[gc.auto]]gc.auto::
++
+When there are approximately more than this many loose objects in the repository,
+auto gc will pack them. Some commands use this command to perform a light-weight
+garbage collection from time to time. The default value is 6700.
++
+Setting this to 0 disables not only automatic packing based on the number of
+loose objects, but any other heuristic auto gc will otherwise use to determine
+if there’s work to do, such as link:#gc.autoPackLimit[gc.autoPackLimit].
+
+[[gc.autodetach]]gc.autodetach::
++
+Makes auto gc run in a background thread. Default is `true`.
+
+[[gc.autopacklimit]]gc.autopacklimit::
++
+When there are more than this many packs that are not marked with `*.keep` file
+in the repository, auto gc consolidates them into one larger pack. The
+default value is 50. Setting this to 0 disables it. Setting `gc.auto` to 0 will
+also disable this.
+
+[[gc.packRefs]]gc.packRefs::
++
+This variable determines whether gc runs git pack-refs. The default is `true`.
+
+[[gc.reflogExpire]]gc.reflogExpire::
++
+Removes reflog entries older than this time; defaults to 90 days. The value "now"
+expires all entries immediately, and "never" suppresses expiration altogether.
+
+[[gc.reflogExpireUnreachable]]gc.reflogExpireUnreachable::
++
+Removes reflog entries older than this time and not reachable from the
+current tip; defaults to 30 days. The value "now" expires all entries immediately,
+and "never" suppresses expiration altogether.
+
+[[jgit-protocol]]
+=== Section protocol
+
+[[protocol.version]]protocol.version::
++
+If set, the server will accept requests from a client attempting to communicate
+using the specified protocol version. Otherwise communication falls back to version 0.
+If set in file `etc/jgit.config` this option will be used for all repositories of
+the site. It can be overridden for a given repository by configuring a different
+value in the repository's `config` file.
++
+Supported versions:
+0:: the original wire protocol.
+1:: the original wire protocol with the addition of a version string in the initial response from the server.
+2:: wire protocol version 2. Speeds up fetches from repositories with many refs by allowing the client
+ to specify which refs to list before the server lists them.
+
+[[jgit-receive]]
+=== Section receive
+
+[[receive.autogc]]receive.autogc::
++
+By default, `git-receive-pack` will run auto gc after receiving data from git-push and updating refs.
+You can stop it by setting this variable to `false`. This is recommended in gerrit to avoid the
+additional load this creates. Instead schedule gc using link:cmd-gc.html#gc.startTime[gc.startTime]
+and link:cmd-gc.html#gc.interval[gc.interval] or e.g. in a cron job that runs gc in a separate process.
+
GERRIT
------
Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/config-login-register.txt b/Documentation/config-login-register.txt
deleted file mode 100644
index cc2185b..0000000
--- a/Documentation/config-login-register.txt
+++ /dev/null
@@ -1,137 +0,0 @@
-[[usersetup]]
-== Initial Login
-It's time to exit the gerrit account as you now have Gerrit running on your
-host and setup your first workspace.
-
-Start a shell with the credentials of the account you will perform
-development under.
-
-Check whether there are any ssh keys already. You're looking for two files,
-id_rsa and id_rsa.pub.
-
-----
- user@host:~$ ls .ssh
- authorized_keys config id_rsa id_rsa.pub known_hosts
- user@host:~$
-----
-
-If you have the files, you may skip the key generating step.
-
-If you don't see the files in your listing, your will have to generate rsa
-keys for your ssh sessions:
-
-=== SSH key generation
-
-*Please don't generate new keys if you already have a valid keypair!*
-*They will be overwritten!*
-
-----
- user@host:~$ ssh-keygen -t rsa
- Generating public/private rsa key pair.
- Enter file in which to save the key (/home/user/.ssh/id_rsa):
- Created directory '/home/user/.ssh'.
- Enter passphrase (empty for no passphrase):
- Enter same passphrase again:
- Your identification has been saved in /home/user/.ssh/id_rsa.
- Your public key has been saved in /home/user/.ssh/id_rsa.pub.
- The key fingerprint is:
- 00:11:22:00:11:22:00:11:44:00:11:22:00:11:22:99 user@host
- The key's randomart image is:
- +--[ RSA 2048]----+
- | ..+.*=+oo.*E|
- | u.OoB.. . +|
- | ..*. |
- | o |
- | . S .. |
- | |
- | |
- | .. |
- | |
- +-----------------+
- user@host:~$
-----
-
-=== Registering your key in Gerrit
-
-Open a browser and enter the canonical url of your Gerrit server. You can
-find the url in the settings file.
-
-----
- gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config gerrit.canonicalWebUrl
- http://localhost:8080/
- gerrit@host:~$
-----
-
-Register a new account in Gerrit through the web interface with the
-email address of your choice.
-
-The default authentication type is OpenID. If your Gerrit server is behind a
-proxy, and you are using an external OpenID provider, you will need to add the
-proxy settings in the configuration file.
-
-----
- gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxy http://proxy:8080
- gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxyUsername username
- gerrit@host:~$ git config -f $GERRIT_SITE/etc/gerrit.config --add http.proxyPassword password
-----
-
-Refer to the Gerrit configuration guide for more detailed information about
-link:config-gerrit.html#auth[authentication] and
-link:config-gerrit.html#http.proxy[proxy] settings.
-
-The first user to sign-in and register an account will be
-automatically placed into the fully privileged Administrators group,
-permitting server management over the web and over SSH. Subsequent
-users will be automatically registered as unprivileged users.
-
-Once signed in as your user, you find a little wizard to get you started.
-The wizard helps you fill out:
-
-* Real name (visible name in Gerrit)
-* Register your email (it must be confirmed later)
-* Select a username with which to communicate with Gerrit over ssh+git. Note
-that once saved, the username cannot be changed.
-
-* The server will ask you for an RSA public key.
-That's the key we generated above, and it's time to make sure that Gerrit knows
-about our new key and can identify us by it.
-
-----
- user@host:~$ cat .ssh/id_rsa.pub
- ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA1bidOd8LAp7Vp95M1b9z+LGO96OEWzdAgBPfZPq05jUh
- jw0mIdUuvg5lhwswnNsvmnFhGbsUoXZui6jdXj7xPUWOD8feX2NNEjTAEeX7DXOhnozNAkk/Z98WUV2B
- xUBqhRi8vhVmaCM8E+JkHzAc+7/HVYBTuPUS7lYPby5w95gs3zVxrX8d1++IXg/u/F/47zUxhdaELMw2
- deD8XLhrNPx2FQ83FxrjnVvEKQJyD2OoqxbC2KcUGYJ/3fhiupn/YpnZsl5+6mfQuZRJEoZ/FH2n4DEH
- wzgBBBagBr0ZZCEkl74s4KFZp6JJw/ZSjMRXsXXXWvwcTpaUEDii708HGw== John Doe@MACHINE
- user@host:~$
-----
-
-[IMPORTANT]
-Please take note of the extra line-breaks introduced in the key above
-for formatting purposes. Please be sure to copy and paste your key without
-line-breaks.
-
-Copy the string starting with ssh-rsa to your clipboard and then paste it
-into the box for RSA keys. Make *absolutely sure* no extra spaces or line feeds
-are entered in the middle of the RSA string.
-
-Verify that the ssh connection works for you.
-
-----
- user@host:~$ ssh user@localhost -p 29418
- The authenticity of host '[localhost]:29418 ([127.0.0.1]:29418)' can't be established.
- RSA key fingerprint is db:07:3d:c2:94:25:b5:8d:ac:bc:b5:9e:2f:95:5f:4a.
- Are you sure you want to continue connecting (yes/no)? yes
- Warning: Permanently added '[localhost]:29418' (RSA) to the list of known hosts.
-
- **** Welcome to Gerrit Code Review ****
-
- Hi user, you have successfully connected over SSH.
-
- Unfortunately, interactive shells are disabled.
- To clone a hosted Git repository, use:
-
- git clone ssh://user@localhost:29418/REPOSITORY_NAME.git
-
- user@host:~$
-----
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..96cc67f 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -18,7 +18,7 @@
To build Gerrit from source, you need:
* A Linux or macOS system (Windows is not supported at this time)
-* A JDK for Java 8|9|10|11|...
+* A JDK for Java 8|11|...
* Python 2 or 3
* link:https://github.com/nodesource/distributions/blob/master/README.md[Node.js (including npm),role=external,window=_blank]
* Bower (`npm install -g bower`)
@@ -54,6 +54,26 @@
`java -version`
+[[java-8]]
+==== Java 8 support (deprecated)
+
+Java 8 is a legacy Java release and support for Java 8 will be discontinued
+in future gerrit releases. To build Gerrit with Java 8 language level, run:
+
+```
+ $ bazel build --java_toolchain //tools:error_prone_warnings_toolchain :release
+```
+
+[[java-11]]
+==== Java 11 support
+
+Java language level 11 is the default. To build Gerrit with Java 11 language
+level, run:
+
+```
+ $ bazel build :release
+```
+
[[java-13]]
==== Java 13 support
@@ -90,33 +110,17 @@
```
$ 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
all those options.
-[[java-11]]
-==== Java 11 support
-
-Java 11 is supported through alternative java toolchain
-link:https://docs.bazel.build/versions/master/toolchains.html[Bazel option].
-To build Gerrit with Java 11, specify JDK 11 java toolchain:
-
-```
- $ bazel build \
- --host_javabase=@bazel_tools//tools/jdk:remote_jdk11 \
- --javabase=@bazel_tools//tools/jdk:remote_jdk11 \
- --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
- --java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 \
- :release
-```
-
=== Node.js and npm packages
See link:https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/README.md#installing-node_js-and-npm-packages[Installing Node.js and npm packages,role=external,window=_blank].
diff --git a/Documentation/dev-core-plugins.txt b/Documentation/dev-core-plugins.txt
index 04e2420..6b777d3 100644
--- a/Documentation/dev-core-plugins.txt
+++ b/Documentation/dev-core-plugins.txt
@@ -155,6 +155,52 @@
link:https://www.gerritcodereview.com/news.html[project news].
--
+[[removing]]
+=== Removing Core Plugins
+
+A core plugin could be subject to NOT be considered core anymore if:
+
+1. Does not respect the license:
++
+The plugin code or the libraries used are not following anymore the
+Apache License Version 2.0.
+
+2. Is out of scope:
++
+The plugin functionality has gone outside the Gerrit-related scope,
+has a clear scope or conflict with other core plugins or existing and
+planned Gerrit core features.
++
+NOTE: The plugin would need to remain core until the planned replacement gets
+implemented. Otherwise the feature is likely missing between the removal and
+planned implementation times.
+
+3. Is not relevant:
++
+The plugin functionality is no more relevant to a majority of the Gerrit community:
++
+--
+** An out of the box Gerrit installation won’t be missing anything if the plugin is
+ not installed.
+** It isn’t anymore used by most sites.
+** Multiple parties (different organizations/companies) have abandoned the use of
+ the plugin and agree that it should not be anymore a core plugin.
+** If the same or similar functionality is provided by multiple plugins, the plugin
+ is not a clear recommended solution anymore by the community.
+** Whether a plugin is no more relevant to a majority of the Gerrit community must be
+ discussed on a case-by-case basis. In case of doubt, it’s up to the engineering
+ steering committee to make a decision.
+--
+
+4. Degraded code quality:
++
+The plugin code maintenance is lacking and has not anymore good test coverage.
+Maintaining the plugin code creates a significant overhead for the Gerrit maintainers.
+
+5. Outdated documentation:
++
+The plugin functionality documented is significantly outdated.
+
GERRIT
------
Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 02ac2f5..4e248dd 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -147,7 +147,7 @@
link:https://github.com/google/google-java-format[`google-java-format`,role=external,window=_blank]
tool (version 1.7), and to format Bazel BUILD, WORKSPACE and .bzl files the
link:https://github.com/bazelbuild/buildtools/tree/master/buildifier[`buildifier`,role=external,window=_blank]
-tool (version 3.3.0). Unused dependencies are found and removed using the
+tool (version 3.5.0). Unused dependencies are found and removed using the
link:https://github.com/bazelbuild/buildtools/tree/master/unused_deps[`unused_deps`,role=external,window=_blank]
build tool, a sibling of `buildifier`.
diff --git a/Documentation/dev-design-docs.txt b/Documentation/dev-design-docs.txt
index 7954c54..6dc6f5f 100644
--- a/Documentation/dev-design-docs.txt
+++ b/Documentation/dev-design-docs.txt
@@ -135,6 +135,16 @@
committee] within 30 calendar days whether the proposed feature is in
scope of the project and if it can be accepted.
+[[meetings]]
+=== Meeting discussions
+
+If the Gerrit review doesn't start efficiently enough, stalls, gets off-track
+too much or becomes overly complex, one can use a meeting to refocus it. From
+that review thread, the organizer can volunteer oneself, or be proposed (even
+requested) by a reviewer. link:https://www.gerritcodereview.com/members.html#community-managers[
+Community managers,role=external,window=_blank] may help facilitate that if
+ultimately necessary.
+
[[watch-designs]]
== How to get notified for new design docs?
diff --git a/Documentation/dev-e2e-tests.txt b/Documentation/dev-e2e-tests.txt
index 78b2c15..4b1312a 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].
+2. Run `sbt eclipse` from the `e2e-tests` root directory.
+3. Import the resulting `e2e-tests` eclipse file inside the Gerrit project, in Eclipse.
+4. You should see errors in Eclipse telling you there are missing packages.
+5. This is due to the sbt-eclipse plugin not properly linking the Gerrit Gatling e2e tests with
+ Gatling Git plugin.
+6. You then have to right-click on the root directory and choose the build path->link source option.
+7. Then you have to browse to `.sbt/1.0/staging`, find the folder where gatling-git is contained,
+ and choose that.
+8. That last step should link the gatling-git plugin to the project; e2e tests should not show
+ errors anymore.
+9. 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
@@ -107,7 +126,8 @@
file contains the commands and repository used during the e2e test. That file currently looks like
below. This scenario serves as a simple example with no actual load in it. It can be used to test
or validate the local setup. More complex scenarios can be further developed, under the
-`com.google.gerrit.scenarios` package. The uppercase keywords are discussed further below.
+`com.google.gerrit.scenarios` package. The uppercase keywords are set through
+link:#_environment_properties[environment properties,role=external,window=_blank].
----
[
@@ -156,21 +176,50 @@
* `-Dcom.google.gerrit.scenarios.http_scheme=http`
Above, the properties can be set with values matching specific deployment topologies under test.
-The example values shown above are the currently coded default ones. For example, the `http` scheme
-above could be replaced with `https`. The framework could support differing or more properties over
-time.
+The name of the property corresponds to the uppercase keyword found in the json file. For example,
+`hostname` above will set the value of `HOSTNAME` in the
+link:#_input_file[aforementioned example,role=external,window=_blank].
-Plugin or otherwise non-core scenarios may do so just as well. The core java package
+The example values shown above are the currently coded default ones. For example, the `http` scheme
+above could be replaced with `https`. The framework may support differing or more properties over time.
+
+==== Replication delay
+
+The `replication_delay` property allows test scenario steps to wait for that many seconds, prior to
+expecting a done replication. Its default is `15` seconds and can be set using another value:
+
+* `-Dcom.google.gerrit.scenarios.replication_delay=15`
+
+There is a short time buffer added to this property. Now, the replication starts after replication
+plugin's own `replicationDelay`, in seconds, and typically takes some more seconds to complete.
+That whole replication time depends on the system under test. Therefore, this property here should
+be set to a value high enough, so that the test checks for a done replication at the right time.
+
+==== Automatic properties
+
+The link:#_input_file[example keywords,role=external,window=_blank] also include `_PROJECT`,
+prefixed with an underscore, which means that its value gets automatically generated by the
+scenario. Any property setting for it is therefore not applicable. Its usage differs from the
+non-prefixed `PROJECT` keyword, in that sense. Using the latter instead in json files requires
+setting this `JAVA_OPTS` property:
+
+* `-Dcom.google.gerrit.scenarios.project=myOwnTestRepoProjectName`
+
+Other automatic keys may be used and implemented, always prefixed with an underscore that tells so.
+
+==== Plugin scenarios
+
+Plugin or otherwise non-core scenarios can also use such properties. 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:
-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
-applicable. Its usage differs from the non-prefixed `PROJECT` keyword, in that sense.
+* `link:https://gerrit.googlesource.com/plugins/gc-conductor[gc-conductor,role=external,window=_blank]`
+* `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]`
+
+==== Power factor
The following core property can be optionally set depending on the runtime environment. The test
environments used as reference for scenarios development assume its default value, `1.0`. For
diff --git a/Documentation/dev-eclipse.txt b/Documentation/dev-eclipse.txt
index 9596a55..bbe227a 100644
--- a/Documentation/dev-eclipse.txt
+++ b/Documentation/dev-eclipse.txt
@@ -4,7 +4,8 @@
This document is about configuring Gerrit Code Review into an
Eclipse workspace for development.
-Java 8 or later SDK is required.
+Java 11 or later SDK is required.
+Otherwise, java 8 can still be used for now as described below.
[[setup]]
== Project Setup
@@ -30,6 +31,10 @@
----
First, generate the Eclipse project by running the `tools/eclipse/project.py` script.
+If running Eclipse on Java 8, add the extra parameter
+`-e='--java_toolchain=//tools:error_prone_warnings_toolchain'`
+for generating a compatible project.
+
Then, in Eclipse, choose 'Import existing project' and select the `gerrit` project
from the current working directory.
@@ -41,6 +46,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`
@@ -48,15 +84,16 @@
link:dev-build-plugins.html#_bundle_custom_plugin_in_release_war[bundling in release.war]
and run `tools/eclipse/project.py`.
-[[Newer Java versions]]
+== Java Versions
-Java 9 and later are supported, but some adjustments must be done, because
-Java 8 is still the default:
+Java 11 is supported as a default, but some adjustments must be done for other JDKs:
* Add JRE, e.g.: directory: /usr/lib64/jvm/java-9-openjdk, name: java-9-openjdk-9
* Change execution environment for gerrit project to: JavaSE-9 (java-9-openjdk-9)
* Check that compiler compliance level in gerrit project is set to: 9
+Moreover, the actual java 11 language features are not supported yet.
+
[[Formatting]]
== Code Formatter Settings
diff --git a/Documentation/dev-intellij.txt b/Documentation/dev-intellij.txt
index b67d546..149b14a 100644
--- a/Documentation/dev-intellij.txt
+++ b/Documentation/dev-intellij.txt
@@ -9,7 +9,7 @@
<<dev-bazel#installation,Building with Bazel - Installation>>.
It's strongly recommended to verify you can build your Gerrit tree with Bazel
-for Java 8 from the command line first. Ensure that at least
+for Java 11 from the command line first. Ensure that at least
`bazel build gerrit` runs successfully before you proceed.
=== IntelliJ version and Bazel plugin
@@ -21,12 +21,12 @@
Also note that the version of the Bazel plugin used in turn may or may not be
compatible with the Bazel version used.
-In addition, Java 8 must be specified on your path or via `JAVA_HOME` so that
+In addition, Java 11 must be specified on your path or via `JAVA_HOME` so that
building with Bazel via the Bazel plugin is possible.
TIP: If the synchronization of the project with the BUILD files using the Bazel
plugin fails and IntelliJ reports the error **Could not get Bazel roots**, this
-indicates that the Bazel plugin couldn't find Java 8.
+indicates that the Bazel plugin couldn't find Java 11.
=== Installation of IntelliJ IDEA
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index c3df396..b2e1589 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -379,13 +379,13 @@
notifications of these events by implementing the corresponding
listeners.
-* `com.google.gerrit.common.EventListener`:
+* `com.google.gerrit.server.events.EventListener`:
+
Allows to listen to events without user visibility restrictions. These
are the same link:cmd-stream-events.html#events[events] that are also streamed by
the link:cmd-stream-events.html[gerrit stream-events] command.
-* `com.google.gerrit.common.UserScopedEventListener`:
+* `com.google.gerrit.server.events.UserScopedEventListener`:
+
Allows to listen to events visible to the specified user. These are the
same link:cmd-stream-events.html#events[events] that are also streamed
@@ -742,7 +742,8 @@
Plugin methods implementing search operands (returning a
`Predicate<ChangeData>`), must be defined on a class implementing
one of the `ChangeQueryBuilder.ChangeOperandsFactory` interfaces
-(.e.g., ChangeQueryBuilder.ChangeHasOperandFactory). The specific
+(.e.g., ChangeQueryBuilder.ChangeHasOperandFactory or
+ChangeQueryBuilder.ChangeIsOperandFactory). The specific
`ChangeOperandFactory` class must also be bound to the `DynamicSet` from
a module's `configure()` method in the plugin.
@@ -802,6 +803,35 @@
}
----
+To provide additional Guice bindings for options to a command in another classloader, bind a
+ModulesClassNamesProvider which provides the name of your Modules needed for your DynamicBean
+in the other classLoader.
+
+Do this by binding to the name of the command you are going to bind to and providing an
+Iterable of Module names to instantiate and add to the Injector used to instantiate the
+DynamicBean in the other classLoader. This interface supports running LifecycleListeners
+which are defined by the Modules being provided. The duration of the lifecycle starts when
+a ssh or http request starts and ends when the request completes.
+
+[source, java]
+----
+ bind(DynamicOptions.DynamicBean.class)
+ .annotatedWith(Exports.named(
+ "com.google.gerrit.plugins.otherplugin.command"))
+ .to(MyOptionsModulesClassNamesProvider.class);
+
+ static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ModulesClassNamesProvider {
+ {@literal @}Override
+ public String getClassName() {
+ return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
+ }
+ {@literal @}Override
+ public Iterable<String> getModulesClassNames()() {
+ return "com.googlesource.gerrit.plugins.myplugin.MyOptionsModule";
+ }
+ }
+----
+
=== Calling Command Options ===
Within an OptionHandler, during the processing of an option, plugins can
@@ -863,13 +893,15 @@
[[query_attributes]]
=== Change Attributes ===
+==== ChangePluginDefinedInfoFactory
+
Plugins can provide additional attributes to be returned from the Get Change and
-Query Change APIs by implementing implementing the `ChangeAttributeFactory`
-interface and adding it to the `DynamicSet` in the plugin module's `configure()`
-method. The new attribute(s) will be output under a `plugin` attribute in the
-change output. This can be further controlled by registering a class containing
-@Option declarations as a `DynamicBean`, annotated with the with HTTP/SSH
-commands on which the options should be available.
+Query Change APIs by implementing the `ChangePluginDefinedInfoFactory` interface
+and adding it to the `DynamicSet` in the plugin module's `configure()` method.
+The new attribute(s) will be output under a `plugin` attribute in the change
+output. This can be further controlled by registering a class containing @Option
+declarations as a `DynamicBean`, annotated with the HTTP/SSH commands on
+which the options should be available.
The example below shows a plugin that adds two attributes (`exampleName` and
`changeValue`), to the change query output, when the query command is provided
@@ -881,7 +913,7 @@
@Override
protected void configure() {
// Register attribute factory.
- DynamicSet.bind(binder(), ChangeAttributeFactory.class)
+ DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
.to(AttributeFactory.class);
// Register options for GET /changes/X/change and /changes/X/detail.
@@ -906,7 +938,7 @@
public boolean all = false;
}
-public class AttributeFactory implements ChangeAttributeFactory {
+public class AttributeFactory implements ChangePluginDefinedInfoFactory {
protected MyChangeOptions options;
public class PluginAttribute extends PluginDefinedInfo {
@@ -920,14 +952,17 @@
}
@Override
- public PluginDefinedInfo create(ChangeData c, BeanProvider bp, String plugin) {
+ public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+ Collection<ChangeData> cds, BeanProvider bp, String plugin) {
if (options == null) {
options = (MyChangeOptions) bp.getDynamicBean(plugin);
}
+ Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
if (options.all) {
- return new PluginAttribute(c);
+ cds.forEach(cd -> out.put(cd.getId(), new PluginAttribute(cd)));
+ return out;
}
- return null;
+ return ImmutableMap.of();
}
}
----
@@ -969,10 +1004,20 @@
}
----
-Implementors of the `ChangeAttributeFactory` interface should check whether
-they need to contribute to the link:#change-etag-computation[change ETag
-computation] to prevent callers using ETags from potentially seeing outdated
-plugin attributes.
+Runtime exceptions generated by the implementors of ChangePluginDefinedInfoFactory
+are encapsulated in PluginDefinedInfo objects which are part of SSH/REST query output.
+
+==== ChangeAttributeFactory
+
+Alternatively, there is also `ChangeAttributeFactory` which takes in one single
+`ChangeData` at a time. `ChangePluginDefinedInfoFactory` should be preferred
+over this as it handles many changes at once which also decreases the round-trip
+time for queries resulting in performance increase for bulk queries.
+
+Implementors of the `ChangePluginDefinedInfoFactory` and `ChangeAttributeFactory`
+interfaces should check whether they need to contribute to the
+link:#change-etag-computation[change ETag computation] to prevent callers using
+ETags from potentially seeing outdated plugin attributes.
[[simple-configuration]]
== Simple Configuration in `gerrit.config`
diff --git a/Documentation/dev-processes.txt b/Documentation/dev-processes.txt
index b7dd259..0eb3972 100644
--- a/Documentation/dev-processes.txt
+++ b/Documentation/dev-processes.txt
@@ -87,7 +87,16 @@
Google maintainers do not take part in this vote, because Google
already has dedicated seats in the steering committee (see section
-link:steering-committee[steering committee]).
+link:#steering-committee[steering committee]).
+
+If a non-Google seat on the steering committee becomes vacant before
+the current term ends, an exceptional election is conducted in order
+to replace the member(s) leaving the committee. The election will
+follow the same procedure as regular steering committee elections.
+The number of votes each maintainer gets in such exceptional election
+matches the number of seats to be filled. The term of the new member
+of the steering committee ends at the end of the current term of
+the steering committee when the next regular election concludes.
[[contribution-process]]
== Contribution Process
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index eaf9905..a7240e2 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -13,7 +13,6 @@
and as a checklist for those already familiar with these
tasks.
-
== Gerrit Release Type
Here are some guidelines on release approaches depending on the
@@ -26,33 +25,36 @@
A `stable` release is generally built from the `master` branch and may
need to undergo some stabilization before releasing the final release.
-* Propose the release with any plans/objectives to the mailing list
+* Propose the release with any plans/objectives to the mailing list.
-* Create a Gerrit `rc0`
+* Release plans usually become a
+ link:https://www.gerritcodereview.com/news.html[news article]
+ to be followed up with.
-* If needed create a Gerrit `rc1`
+* Create a Gerrit `rc0`.
+
+* If needed create Gerrit `rc1`, `rc2` and `rc3` (one per week, on Mondays
+ or so; see link:https://www.gerritcodereview.com/news.html[past release plans]).
[NOTE]
-You may let in a few features to this release
+You may let in a few features to these releases.
-* If needed create a Gerrit `rc2`
+* If needed create a Gerrit `rc4`.
[NOTE]
-There should be no new features in this release, only bug fixes
+There should be no new features in this release, only bug fixes.
-* Finally create the `stable` release (no `rc`)
-
+* Finally create the `stable` release (no `rc`).
=== Stable-Fix
`stable-fix` releases should likely only contain bug fixes and doc
updates.
-* Propose the release with any plans/objectives to the mailing list
+* Propose the release with any plans/objectives to the mailing list.
* This type of release does not need any RCs, release when the
-objectives are met
-
+objectives are met.
[[security]]
=== Security-Fix
@@ -71,6 +73,48 @@
the `gerrit-security-fixes` project be taken over into the public
`gerrit` project.
+[[upload-final-release-notes]]
+== Upload the final Release Notes change
+
+Upload a change on the homepage project to:
+
+* Remove 'In Development' caveat from the relevant section.
+
+* Add links to the released documentation and the .war file, and make the
+latest version bold.
+
+The uploaded change is not to be approved yet, but rather act as the
+release content review thread until it can be finalized.
+
+[[update-links]]
+=== Update homepage links
+
+Upload a change on the link:https://gerrit-review.googlesource.com/admin/repos/homepage[
+homepage project,role=external,window=_blank] to change the version numbers
+to the new version.
+
+[[update-issues]]
+=== Update the Issues
+
+Update the issues by hand. There is no script for this.
+
+Our current process is an issue should be updated to say `Status =
+Submitted, FixedIn-$version` once the change is submitted, but before the
+release.
+
+The updated issues are the ones listed in commit messages since the
+previous version tag. Mention each updated issue in the uploaded change,
+following the examples from the previous version notes. Add updated issue
+owners as reviewers of the uploaded change. More reviewers can be added
+or cc'ed, to further coordinate the final release contents.
+
+Similarly to issues, also mention every noteworthy change done after the
+previous release. Again, previous notes should be used as template examples.
+
+You may need to split note update changes from the final change that
+updates the links. This allows non-final update changes to be reviewed and
+submitted timely. The final (links) change may take more time to complete,
+as this underlying release process unfolds.
== Create the Actual Release
@@ -96,6 +140,16 @@
git tag -s -m "v$version" "v$version"
----
+If unable to tag, make sure that git is locally
+link:https://medium.com/@rwbutler/signing-commits-using-gpg-on-macos-7210362d15[
+configured with your user's key,role=external,window=_blank]. These are the
+macOS instructions but such commands should be portable enough. Setting
+`GPG_TTY` this way or similar might also be necessary:
+
+----
+ export GPG_TTY=$(tty)
+----
+
Tag the plugins:
----
@@ -105,23 +159,23 @@
[[build-gerrit]]
=== Build Gerrit
-* Build the Gerrit WAR, API JARs and documentation
+* Build the Gerrit WAR, API JARs and documentation:
+
----
bazel build release Documentation:searchfree
+ ./tools/maven/api.sh war_install
./tools/maven/api.sh install
----
* Verify the WAR version:
+
----
- java -jar ~/dl/gerrit-$version.war --version
+ java -jar bazel-bin/release.war --version
----
-* Try upgrading a test site and launching the daemon
-* Verify plugin versions
-+
-Verify the versions:
+* Try upgrading a test site and launching the daemon.
+
+* Verify the plugin versions:
+
----
java -jar bazel-bin/release.war init --list-plugins
@@ -135,7 +189,7 @@
* Make sure you have done the
link:dev-release-deploy-config.html#deploy-configuration-setting-maven-central[
-configuration] for deploying to Maven Central
+configuration] for deploying to Maven Central.
* Make sure that the version is updated in the `version.bzl` file and in
the `*_pom.xml` files as described in the link:#update-versions[Update
@@ -162,7 +216,7 @@
https://oss.sonatype.org/content/repositories/snapshots/com/google/gerrit/[role=external,window=_blank]
** Release versions are uploaded into a staging repository in the
-link:https://oss.sonatype.org/[Sonatype Nexus Server].
+link:https://oss.sonatype.org/[Sonatype Nexus Server,role=external,window=_blank].
* Verify the staging repository
@@ -189,7 +243,7 @@
** Test closed staging repository
+
Once a repository is closed you can find the URL to it in the `Summary`
-section, e.g. https://oss.sonatype.org/content/repositories/comgooglegerrit-1029[role=external,window=_blank]
+section, e.g. https://oss.sonatype.org/content/repositories/comgooglegerrit-1029[role=external,window=_blank].
+
Use this URL for further testing of the artifacts in this repository,
e.g. to try building a plugin against the plugin API in this repository
@@ -214,16 +268,16 @@
Releasing artifacts to Maven Central cannot be undone!
** Find the closed staging repository in the
-link:https://oss.sonatype.org/[Sonatype Nexus Server], select it and
-click on `Release`.
+link:https://oss.sonatype.org/[Sonatype Nexus Server,role=external,window=_blank],
+select it and click on `Release`.
** The released artifacts are available in
-https://oss.sonatype.org/content/repositories/releases/com/google/gerrit/[role=external,window=_blank]
+https://oss.sonatype.org/content/repositories/releases/com/google/gerrit/[role=external,window=_blank].
** It may take up to 2 hours until the artifacts appear on Maven
Central:
+
-http://central.maven.org/maven2/com/google/gerrit/[role=external,window=_blank]
+https://repo1.maven.org/maven2/com/google/gerrit/[role=external,window=_blank]
* [optional]: View download statistics
@@ -235,14 +289,15 @@
** Select `com.google.gerrit` as `Project`.
-
[[publish-to-google-storage]]
==== Publish the Gerrit WAR to the Google Cloud Storage
-* go to the link:https://console.cloud.google.com/storage/browser/gerrit-releases/?project=api-project-164060093628[
-gerrit-releases bucket in the Google cloud storage console,role=external,window=_blank]
-* make sure you are signed in with your Gmail account
-* manually upload the Gerrit WAR file by using the `Upload` button
+* Go to the link:https://console.cloud.google.com/storage/browser/gerrit-releases/?project=api-project-164060093628[
+gerrit-releases bucket in the Google cloud storage console,role=external,window=_blank].
+
+* Make sure you are signed in with your Gmail account.
+
+* Manually upload the Gerrit WAR file by using the `Upload` button.
[[push-stable]]
==== Push the Stable Branch
@@ -257,7 +312,6 @@
* Create a change updating the `defaultbranch` field in the `.gitreview`
to match the branch name created.
-
[[push-tag]]
==== Push the Release Tag
@@ -273,7 +327,6 @@
git submodule foreach git push gerrit-review tag v$version
----
-
[[upload-documentation]]
==== Upload the Documentation
@@ -288,34 +341,16 @@
[[finalize-release-notes]]
=== Finalize the Release Notes
-Upload a change on the homepage project to:
+Submit any previously uploaded notes change on the homepage project.
-* Remove 'In Development' caveat from the relevant section.
-
-* Add links to the released documentation and the .war file, and make the
-latest version bold.
-
-[[update-links]]
-==== Update homepage links
-
-Upload a change on the link:https://gerrit-review.googlesource.com/admin/repos/homepage[
-homepage project,role=external,window=_blank] to change the version numbers to the new version.
-
-[[update-issues]]
+[[finalize-issues]]
==== Update the Issues
-Update the issues by hand. There is no script for this.
-
-Our current process is an issue should be updated to say `Status =
-Submitted, FixedIn-$version` once the change is submitted, but before the
-release.
-
-After the release is actually made, you can search in Google Code for
+After the release is actually made, you can search (in Monorail) for
`Status=Submitted FixedIn=$version` and then batch update these changes
to say `Status=Released`. Make sure the pulldown says `All Issues`
because `Status=Submitted` is considered a closed issue.
-
[[announce]]
==== Announce on Mailing List
@@ -328,7 +363,7 @@
help text:
----
- ~/gerrit-release-tools/release-announcement.py --help
+ ~/gerrit-release-tools/release-announcement.py --help
----
[[increase-version]]
@@ -341,7 +376,7 @@
Use the `version` tool to set the version in the `version.bzl` file:
----
- ./tools/version.py 2.6-SNAPSHOT
+ ./tools/version.py 2.6-SNAPSHOT
----
Verify that the changes made by the tool are sane, then commit them, push
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/images/user-attention-set-dashboard-empty.png b/Documentation/images/user-attention-set-dashboard-empty.png
new file mode 100644
index 0000000..7b15fa0
--- /dev/null
+++ b/Documentation/images/user-attention-set-dashboard-empty.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-dashboard.png b/Documentation/images/user-attention-set-dashboard.png
new file mode 100644
index 0000000..4533380
--- /dev/null
+++ b/Documentation/images/user-attention-set-dashboard.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-hovercard.png b/Documentation/images/user-attention-set-hovercard.png
new file mode 100644
index 0000000..8d6af58
--- /dev/null
+++ b/Documentation/images/user-attention-set-hovercard.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-icon-click.png b/Documentation/images/user-attention-set-icon-click.png
new file mode 100644
index 0000000..32b1961
--- /dev/null
+++ b/Documentation/images/user-attention-set-icon-click.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-icon.png b/Documentation/images/user-attention-set-icon.png
new file mode 100644
index 0000000..a6789b9
--- /dev/null
+++ b/Documentation/images/user-attention-set-icon.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-reply-modify.png b/Documentation/images/user-attention-set-reply-modify.png
new file mode 100644
index 0000000..a8895f9
--- /dev/null
+++ b/Documentation/images/user-attention-set-reply-modify.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-reply-select.png b/Documentation/images/user-attention-set-reply-select.png
new file mode 100644
index 0000000..e93ff58
--- /dev/null
+++ b/Documentation/images/user-attention-set-reply-select.png
Binary files differ
diff --git a/Documentation/images/user-attention-set-user-prefs.png b/Documentation/images/user-attention-set-user-prefs.png
new file mode 100644
index 0000000..47cdbf5
--- /dev/null
+++ b/Documentation/images/user-attention-set-user-prefs.png
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/js_licenses.txt b/Documentation/js_licenses.txt
index 956a94d..f6120a7 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -247,310 +247,6 @@
----
-[[isarray]]
-isarray
-
-* isarray
-
-[[isarray_license]]
-----
-(MIT)
-
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
-[[Polymer-2018]]
-Polymer-2018
-
-* @webcomponents/webcomponentsjs
-* polymer-bridges
-* polymer-resin
-
-[[Polymer-2018_license]]
-----
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
- * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
- * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
- * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
-[[font-roboto-local-fonts-robotomono]]
-font-roboto-local-fonts-robotomono
-
-* @polymer/font-roboto-local - only the following file(s):
-** fonts/robotomono/LICENSE.txt
-** fonts/robotomono/METADATA.json
-** fonts/robotomono/RobotoMono-Bold.ttf
-** fonts/robotomono/RobotoMono-BoldItalic.ttf
-** fonts/robotomono/RobotoMono-Italic.ttf
-** fonts/robotomono/RobotoMono-Light.ttf
-** fonts/robotomono/RobotoMono-LightItalic.ttf
-** fonts/robotomono/RobotoMono-Medium.ttf
-** fonts/robotomono/RobotoMono-MediumItalic.ttf
-** fonts/robotomono/RobotoMono-Regular.ttf
-** fonts/robotomono/RobotoMono-Thin.ttf
-** fonts/robotomono/RobotoMono-ThinItalic.ttf
-
-[[font-roboto-local-fonts-robotomono_license]]
-----
-
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- 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.
-
-----
-
-
[[Polymer-2014]]
Polymer-2014
@@ -684,6 +380,100 @@
----
+[[Polymer-2017]]
+Polymer-2017
+
+* @polymer/decorators
+* @polymer/polymer
+* @webcomponents/shadycss
+
+[[Polymer-2017_license]]
+----
+Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[Polymer-2018]]
+Polymer-2018
+
+* @webcomponents/webcomponentsjs
+* polymer-bridges
+* polymer-resin
+
+[[Polymer-2018_license]]
+----
+Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
[[ba-linkify]]
ba-linkify
@@ -945,52 +735,374 @@
----
-[[Polymer-2017]]
-Polymer-2017
+[[font-roboto-local-fonts-robotomono]]
+font-roboto-local-fonts-robotomono
-* @polymer/polymer
-* @webcomponents/shadycss
+* @polymer/font-roboto-local - only the following file(s):
+** fonts/robotomono/LICENSE.txt
+** fonts/robotomono/METADATA.json
+** fonts/robotomono/RobotoMono-Bold.ttf
+** fonts/robotomono/RobotoMono-BoldItalic.ttf
+** fonts/robotomono/RobotoMono-Italic.ttf
+** fonts/robotomono/RobotoMono-Light.ttf
+** fonts/robotomono/RobotoMono-LightItalic.ttf
+** fonts/robotomono/RobotoMono-Medium.ttf
+** fonts/robotomono/RobotoMono-MediumItalic.ttf
+** fonts/robotomono/RobotoMono-Regular.ttf
+** fonts/robotomono/RobotoMono-Thin.ttf
+** fonts/robotomono/RobotoMono-ThinItalic.ttf
-[[Polymer-2017_license]]
+[[font-roboto-local-fonts-robotomono_license]]
----
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
+
+----
+
+
+[[isarray]]
+isarray
+
+* isarray
+
+[[isarray_license]]
+----
+(MIT)
+
+Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
+[[lit-element]]
+lit-element
+
+* lit-element
+
+[[lit-element_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
+modification, are permitted provided that the following conditions are met:
- * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
- * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
- * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
----
+[[lit-html]]
+lit-html
+
+* lit-html
+
+[[lit-html_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[page]]
+page
+
+* page
+
+[[page_license]]
+----
+(The MIT License)
+
+Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
[[path-to-regexp]]
path-to-regexp
@@ -1023,35 +1135,238 @@
----
-[[page]]
-page
+[[rxjs]]
+rxjs
-* page
+* rxjs
-[[page_license]]
+[[rxjs_license]]
----
-(The MIT License)
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
-Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-'Software'), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
+ 1. Definitions.
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
-THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
-TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
+
+ 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.
+
+
+----
+
+
+[[tslib]]
+tslib
+
+* tslib
+
+[[tslib_license]]
+----
+Copyright (c) Microsoft Corporation.
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
----
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index d561596..d43203f 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -44,10 +44,12 @@
* auto:auto-value
* auto:auto-value-annotations
+* auto:auto-value-gson
* commons:codec
* commons:compress
* commons:dbcp
* commons:lang
+* commons:lang3
* commons:net
* commons:pool
* commons:validator
@@ -3189,310 +3191,6 @@
----
-[[isarray]]
-isarray
-
-* isarray
-
-[[isarray_license]]
-----
-(MIT)
-
-Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-of the Software, and to permit persons to whom the Software is furnished to do
-so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-----
-
-
-[[Polymer-2018]]
-Polymer-2018
-
-* @webcomponents/webcomponentsjs
-* polymer-bridges
-* polymer-resin
-
-[[Polymer-2018_license]]
-----
-Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
-
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
-
- * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
- * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
- * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-
-----
-
-
-[[font-roboto-local-fonts-robotomono]]
-font-roboto-local-fonts-robotomono
-
-* @polymer/font-roboto-local - only the following file(s):
-** fonts/robotomono/LICENSE.txt
-** fonts/robotomono/METADATA.json
-** fonts/robotomono/RobotoMono-Bold.ttf
-** fonts/robotomono/RobotoMono-BoldItalic.ttf
-** fonts/robotomono/RobotoMono-Italic.ttf
-** fonts/robotomono/RobotoMono-Light.ttf
-** fonts/robotomono/RobotoMono-LightItalic.ttf
-** fonts/robotomono/RobotoMono-Medium.ttf
-** fonts/robotomono/RobotoMono-MediumItalic.ttf
-** fonts/robotomono/RobotoMono-Regular.ttf
-** fonts/robotomono/RobotoMono-Thin.ttf
-** fonts/robotomono/RobotoMono-ThinItalic.ttf
-
-[[font-roboto-local-fonts-robotomono_license]]
-----
-
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- 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.
-
-----
-
-
[[Polymer-2014]]
Polymer-2014
@@ -3626,6 +3324,100 @@
----
+[[Polymer-2017]]
+Polymer-2017
+
+* @polymer/decorators
+* @polymer/polymer
+* @webcomponents/shadycss
+
+[[Polymer-2017_license]]
+----
+Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[Polymer-2018]]
+Polymer-2018
+
+* @webcomponents/webcomponentsjs
+* polymer-bridges
+* polymer-resin
+
+[[Polymer-2018_license]]
+----
+Copyright (c) 2018 The Polymer Project Authors. All rights reserved.
+
+This code may only be used under the BSD style license found at
+http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
+http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
+found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
+part of the polymer project is also subject to an additional IP rights grant
+found at http://polymer.github.io/PATENTS.txt
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
[[ba-linkify]]
ba-linkify
@@ -3887,52 +3679,374 @@
----
-[[Polymer-2017]]
-Polymer-2017
+[[font-roboto-local-fonts-robotomono]]
+font-roboto-local-fonts-robotomono
-* @polymer/polymer
-* @webcomponents/shadycss
+* @polymer/font-roboto-local - only the following file(s):
+** fonts/robotomono/LICENSE.txt
+** fonts/robotomono/METADATA.json
+** fonts/robotomono/RobotoMono-Bold.ttf
+** fonts/robotomono/RobotoMono-BoldItalic.ttf
+** fonts/robotomono/RobotoMono-Italic.ttf
+** fonts/robotomono/RobotoMono-Light.ttf
+** fonts/robotomono/RobotoMono-LightItalic.ttf
+** fonts/robotomono/RobotoMono-Medium.ttf
+** fonts/robotomono/RobotoMono-MediumItalic.ttf
+** fonts/robotomono/RobotoMono-Regular.ttf
+** fonts/robotomono/RobotoMono-Thin.ttf
+** fonts/robotomono/RobotoMono-ThinItalic.ttf
-[[Polymer-2017_license]]
+[[font-roboto-local-fonts-robotomono_license]]
----
-Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
-This code may only be used under the BSD style license found at
-http://polymer.github.io/LICENSE.txt The complete set of authors may be found at
-http://polymer.github.io/AUTHORS.txt The complete set of contributors may be
-found at http://polymer.github.io/CONTRIBUTORS.txt Code distributed by Google as
-part of the polymer project is also subject to an additional IP rights grant
-found at http://polymer.github.io/PATENTS.txt
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ 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.
+
+----
+
+
+[[isarray]]
+isarray
+
+* isarray
+
+[[isarray_license]]
+----
+(MIT)
+
+Copyright (c) 2013 Julian Gruber <julian@juliangruber.com>;
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+----
+
+
+[[lit-element]]
+lit-element
+
+* lit-element
+
+[[lit-element_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are
-met:
+modification, are permitted provided that the following conditions are met:
- * Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
- * Redistributions in binary form must reproduce the above
-copyright notice, this list of conditions and the following disclaimer
-in the documentation and/or other materials provided with the
-distribution.
- * Neither the name of Google Inc. nor the names of its
-contributors may be used to endorse or promote products derived from
-this software without specific prior written permission.
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
-A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
-OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
-SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
-LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
-THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
-(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
----
+[[lit-html]]
+lit-html
+
+* lit-html
+
+[[lit-html_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017, The Polymer Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+----
+
+
+[[page]]
+page
+
+* page
+
+[[page_license]]
+----
+(The MIT License)
+
+Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+'Software'), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+----
+
+
[[path-to-regexp]]
path-to-regexp
@@ -3965,35 +4079,238 @@
----
-[[page]]
-page
+[[rxjs]]
+rxjs
-* page
+* rxjs
-[[page_license]]
+[[rxjs_license]]
----
-(The MIT License)
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
-Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of this software and associated documentation files (the
-'Software'), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish,
-distribute, sublicense, and/or sell copies of the Software, and to
-permit persons to whom the Software is furnished to do so, subject to
-the following conditions:
+ 1. Definitions.
-The above copyright notice and this permission notice shall be
-included in all copies or substantial portions of the Software.
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
-THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
-IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
-CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
-TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
-SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright (c) 2015-2018 Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
+
+ 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.
+
+
+----
+
+
+[[tslib]]
+tslib
+
+* tslib
+
+[[tslib_license]]
+----
+Copyright (c) Microsoft Corporation.
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
----
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/pg-plugin-dev.txt b/Documentation/pg-plugin-dev.txt
index adb5d20..91bc476 100644
--- a/Documentation/pg-plugin-dev.txt
+++ b/Documentation/pg-plugin-dev.txt
@@ -375,93 +375,4 @@
Note: TODO
=== url
-`plugin.url(opt_path)`
-
-Note: TODO
-
-[[deprecated-api]]
-== Deprecated APIs
-
-Some of the deprecated APIs have limited implementation in PolyGerrit to serve
-as a "stepping stone" to allow gradual migration.
-
-=== install
-`plugin.deprecated.install()`
-
-.Params:
-- none
-
-Replaces plugin APIs with a deprecated version. This allows use of deprecated
-APIs without changing JS code. For example, `onAction` is not available by
-default, and after `plugin.deprecated.install()` it's accessible via
-`self.onAction()`.
-
-=== onAction
-`plugin.deprecated.onAction(type, view_name, callback)`
-
-.Params:
-- `*string* type` Action type.
-- `*string* view_name` REST API action.
-- `*function(actionContext)* callback` Callback invoked on action button click.
-
-Adds a button to the UI with a click callback. Exact button location depends on
-parameters. Callback is triggered with an instance of
-link:#deprecated-action-context[action context].
-
-Support is limited:
-
-- type is either `change` or `revision`.
-
-See link:js-api.html#self_onAction[self.onAction] for more info.
-
-=== panel
-`plugin.deprecated.panel(extensionpoint, callback)`
-
-.Params:
-- `*string* extensionpoint`
-- `*function(screenContext)* callback`
-
-Adds a UI DOM element and triggers a callback with context to allow direct DOM
-access.
-
-Support is limited:
-
-- extensionpoint is one of the following:
- * CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK
- * CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK
-
-See link:js-api.html#self_panel[self.panel] for more info.
-
-=== settingsScreen
-`plugin.deprecated.settingsScreent(path, menu, callback)`
-
-.Params:
-- `*string* path` URL path fragment of the screen for direct link.
-- `*string* menu` Menu item title.
-- `*function(settingsScreenContext)* callback`
-
-Adds a settings menu item and a section in the settings screen that is provided
-to plugin for setup.
-
-See link:js-api.html#self_settingsScreen[self.settingsScreen] for more info.
-
-[[deprecated-action-context]]
-=== Action Context (deprecated)
-Instance of Action Context is passed to `onAction()` callback.
-
-Support is limited:
-
-- `popup()`
-- `hide()`
-- `refresh()`
-- `textfield()`
-- `br()`
-- `msg()`
-- `div()`
-- `button()`
-- `checkbox()`
-- `label()`
-- `prependLabel()`
-- `call()`
-
-See link:js-api.html#ActionContext[Action Context] for more info.
+`plugin.url(opt_path)`
\ No newline at end of file
diff --git a/Documentation/pg-plugin-migration.txt b/Documentation/pg-plugin-migration.txt
index bca4b7a..061c687 100644
--- a/Documentation/pg-plugin-migration.txt
+++ b/Documentation/pg-plugin-migration.txt
@@ -79,9 +79,6 @@
<script>
Gerrit.install(plugin => {
// Setup block, is executed before sampleplugin.js
-
- // Install deprecated JS APIs (onAction, popup, etc)
- plugin.deprecated.install();
});
</script>
@@ -105,8 +102,6 @@
- `sampleplugin.js` is loaded since it's referenced in `sampleplugin.html`
- setup script tag code is executed before `sampleplugin.js`
- cleanup script tag code is executed after `sampleplugin.js`
-- `plugin.deprecated.install()` enables deprecated APIs (onAction(), popup(),
-etc) before `sampleplugin.js` is loaded
This means the plugin instance is shared between .html-based and .js-based
code. This allows to gradually and incrementally transfer code to the new API.
diff --git a/Documentation/pgm-daemon.txt b/Documentation/pgm-daemon.txt
index 7345d06..cf6560b 100644
--- a/Documentation/pgm-daemon.txt
+++ b/Documentation/pgm-daemon.txt
@@ -49,8 +49,9 @@
This option automatically implies '--enable-sshd'.
--console-log::
- Send log messages to the console, instead of to the standard
- log file '$site_path/logs/error_log'.
+ Send log messages to the console. Log files will still be written to
+ the error log file, if log.textLogging and/or log.jsonLogging is set to
+ 'true'.
--headless::
Don't start the default Gerrit UI. May be useful when Gerrit is
diff --git a/Documentation/prolog-change-facts.txt b/Documentation/prolog-change-facts.txt
index ac69616..e5b4140 100644
--- a/Documentation/prolog-change-facts.txt
+++ b/Documentation/prolog-change-facts.txt
@@ -54,6 +54,17 @@
|`commit_stats/3` |`commit_stats(5,20,50).`
|Number of files modified, number of insertions and the number of deletions.
+|`files/3` |`files(file('modules/jgit', 'A', 'SUBMODULE')).`
+
+ |`files(file('a.txt', 'M', 'REGULAR')).'
+
+ A list of tuples: The first argument is a file name of the current patchset.
+ The second argument is the modification type of this file, with the options being
+ 'A' for 'added', 'M' for 'modified', 'D' for 'deleted', 'R' for 'renamed', 'C' for
+ 'COPIED' and 'W' for 'rewrite'.
+ The third argument is the type of file, with the options being a submodule file
+ 'SUBMODULE' and a non-submodule file being 'REGULAR'.
+
|`pure_revert/1` |`pure_revert(1).`
|link:rest-api-changes.html#get-pure-revert[Pure revert] as integer atom (1 if
the change is a pure revert, 0 otherwise)
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 21b8c9f..189ccfc 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -26,7 +26,7 @@
link:https://groups.google.com/d/topic/repo-discuss/wJxTGhlHZMM/discussion[This
discussion thread,role=external,window=_blank] explains why Prolog was chosen for the purpose of writing
project specific submit rules.
-link:http://gerrit-documentation.googlecode.com/svn/ReleaseNotes/ReleaseNotes-2.2.2.html[Gerrit
+link:https://gerrit-documentation.storage.googleapis.com/ReleaseNotes/ReleaseNotes-2.2.2.html#_prolog[Gerrit
2.2.2 ReleaseNotes,role=external,window=_blank] introduces Prolog support in Gerrit.
[[SubmitType]]
@@ -951,40 +951,40 @@
sum_list([], S, S).
----
-=== Example 14: Master and apprentice
-The master and apprentice example allow you to specify a user (the `master`)
-that must approve all changes done by another user (the `apprentice`).
+=== Example 14: Mentor and Mentee
+The mentor and mentee example allow you to specify a user (the `mentor`)
+that must approve all changes done by another user (the `mentee`).
The code first checks if the commit author is in the apprentice database.
-If the commit is done by an `apprentice`, it will check if there is a `+2`
-review by the associated `master`.
+If the commit is done by a `mentee`, it will check if there is a `+2`
+review by the associated `mentor`.
`rules.pl`
[source,prolog]
----
-% master_apprentice(Master, Apprentice).
-% Extend this with appropriate user-id for your master/apprentice setup.
-master_apprentice(user(1000064), user(1000000)).
+% mentor_mentee(Mentor, Mentee).
+% Extend this with appropriate user-id for your mentor/mentee setup.
+mentor_mentee(user(1000064), user(1000000)).
submit_rule(S) :-
gerrit:default_submit(In),
In =.. [submit | Ls],
- add_apprentice_master(Ls, R),
+ add_mentee_mentor(Ls, R),
S =.. [submit | R].
-check_master_approval(S1, S2, Master) :-
+check_mentor_approval(S1, S2, Mentor) :-
gerrit:commit_label(label('Code-Review', 2), R),
- R = Master, !,
- S2 = [label('Master-Approval', ok(R)) | S1].
-check_master_approval(S1, [label('Master-Approval', need(_)) | S1], _).
+ R = Mentor, !,
+ S2 = [label('Mentor-Approval', ok(R)) | S1].
+check_mentor_approval(S1, [label('Mentor-Approval', need(_)) | S1], _).
-add_apprentice_master(S1, S2) :-
+add_mentee_mentor(S1, S2) :-
gerrit:commit_author(Id),
- master_apprentice(Master, Id),
+ mentor_mentee(Mentor, Id),
!,
- check_master_approval(S1, S2, Master).
+ check_mentor_approval(S1, S2, Mentor).
-add_apprentice_master(S, S).
+add_mentee_mentor(S, S).
----
=== Example 15: Make change submittable if all comments have been resolved
@@ -1083,6 +1083,35 @@
indicate to the user that the change has to be a pure revert in order
to become submittable.
+=== Example 17: Make a change submittable if it doesn't include specific files
+
+We can block any change which contains a submodule file change:
+
+`rules.pl`
+[source,prolog]
+----
+submit_rule(submit(R)) :-
+ gerrit:includes_file(file(_,_,'SUBMODULE')),
+ !,
+ R = label('All-Submodules-Resolved', need(_)).
+submit_rule(submit(label('All-Submodules-Resolved', ok(A)))) :-
+ gerrit:commit_author(A).
+----
+
+We can also block specific files, modification type, or file type,
+by changing include_files/1 to a different parameter. E.g,
+include_files('a.txt',_,_) includes any update to "a.txt", and
+('a.txt','D',_) includes any deletion to "a.txt". Also, (_,_,_) includes
+any file (other than magic file).
+
+An inclusive list of possible arguments using the code above with variations
+of include_file:
+The first parameter is the file name.
+The second is the modification type ('A' for 'added', 'M' for 'modified',
+'D' for 'deleted', 'R' for 'renamed', 'C' for 'COPIED' and 'W' for 'rewrite').
+The third argument is the type of file, with the options being a submodule
+file 'SUBMODULE' and a non-submodule file being 'REGULAR'.
+
== Examples - Submit Type
The following examples show how to implement own submit type rules.
diff --git a/Documentation/rest-api-access.txt b/Documentation/rest-api-access.txt
index c2a7d21..6664aa2 100644
--- a/Documentation/rest-api-access.txt
+++ b/Documentation/rest-api-access.txt
@@ -287,12 +287,12 @@
"15bfcd8a6de1a69c50b30cedcdcc951c15703152": {
"url": "#/admin/groups/uuid-15bfcd8a6de1a69c50b30cedcdcc951c15703152",
"options": {},
- "description": "Users who perform batch actions on Gerrit",
+ "description": "Service accounts that interact with Gerrit",
"group_id": 2,
"owner": "Administrators",
"owner_id": "53a4f647a89ea57992571187d8025f830625192a",
"created_on": "2009-06-08 23:31:00.000000000",
- "name": "Non-Interactive Users"
+ "name": "Service Users"
},
"global:Anonymous-Users": {
"options": {},
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 86f546d..2a59d0c 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -60,7 +60,7 @@
[[details]]
--
* `DETAILS`: Includes full name, preferred email, username, display
-name, avatars, status and state for each account.
+name, avatars, status, state and tags for each account.
--
[[all-emails]]
@@ -2302,6 +2302,12 @@
|`status` |optional|Status message of the account.
|`inactive` |not set if `false`|
Whether the account is inactive.
+|`tags` |optional, not set if empty|
+List of additional tags that this account has. The only +
+current tag an account can have is `SERVICE_USER`. +
+Only set if detailed account information is requested. +
+See option link:rest-api-changes.html#detailed-accounts[
+DETAILED_ACCOUNTS]
|===============================
[[account-input]]
@@ -2801,9 +2807,11 @@
|`email_strategy` ||
The type of email strategy to use. On `ENABLED`, the user will receive emails
from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
-their own comments. On `DISABLED` the user will not receive any email
-notifications from Gerrit.
-Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+their own comments. On `ATTENTION_SET_ONLY`, on emails about changes, the user
+will receive emails only if they are in the attention set of that change.
+On `DISABLED` the user will not receive any email notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `ATTENTION_SET_ONLY`,
+`DISABLED`.
|`default_base_for_merges` ||
The base which should be pre-selected in the 'Diff Against' drop-down
list when the change screen is opened for a merge commit.
@@ -2864,9 +2872,11 @@
|`email_strategy` |optional|
The type of email strategy to use. On `ENABLED`, the user will receive emails
from Gerrit. On `CC_ON_OWN_COMMENTS` the user will also receive emails for
-their own comments. On `DISABLED` the user will not receive any email
-notifications from Gerrit.
-Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `DISABLED`.
+their own comments. On `ATTENTION_SET_ONLY`, on emails about changes, the user
+will receive emails only if they are in the attention set of that change.
+On `DISABLED` the user will not receive any email notifications from Gerrit.
+Allowed values are `ENABLED`, `CC_ON_OWN_COMMENTS`, `ATTENTION_SET_ONLY`,
+`DISABLED`.
|`default_base_for_merges` |optional|
The base which should be pre-selected in the 'Diff Against' drop-down
list when the change screen is opened for a merge commit.
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index e9bdc25..d128613 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -2039,6 +2039,10 @@
comments for each path are sorted by patch set number. Each comment has
the `patch_set` and `author` fields set.
+If the `enable_context` request parameter is set to true, the comment entries
+will contain a list of link:#context-line[ContextLine] containing the lines of
+the source file where the comment was written.
+
.Request
----
GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/comments HTTP/1.0
@@ -5026,6 +5030,139 @@
}
----
+[[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.
+
+This endpoint requires authentication.
+
+.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
--
@@ -5450,9 +5587,6 @@
differences are reported in the result. Valid values are `IGNORE_NONE`,
`IGNORE_TRAILING`, `IGNORE_LEADING_AND_TRAILING` or `IGNORE_ALL`.
-The `context` parameter can be specified to control the number of lines of surrounding context
-in the diff. Valid values are `ALL` or number of lines.
-
[[preview-fix]]
=== Preview fix
--
@@ -5803,6 +5937,8 @@
If a user is added while already in the attention set, the
request is silently ignored.
+The user must be a reviewer, cc, uploader, or owner on the change.
+
.Request
----
POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/attention HTTP/1.0
@@ -6004,7 +6140,8 @@
If not set, the default is `ALL`.
|`notify_details`|optional|
Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|=============================
[[action-info]]
@@ -6115,7 +6252,7 @@
|Field Name ||Description
|`account` || link:rest-api-accounts.html#account-info[AccountInfo] entity.
|`last_update` || The link:rest-api.html#timestamp[timestamp] of the last update.
-|`reason` | The reason of for adding or removing the user.
+|`reason` || The reason of for adding or removing the user.
|===========================
[[attention-set-input]]
@@ -6125,11 +6262,20 @@
[options="header",cols="1,^1,5"]
|===========================
-|Field Name ||Description
-|`user` |optional| link:rest-api-accounts.html#account-id[ID]
+|Field Name ||Description
+|`user` |optional| link:rest-api-accounts.html#account-id[ID]
of the account that should be added to the attention set. For removals,
this field should be empty or the same as the field in the request header.
-|`reason` | The reason of for adding or removing the user.
+|`reason` || The reason of for adding or removing the user.
+|`notify` |optional|
+Notify handling that defines to whom email notifications should be sent
+after the change is created. +
+Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
+If not set, the default is `OWNER`.
+|`notify_details` |optional|
+Additional information about whom to notify about the change creation
+as a map of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|===========================
[[blame-info]]
@@ -6398,7 +6544,8 @@
If not set, the default is `ALL`.
|`notify_details` |optional|
Additional information about whom to notify about the change creation
-as a map of recipient type to link:#notify-info[NotifyInfo] entity.
+as a map of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|==================================
[[change-message-info]]
@@ -6454,7 +6601,8 @@
If not set, the default is `ALL`.
|`notify_details` |optional|
Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|`keep_reviewers` |optional, defaults to false|
If `true`, carries reviewers and ccs over from original change to newly created one.
|`allow_conflicts` |optional, defaults to false|
@@ -6527,12 +6675,17 @@
resolution of a comment thread is stored in the last comment in that thread
chronologically.
|`change_message_id` |optional|
-Available with published comments. Contains the
-link:rest-api-changes.html#change-message-info[id] of the change message
-that this comment is linked to.
+Available with the link:#list-change-comments[list change comments] endpoint.
+Contains the link:rest-api-changes.html#change-message-info[id] of the change
+message that this comment is linked to.
|`commit_id` |optional|
Hex commit SHA1 (40 characters string) of the commit of the patchset to which
this comment applies.
+|`context_lines` |optional|
+A list of link:#context-line[ContextLine] containing the lines of the source
+file where the comment was written. Available only if the "enable_context"
+parameter (see link:#list-change-comments[List Change Comments]) is set.
+
|===========================
[[comment-input]]
@@ -6604,6 +6757,18 @@
|`end_character` ||The character position in the end line. (0-based)
|===========================
+[[context-line]]
+=== ContextLine
+The `ContextLine` entity contains the line number and line text of a single
+line of the source file content.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name |Description
+|`line_number` |The line number of the source line.
+|`context_line` |String containing the line text.
+|===========================
+
[[commit-info]]
=== CommitInfo
The `CommitInfo` entity contains information about a commit.
@@ -6647,7 +6812,8 @@
If not set, the default is `OWNER` for WIP changes and `ALL` otherwise.
|`notify_details`|optional|
Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|=============================
[[delete-change-message-input]]
@@ -6693,7 +6859,8 @@
If not set, the default is `ALL`.
|`notify_details`|optional|
Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|=============================
[[delete-vote-input]]
@@ -6714,7 +6881,8 @@
If not set, the default is `ALL`.
|`notify_details`|optional|
Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|=============================
[[description-input]]
@@ -6748,6 +6916,8 @@
link:#diff-intraline-info[DiffIntralineInfo] entity.
|`due_to_rebase`|not set if `false`|Indicates whether this entry was introduced by a
rebase.
+|`due_to_move`|not set if `false`|Indicates whether this entry was introduced by a
+move operation.
|`skip` |optional|count of lines skipped on both sides when the file is
too large to include all common lines.
|`common` |optional|Set to `true` if the region is common according
@@ -6954,12 +7124,13 @@
|==========================
|Field Name |Description
|`path` |The path of the file which should be modified. Any file in
-the repository may be modified.
+the repository may be modified. The commit message can be modified via the
+magic file `/COMMIT_MSG` though only the part below the generated header of
+that magic file can be modified. References to the header lines will result in
+errors when the fix is applied.
|`range` |A <<comment-range,CommentRange>> indicating which content
of the file should be replaced. Lines in the file are assumed to be separated
-by the line feed character, the carriage return character, the carriage return
-followed by the line feed character, or one of the other Unicode linebreak
-sequences supported by Java.
+by the line feed character.
|`replacement` |The content which should be used instead of the current one.
|==========================
@@ -7158,6 +7329,13 @@
|`merge` ||
The detail of the source commit for merge as a link:#merge-input[MergeInput]
entity.
+|`author` |optional|
+An link:rest-api-accounts.html#account-input[AccountInput] entity
+that will set the author of the commit to create. The author must be
+specified as name/email combination.
+The caller needs "Forge Author" permission when using this field.
+This field does not affect the owner of the change, which will
+continue to use the identity of the caller.
|==================================
[[move-input]]
@@ -7178,8 +7356,8 @@
be notified about an update. These notifications are sent out even if a
`notify` option in the request input disables normal notifications.
`NotifyInfo` entities are normally contained in a `notify_details` map
-in the request input where the key is the recipient type. The recipient
-type can be `TO`, `CC` and `BCC`.
+in the request input where the key is the
+link:user-notify.html#recipient-types[recipient type].
[options="header",cols="1,^1,5"]
|=======================
@@ -7234,7 +7412,8 @@
If not set, the default is `ALL`.
|`notify_details`|optional|
Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|=============================
[[pure-revert-info]]
@@ -7333,7 +7512,7 @@
|===========================
|Field Name | |Description
|`status` | | Status of the requirement. Can be either `OK`, `NOT_READY` or `RULE_ERROR`.
-|`fallbackText` | | A human readable reason
+|`fallback_text` | | A human readable reason
|`type` | |
Alphanumerical (plus hyphens or underscores) string to identify what the requirement is and why it
was triggered. Can be seen as a class: requirements sharing the same type were created for a similar
@@ -7370,12 +7549,17 @@
If not set, the default is `ALL`.
|`notify_details`|optional|
Additional information about whom to notify about the revert as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|`topic` |optional|
Name of the topic for the revert change. If not set, the default for Revert
endpoint is the topic of the change being reverted, and the default for the
RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
Topic can't contain quotation marks.
+|`work_in_progress` |optional|
+When present, change is marked as Work In Progress. This will also override
+the notify value to `OWNER`. +
+If not set, the default is false.
|=============================
[[revert-submission-info]]
@@ -7430,24 +7614,24 @@
[options="header",cols="1,^1,5"]
|============================
-|Field Name ||Description
-|`message` |optional|
+|Field Name ||Description
+|`message` |optional|
The message to be added as review comment.
-|`tag` |optional|
+|`tag` |optional|
Apply this tag to the review comment message, votes, and inline
comments. Tags may be used by CI or other automated systems to
distinguish them from human reviews. Votes/comments that contain `tag` with
'autogenerated:' prefix can be filtered out in the web UI.
-|`labels` |optional|
+|`labels` |optional|
The votes that should be added to the revision as a map that maps the
label names to the voting values.
-|`comments` |optional|
+|`comments` |optional|
The comments that should be added as a map that maps a file path to a
list of link:#comment-input[CommentInput] entities.
-|`robot_comments` |optional|
+|`robot_comments` |optional|
The robot comments that should be added as a map that maps a file path
to a list of link:#robot-comment-input[RobotCommentInput] entities.
-|`drafts` |optional|
+|`drafts` |optional|
Draft handling that defines how draft comments are handled that are
already in the database but that were not also described in this
input. +
@@ -7456,37 +7640,39 @@
Only `KEEP` is allowed when used in conjunction with `on_behalf_of`. +
If not set, the default is `KEEP`. If `on_behalf_of` is set, then no other value
besides `KEEP` is allowed.
-|`notify` |optional|
+|`notify` |optional|
Notify handling that defines to whom email notifications should be sent
after the review is stored. +
Allowed values are `NONE`, `OWNER`, `OWNER_REVIEWERS` and `ALL`. +
If not set, the default is `ALL`.
-|`notify_details` |optional|
+|`notify_details` |optional|
Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
-|`omit_duplicate_comments` |optional|
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
+|`omit_duplicate_comments` |optional|
If `true`, comments with the same content at the same place will be omitted.
-|`on_behalf_of` |optional|
+|`on_behalf_of` |optional|
link:rest-api-accounts.html#account-id[\{account-id\}] the review
should be posted on behalf of. To use this option the caller must
have been granted `labelAs-NAME` permission for all keys of labels.
-|`reviewers` |optional|
+|`reviewers` |optional|
A list of link:rest-api-changes.html#reviewer-input[ReviewerInput]
representing reviewers that should be added to the change.
-|`ready` |optional|
+|`ready` |optional|
If true, and if the change is work in progress, then start review.
It is an error for both `ready` and `work_in_progress` to be true.
-|`work_in_progress` |optional|
+|`work_in_progress` |optional|
If true, mark the change as work in progress. It is an error for both
`ready` and `work_in_progress` to be true.
-|`add_to_attention_set` |optional|
+|`add_to_attention_set` |optional|
list of link:#attention-set-input[AttentionSetInput] entities to add
-to the link:#attention-set[attention set].
-|`remove_from_attention_set` |optional|
+to the link:#attention-set[attention set]. Users that are not reviewers,
+ccs, owner, or uploader are silently ignored.
+|`remove_from_attention_set` |optional|
list of link:#attention-set-input[AttentionSetInput] entities to remove
from the link:#attention-set[attention set].
-|`ignore_default_attention_set_rules`|optional|
-If set to true, ignore all default attention set rules described in the
+|`ignore_automatic_attention_set_rules`|optional|
+If set to true, ignore all automatic attention set rules described in the
link:#attention-set[attention set]. Updates in add_to_attention_set
and remove_from_attention_set are not ignored.
|============================
@@ -7566,7 +7752,8 @@
If not set, the default is `ALL`.
|`notify_details`|optional|
Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|=============================
[[revision-info]]
@@ -7632,7 +7819,8 @@
The `RobotCommentInfo` entity contains information about a robot inline
comment.
-`RobotCommentInfo` has the same fields as <<comment-info,CommentInfo>>.
+`RobotCommentInfo` has the same fields as <<comment-info,CommentInfo>>
+except for the `unresolved` field which doesn't exist for robot comments.
In addition `RobotCommentInfo` has the following fields:
[options="header",cols="1,^1,5"]
@@ -7652,8 +7840,35 @@
The `RobotCommentInput` entity contains information for creating an inline
robot comment.
-`RobotCommentInput` has the same fields as
-<<robot-comment-info,RobotCommentInfo>>.
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name ||Description
+|`path` ||
+link:#file-id[The file path] for which the inline comment should be added.
+|`side` |optional|
+The side on which the comment should be added. +
+Allowed values are `REVISION` and `PARENT`. +
+If not set, the default is `REVISION`.
+|`line` |optional|
+The number of the line for which the comment should be added. +
+`0` if it is a file comment. +
+If neither line nor range is set, a file comment is added. +
+If range is set, this value is ignored in favor of the `end_line` of the range.
+|`range` |optional|
+The range of the comment as a link:#comment-range[CommentRange]
+entity.
+|`in_reply_to` |optional|
+The URL encoded UUID of the comment to which this comment is a reply.
+|`message` |optional|
+The comment message.
+|`robot_id` ||The ID of the robot that generated this comment.
+|`robot_run_id` ||An ID of the run of the robot.
+|`url` |optional|URL to more information.
+|`properties` |optional|Robot specific properties as map that maps arbitrary
+keys to values.
+|`fix_suggestions`|optional|Suggested fixes for this robot comment as a list of
+<<fix-suggestion-info,FixSuggestionInfo>> entities.
+|===========================
[[rule-input]]
=== RuleInput
@@ -7713,7 +7928,8 @@
If not set, the default is `ALL`.
|`notify_details`|optional|
Additional information about whom to notify about the update as a map
-of recipient type to link:#notify-info[NotifyInfo] entity.
+of link:user-notify.html#recipient-types[recipient type] to
+link:#notify-info[NotifyInfo] entity.
|=============================
[[submit-record]]
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index f76e0b8..a62ed47 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1701,7 +1701,7 @@
|======================
|Field Name|Description
|`status` |The status of the consistency problem. +
-Possible values are `ERROR` and `WARNING`.
+Possible values are `FATAL`, `ERROR` and `WARNING`.
|`message` |Message describing the consistency problem.
|======================
@@ -2011,7 +2011,7 @@
UserConfigInfo] entity.
|`default_theme` |optional|
URL to a default PolyGerrit UI theme plugin, if available.
-Located in `/static/gerrit-theme.html` by default.
+Located in `/static/gerrit-theme.js` by default.
|=======================================
[[sshd-info]]
diff --git a/Documentation/rest-api-groups.txt b/Documentation/rest-api-groups.txt
index 72974e2..52c505a 100644
--- a/Documentation/rest-api-groups.txt
+++ b/Documentation/rest-api-groups.txt
@@ -67,12 +67,12 @@
"owner_id": "834ec36dd5e0ed21a2ff5d7e2255da082d63bbd7",
"created_on": "2013-02-01 09:59:32.126000000"
},
- "Non-Interactive Users": {
+ "Service Users": {
"id": "5057f3cbd3519d6ab69364429a89ffdffba50f73",
"url": "#/admin/groups/uuid-5057f3cbd3519d6ab69364429a89ffdffba50f73",
"options": {
},
- "description": "Users who perform batch actions on Gerrit",
+ "description": "Service accounts that interact with Gerrit",
"group_id": 4,
"owner": "Administrators",
"owner_id": "6a1e70e1a88782771a91808c8af9bbb7a9871389",
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index ff022c5..d34ccb4 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1153,7 +1153,7 @@
"owner": "Administrators",
"owner_id": "d5b7124af4de52924ed397913e2c3b37bf186948",
"created_on": "2009-06-08 23:31:00.000000000",
- "name": "Non-Interactive Users"
+ "name": "Service Users"
},
"global:Anonymous-Users": {
"options": {},
@@ -3393,6 +3393,8 @@
|`status` ||The HTTP status code for the access.
200 means success and 403 means denied.
|`message` |optional|A clarifying message if `status` is not 200.
+|`debug_logs` |optional|
+Debug logs that may help to understand why a permission is denied or allowed.
|=========================================
[[auto_closeable_changes_check_input]]
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
new file mode 100644
index 0000000..bca338a
--- /dev/null
+++ b/Documentation/user-attention-set.txt
@@ -0,0 +1,187 @@
+= Gerrit Code Review - Attention Set
+
+Report a bug or send feedback using
+link:https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set[this Monorail template].
+You can also report a bug through the bug icon in the user hovercard and in the
+reply dialog.
+
+[[whose-turn]]
+== Whose turn is it?
+
+Code Review is a turn-based workflow going back and forth between the change
+owner and reviewers. For every change Gerrit maintains an "Attention Set" with
+users that are currently expected to act on the change. Both on the dashboard
+and on the change page, this is expressed by an arrow icon before a (bolded)
+user name:
+
+image::images/user-attention-set-icon.png["account chip with attention icon", align="center"]
+
+While the attention set brings clarity to the process it also comes with
+responsibilities and expectations. To provide the best outcome for all users, we
+suggest following these principles:
+
+* Reviewers are expected to respond in a timely manner when it is their turn. If
+ you don't plan to respond within ~24h, then you should either remove yourself
+ from the attention set or you should at least send a clarification message to
+ the change owner.
+* Change owners are expected to manage the attention set of their changes
+ carefully. They should make sure that reviewers are only in the attention set
+ when the owner waits for a response from them.
+
+On the plus side you can strictly ignore everyone else's changes, if you are not
+in the attention set. :-)
+
+=== Rules
+
+To help with the back and forth, Gerrit applies some basic automated rules for
+changing the attention set:
+
+* If reviewers are added to a change, then they are added to the attention set.
+ * Exception: A reviewer adding themselves along with a comment or vote.
+* If an active change is submitted, abandoned or reset to "work in progress",
+ then all users are removed from the attention set.
+* Replying (commenting, voting or just writing a change message) removes the
+ replying user from the attention set. And it adds all participants of comment
+ conversations that the user is replying to.
+* If a *reviewer* replies, then the change owner (and uploader) are added to the
+ attention set.
+* For merged and abandoned changes the owner is added only when a human creates
+ an unresolved comment.
+* Only owner, uploader, reviewers and ccs can be in the attention set.
+
+*!IMPORTANT!* These rules are not meant to be super smart and to always do the
+right thing, e.g. if the change owner sends a reply, then they are often
+expected to individually select whose turn it is.
+
+Note that just uploading a new patchset is not a relevant event for the
+attention set to change.
+
+=== Interaction
+
+There are three ways to interact with the attention set: The attention icon,
+the hovercard of owner and reviewer chips and the "Reply" dialog.
+
+*The attention icon* can be used to quickly remove yourself (or someone else)
+from the attention set. Just click the icon, and it will disappear:
+
+image::images/user-attention-set-icon-click.png["attention set icon with tooltip", align="center"]
+
+*The hovercard* (on both the Dashboard and Change page) contains information
+about whether, why and when a user was added to the attention set. It also
+contains an action for adding/removing the user to/from the attention set.
+
+image::images/user-attention-set-hovercard.png["user hovercard with info and action", align="center"]
+
+*The reply dialog* contains a section for controlling to whom the turn should be
+passed.
+
+image::images/user-attention-set-reply-modify.png["reply dialog section for modifying", align="center"]
+
+If you click "MODIFY", then the section will
+expand and you can select and de-select users by clicking on their chips.
+Whatever you select here will be the new state of the attention set for this
+change. As a change owner make sure to remove reviewers that you don't expect to
+take action.
+
+image::images/user-attention-set-reply-select.png["reply dialog section for selecting users", align="center"]
+
+=== Bots
+
+The attention set is meant for human reviews only. Triggering bots and reacting
+to their results is a different workflow and not in scope of the attenion set.
+Thus members of the "Service Users" group will never be added to the
+attention set. And replies by such users will only add the change owner (and
+uploader) to the attention set, if it comes along with a negative vote.
+
+=== Dashboard
+
+The default *dashboard* contains a new section at the top called "Your Turn". It
+lists all changes where the logged-in user is in the attention set. When you are
+a reviewer, the change is highlighted and is shown at the top of the section.
+The "Waiting" column indicates how long the owner has already been waiting for
+you to act.
+
+image::images/user-attention-set-dashboard.png["dashboard with Your Turn section", align="center"]
+
+As an active developer, one of your daily goals will be to iterate over this
+list and clear it.
+
+image::images/user-attention-set-dashboard-empty.png["dashboard with empty Your Turn section", align="center"]
+
+Note that you can also navigate to other users' dashboards to check their
+"Your Turn" section.
+
+=== Emails
+
+Every email begins with `Attention is currently required from: ...`, so you can
+identify at a glance whether you are expected to act.
+
+You can even change your email notification preferences in the user settings to
+only receive emails when you are in the attention set of a change:
+
+image::images/user-attention-set-user-prefs.png["user preference for email notifications", align="center"]
+
+If you prefer setting up customized filters in your mail client, then you can
+make use of the `Gerrit-Attention:` footer lines that are added for every user
+in the attention set, e.g.
+
+----
+Gerrit-Attention: Marian Harbach <mharbach@google.com>
+----
+
+=== Assignee
+
+While the "Assignee" feature can still be used together with the attention set,
+we do not recommend doing so. Using both features is likely confusing. The
+distinct feature of the "Assignee" compared to the attention set is that only
+one user can be the assignee at the same time. So the assignee can be used to
+single out one person or escalate, if there are multiple reviewers. Since
+*every* reviewer in the attention set is expected to take action, singling out
+is not likely to be important and also still achievable with the attention set.
+Otherwise "Assignee" and "Attention Set" are very much overlapping, so we
+recommend to only use one of them.
+
+If you don't expect action from reviewers, then consider adding them to CC
+instead.
+
+The "Assignee" feature can be turned on/off with the
+link:config-gerrit.html#change.enableAttentionSet[enableAssignee] config option.
+
+=== Bold Changes / Mark Reviewed
+
+Before the attention set feature, changes were bolded in the dashboard when
+*something* happened and you could explicitly "mark a change reviewed" on the
+change page. This former way of keeping track of what you should look at has
+been replaced by the attention set.
+
+=== For Gerrit Admins
+
+The Attention Set will be part of the upcoming 3.3 release (due late 2020). It
+is enabled by default, but you can disable it by setting
+link:config-gerrit.html#change.enableAttentionSet[enableAttentionSet] to false.
+
+=== Important note for all host owners, project owners, and bot owners
+
+If you are a host/project owner, please make sure all bots that run against your
+host/project are part of the "Service Users" group.
+
+If you are a bot owner, please make sure your bot is part of the "Service Users"
+group on all hosts it runs on.
+
+To add users to the "Service Users" group, first ensure that the group exists on
+your host. If it doesn't, create it. The name must exactly be "Service Users".
+
+To create a group, use the Gerrit UI; BROWSE -> Groups -> CREATE NEW.
+
+Then, add the bots as members in this group. Alternatively, add an existing
+group that has multiple bots as a subgroup of "Service Users".
+
+To add members or subgroups, use the Gerrit UI; BROWSE -> Groups ->
+search for "Service Users" -> Members.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/user-named-destinations.txt b/Documentation/user-named-destinations.txt
index 1b6f143..a1ab258 100644
--- a/Documentation/user-named-destinations.txt
+++ b/Documentation/user-named-destinations.txt
@@ -13,6 +13,7 @@
row in a destination file represents a single destination in the
named set. The left column represents the ref of the destination,
and the right column represents the project of the destination.
+The named destinations can be publicly accessible by other users.
Example destination file named `destinations/myreviews`:
diff --git a/Documentation/user-named-queries.txt b/Documentation/user-named-queries.txt
index e79b3da..c01f790 100644
--- a/Documentation/user-named-queries.txt
+++ b/Documentation/user-named-queries.txt
@@ -7,7 +7,8 @@
link:intro-user.html#user-refs[user's ref] in the `All-Users` project. The
user's queries file is a 2 column tab delimited file. The left
column represents the name of the query, and the right column
-represents the query expression represented by the name.
+represents the query expression represented by the name. The named queries
+can be publicly accessible by other users.
Example queries file:
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 5346b2e..5ee3136 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -6,6 +6,15 @@
uploaded for review, after comments have been posted on a change,
or after the change has been submitted to a branch.
+[[recipient-types]]
+== Recipient Type
+
+Those are the available recipient types:
++
+* `to`: The standard To field is used; addresses are visible to all.
+* `cc`: The standard CC field is used; addresses are visible to all.
+* `bcc`: SMTP RCPT TO is used to hide the address.
+
[[user]]
== User Level Settings
@@ -114,10 +123,8 @@
Email header used to list the destination. If not set BCC is used.
Only one value may be specified. To use different headers for each
address list them in different notify blocks.
-+
-* `to`: The standard To field is used; addresses are visible to all.
-* `cc`: The standard CC field is used; addresses are visible to all.
-* `bcc`: SMTP RCPT TO is used to hide the address.
+
+The possible options are the link:#recipient-types[recipient types].
[[notify.name.filter]]notify.<name>.filter::
+
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index ffe889f..5f18e9b 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -106,9 +106,11 @@
that was scraped out of the commit message.
[[destination]]
-destination:'NAME'::
+destination:'[name=]NAME[,user=USER]'::
+
-Changes which match the current user's destination named 'NAME'.
+Changes which match the specified USER's destination named 'NAME'. If 'USER'
+is unspecified, the current user is used. The named destinations can be
+publicly accessible by other users.
(see link:user-named-destinations.html[Named Destinations]).
[[owner]]
@@ -123,9 +125,11 @@
Changes originally submitted by a user in 'GROUP'.
[[query]]
-query:'NAME'::
+query:'[name=]NAME[,user=USER]'::
+
-Changes which match the current user's query named 'NAME'
+Changes which match the specified USER's query named 'NAME'. If 'USER'
+is unspecified, the current user is used. The named queries can be
+publicly accessible by other users.
(see link:user-named-queries.html[Named Queries]).
[[reviewer]]
@@ -304,6 +308,11 @@
`file:"^name[1-3].xml"`.
+
Slash ('/') is used path separator.
++
+More examples:
+* `-file:^path/.*` - changes that do not modify files from `path/`,
+* `file:{^~(path/.*)}` - changes that modify files not from `path/` (but may
+contain files from `path/`).
[[file]]
file:'NAME', f:'NAME'::
@@ -524,7 +533,7 @@
For example, added:>50 will be true for any change which adds at least 50
lines.
+
-Valid relations are >=, >, <=, <, or no relation, which will match if the
+Valid relations are >=, >, \<=, <, or no relation, which will match if the
number of lines is exactly equal.
[[commentby]]
@@ -576,7 +585,7 @@
For example, unresolved:>0 will be true for any change which has at least one unresolved
comment while unresolved:0 will be true for any change which has all comments resolved.
+
-Valid relations are >=, >, <=, <, or no relation, which will match if the number of unresolved
+Valid relations are >=, >, \<=, <, or no relation, which will match if the number of unresolved
comments is exactly equal.
== Argument Quoting
@@ -693,7 +702,7 @@
Matches changes with a +1 code review where the reviewer is in the
ldap/linux.workflow group.
-`label:Code-Review<=-1`::
+`label:Code-Review\<=-1`::
+
Matches changes with either a -1, -2, or any lower score.
diff --git a/WORKSPACE b/WORKSPACE
index b8d08dc..3c30026 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -34,11 +34,11 @@
http_archive(
name = "bazel_toolchains",
- sha256 = "88e818f9f03628eef609c8429c210ecf265ffe46c2af095f36c7ef8b1855fef5",
- strip_prefix = "bazel-toolchains-92dd8a7a518a2fb7ba992d47c8b38299fe0be825",
+ sha256 = "726b5423e1c7a3866a3a6d68e7123b4a955e9fcbe912a51e0f737e6dab1d0af2",
+ strip_prefix = "bazel-toolchains-3.1.0",
urls = [
- "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/archive/92dd8a7a518a2fb7ba992d47c8b38299fe0be825.tar.gz",
- "https://github.com/bazelbuild/bazel-toolchains/archive/92dd8a7a518a2fb7ba992d47c8b38299fe0be825.tar.gz",
+ "https://mirror.bazel.build/github.com/bazelbuild/bazel-toolchains/releases/download/3.1.0/bazel-toolchains-3.1.0.tar.gz",
+ "https://github.com/bazelbuild/bazel-toolchains/releases/download/3.1.0/bazel-toolchains-3.1.0.tar.gz",
],
)
@@ -64,8 +64,8 @@
http_archive(
name = "build_bazel_rules_nodejs",
- sha256 = "84abf7ac4234a70924628baa9a73a5a5cbad944c4358cf9abdb4aab29c9a5b77",
- urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/1.7.0/rules_nodejs-1.7.0.tar.gz"],
+ sha256 = "f2194102720e662dbf193546585d705e645314319554c6ce7e47d8b59f459e9c",
+ urls = ["https://github.com/bazelbuild/rules_nodejs/releases/download/2.2.2/rules_nodejs-2.2.2.tar.gz"],
)
# Golang support for PolyGerrit local dev server.
@@ -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.
@@ -640,6 +640,38 @@
sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
)
+AUTO_VALUE_GSON_VERSION = "1.3.0"
+
+maven_jar(
+ name = "auto-value-gson-runtime",
+ artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
+ sha1 = "a69a9db5868bb039bd80f60661a771b643eaba59",
+)
+
+maven_jar(
+ name = "auto-value-gson-extension",
+ artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
+ sha1 = "6a61236d17b58b05e32b4c532bcb348280d2212b",
+)
+
+maven_jar(
+ name = "auto-value-gson-factory",
+ artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
+ sha1 = "b1f01918c0d6cb1f5482500e6b9e62589334dbb0",
+)
+
+maven_jar(
+ name = "javapoet",
+ artifact = "com.squareup:javapoet:1.13.0",
+ sha1 = "d6562d385049f35eb50403fa86bb11cce76b866a",
+)
+
+maven_jar(
+ name = "autotransient",
+ artifact = "io.sweers.autotransient:autotransient:1.0.0",
+ sha1 = "38b1c630b8e76560221622289f37be40105abb3d",
+)
+
declare_nongoogle_deps()
LUCENE_VERS = "6.6.5"
@@ -758,8 +790,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(
@@ -843,30 +875,30 @@
sha1 = "42a25dc3219429f0e5d060061f71acb49bf010a0",
)
-TRUTH_VERS = "1.0.1"
+TRUTH_VERS = "1.1"
maven_jar(
name = "truth",
artifact = "com.google.truth:truth:" + TRUTH_VERS,
- sha1 = "361459309085bd9441cb97b62f160e8b353a93c0",
+ sha1 = "6a096a16646559c24397b03f797d0c9d75ee8720",
)
maven_jar(
name = "truth-java8-extension",
artifact = "com.google.truth.extensions:truth-java8-extension:" + TRUTH_VERS,
- sha1 = "ef07b2cc2201472381fdd3bcf773310e22bb9080",
+ sha1 = "258db6eb8df61832c5c059ed2bc2e1c88683e92f",
)
maven_jar(
name = "truth-liteproto-extension",
artifact = "com.google.truth.extensions:truth-liteproto-extension:" + TRUTH_VERS,
- sha1 = "bd1f5ac8a5f66e60cd1738f7b95c97a582ffcef9",
+ sha1 = "bf65afa13aa03330e739bcaa5d795fe0f10fbf20",
)
maven_jar(
name = "truth-proto-extension",
artifact = "com.google.truth.extensions:truth-proto-extension:" + TRUTH_VERS,
- sha1 = "039aa2d7c9196b30d367eac7cb467ecaa726e23d",
+ sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
)
maven_jar(
@@ -875,48 +907,48 @@
sha1 = "7e060dd5b19431e6d198e91ff670644372f60fbd",
)
-JETTY_VERS = "9.4.27.v20200227"
+JETTY_VERS = "9.4.32.v20200930"
maven_jar(
name = "jetty-servlet",
artifact = "org.eclipse.jetty:jetty-servlet:" + JETTY_VERS,
- sha1 = "c6354d1e53c41f839ae56f4d8622c866a1ad8487",
+ sha1 = "4253dd46c099e0bca4dd763fc1e10774e10de00a",
)
maven_jar(
name = "jetty-security",
artifact = "org.eclipse.jetty:jetty-security:" + JETTY_VERS,
- sha1 = "aead56f2a1ac49d720a192cb7c1568e61e34ddae",
+ sha1 = "16a6110fa40e49050146de5f597ab3a3a3fa83b5",
)
maven_jar(
name = "jetty-server",
artifact = "org.eclipse.jetty:jetty-server:" + JETTY_VERS,
- sha1 = "4ef690ce1277e3767d457f87621f2c436a001881",
+ sha1 = "d2d89099be5237cf68254bc943a7d800d3ee1945",
)
maven_jar(
name = "jetty-jmx",
artifact = "org.eclipse.jetty:jetty-jmx:" + JETTY_VERS,
- sha1 = "df66265ec011d8b33a7fa541774257deb957ecb4",
+ sha1 = "5e8e87a6f89b8eabf5b5b1765e3d758209001570",
)
maven_jar(
name = "jetty-http",
artifact = "org.eclipse.jetty:jetty-http:" + JETTY_VERS,
- sha1 = "722ba6ef20eb58c55868f1ce85411e6af13be98e",
+ sha1 = "5fdcefd82178d11f895690f4fe6e843be69394b3",
)
maven_jar(
name = "jetty-io",
artifact = "org.eclipse.jetty:jetty-io:" + JETTY_VERS,
- sha1 = "e85e7c4f298efb36b80cc53d635f2da776aa54c2",
+ sha1 = "0d0f32c3b511d6b3a542787f95ed229731588810",
)
maven_jar(
name = "jetty-util",
artifact = "org.eclipse.jetty:jetty-util:" + JETTY_VERS,
- sha1 = "44087a126227af5196e3e327a5e11aad1b28852c",
+ sha1 = "efefd29006dcc9c9960a679263504287ce4e6896",
)
maven_jar(
@@ -1003,11 +1035,6 @@
yarn_lock = "//:plugins/yarn.lock",
)
-# Install all Bazel dependencies needed for npm packages that supply Bazel rules
-load("@npm//:install_bazel_dependencies.bzl", "install_bazel_dependencies")
-
-install_bazel_dependencies()
-
load("//tools/bzl:js.bzl", "bower_archive", "npm_binary")
# NPM binaries bundled along with their dependencies.
@@ -1199,8 +1226,4 @@
version = "6.5.1",
)
-load("@npm_bazel_typescript//:index.bzl", "ts_setup_workspace")
-
-ts_setup_workspace()
-
external_plugin_deps()
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/CheckNewProjectReplica1.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
new file mode 100644
index 0000000..f15ddae
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/CheckNewProjectReplica1.json
@@ -0,0 +1,6 @@
+[
+ {
+ "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT1/_PROJECT",
+ "cmd": "clone"
+ }
+]
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/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
new file mode 100644
index 0000000..e30a2cf
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.json
@@ -0,0 +1,5 @@
+[
+ {
+ "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/config/server/caches/projects/flush"
+ }
+]
diff --git a/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
new file mode 100644
index 0000000..f6350be
--- /dev/null
+++ b/e2e-tests/src/test/resources/data/com/google/gerrit/scenarios/ListProjects.json
@@ -0,0 +1,5 @@
+[
+ {
+ "url": "HTTP_SCHEME://HOSTNAME:HTTP_PORT/a/projects/"
+ }
+]
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ApproveChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ApproveChange.scala
index e808d0d..8ae69d7 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ApproveChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ApproveChange.scala
@@ -28,7 +28,7 @@
this.createChange = Some(createChange)
}
- val test: ScenarioBuilder = scenario(unique)
+ val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
.exec(session => {
if (createChange.nonEmpty) {
@@ -37,7 +37,7 @@
session
}
})
- .exec(http(unique)
+ .exec(http(uniqueName)
.post("${url}${number}/revisions/current/review")
.body(ElFileBody(body)).asJson)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CacheFlushSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CacheFlushSimulation.scala
index 98d0190..b98ae21 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CacheFlushSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CacheFlushSimulation.scala
@@ -20,7 +20,7 @@
protected var producer: Option[CacheFlushSimulation] = None
protected var consumer: Option[CacheFlushSimulation] = None
- private var cacheEntriesBeforeFlush: Int = 0
+ private var cacheEntriesBeforeFlush = 0
def entriesBeforeFlush(entries: Int): Unit = {
cacheEntriesBeforeFlush = entries
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckMasterBranchReplica1.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckMasterBranchReplica1.scala
index 96269f2..81274de 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckMasterBranchReplica1.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckMasterBranchReplica1.scala
@@ -39,12 +39,12 @@
private val submitChange = new SubmitChange(createChange)
private val getBranch = new GetMasterBranchRevision
- private val test: ScenarioBuilder = scenario(unique)
+ private val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
.exec(session => {
session.set(getBranch.revisionKey, getBranch.revision.get)
})
- .exec(http(unique).get("${url}")
+ .exec(http(uniqueName).get("${url}")
.check(regex(getBranch.revisionPattern)
.is(session => session(getBranch.revisionKey).as[String])))
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
new file mode 100644
index 0000000..ae4fa80
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckNewProjectReplica1.scala
@@ -0,0 +1,58 @@
+// 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.scenarios
+
+import io.gatling.core.Predef._
+import io.gatling.core.feeder.FeederBuilder
+import io.gatling.core.structure.ScenarioBuilder
+
+import scala.concurrent.duration._
+
+class CheckNewProjectReplica1 extends GitSimulation {
+ private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
+ private val projectName = className
+
+ private lazy val replicationDuration = replicationDelay + SecondsPerWeightUnit
+
+ override def relativeRuntimeWeight: Int = replicationDuration / SecondsPerWeightUnit + 2
+
+ override def replaceOverride(in: String): String = {
+ var next = replaceProperty("http_port1", 8081, in)
+ next = replaceKeyWith("_project", projectName, next)
+ super.replaceOverride(next)
+ }
+
+ private val test: ScenarioBuilder = scenario(uniqueName)
+ .feed(data)
+ .exec(gitRequest)
+
+ private val createProject = new CreateProject(projectName)
+ private val deleteProject = new DeleteProject(projectName)
+
+ setUp(
+ createProject.test.inject(
+ nothingFor(stepWaitTime(createProject) seconds),
+ atOnceUsers(single)
+ ),
+ test.inject(
+ nothingFor(stepWaitTime(this) + replicationDuration seconds),
+ atOnceUsers(single)
+ ).protocols(gitProtocol),
+ deleteProject.test.inject(
+ nothingFor(stepWaitTime(deleteProject) seconds),
+ atOnceUsers(single)
+ ),
+ ).protocols(httpProtocol)
+}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.scala
index f1966b3..96943ce 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CheckProjectsCacheFlushEntries.scala
@@ -27,7 +27,7 @@
this.producer = Some(producer)
}
- val test: ScenarioBuilder = scenario(unique)
+ val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
.exec(session => {
if (producer.nonEmpty) {
@@ -36,7 +36,7 @@
session
}
})
- .exec(http(unique).get("${url}")
+ .exec(http(uniqueName).get("${url}")
.check(regex("\"" + memKey + "\": (\\d+)")
.is(session => session(entriesKey).as[String])))
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
index 9f01e9f..08966a8 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CloneUsingBothProtocols.scala
@@ -22,19 +22,19 @@
class CloneUsingBothProtocols extends GitSimulation {
private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
- private val default: String = name
- private val duration: Int = 2
+ private val projectName = className
+ private val duration = 2
override def replaceOverride(in: String): String = {
- replaceKeyWith("_project", default, in)
+ replaceKeyWith("_project", projectName, in)
}
- private val test: ScenarioBuilder = scenario(unique)
+ private val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
.exec(gitRequest)
- private val createProject = new CreateProject(default)
- private val deleteProject = new DeleteProject(default)
+ private val createProject = new CreateProject(projectName)
+ private val deleteProject = new DeleteProject(projectName)
setUp(
createProject.test.inject(
@@ -44,10 +44,10 @@
test.inject(
nothingFor(stepWaitTime(this) seconds),
constantUsersPerSec(single) during (duration seconds)
- ),
+ ).protocols(gitProtocol),
deleteProject.test.inject(
nothingFor(stepWaitTime(deleteProject) + duration seconds),
atOnceUsers(single)
),
- ).protocols(gitProtocol, httpProtocol)
+ ).protocols(httpProtocol)
}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateChange.scala
index ce37777..f3692a9 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/CreateChange.scala
@@ -28,12 +28,12 @@
override def relativeRuntimeWeight = 2
- def this(default: String) {
+ def this(projectName: String) {
this()
- this.default = default
+ this.projectName = projectName
}
- val test: ScenarioBuilder = scenario(unique)
+ val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
.exec(httpRequest
.body(ElFileBody(body)).asJson
@@ -43,8 +43,8 @@
session
})
- private val createProject = new CreateProject(default)
- private val deleteProject = new DeleteProject(default)
+ private val createProject = new CreateProject(projectName)
+ private val deleteProject = new DeleteProject(projectName)
private val deleteChange = new DeleteChange(this)
setUp(
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..25e50b3 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
@@ -21,14 +21,14 @@
class CreateProject extends ProjectSimulation {
private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
- def this(default: String) {
+ def this(projectName: String) {
this()
- this.default = default
+ this.projectName = projectName
}
- val test: ScenarioBuilder = scenario(unique)
+ val test: ScenarioBuilder = scenario(uniqueName)
.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/DeleteChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteChange.scala
index 5a06ff7..d832bde 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteChange.scala
@@ -30,7 +30,7 @@
this.createChange = Some(createChange)
}
- val test: ScenarioBuilder = scenario(unique)
+ val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
.exec(session => {
if (createChange.nonEmpty) {
@@ -39,7 +39,7 @@
session
}
})
- .exec(http(unique).delete("${url}${number}"))
+ .exec(http(uniqueName).delete("${url}${number}"))
setUp(
test.inject(
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala
index 2007eba..1752634 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/DeleteProject.scala
@@ -21,12 +21,12 @@
class DeleteProject extends ProjectSimulation {
private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
- def this(default: String) {
+ def this(projectName: String) {
this()
- this.default = default
+ this.projectName = projectName
}
- val test: ScenarioBuilder = scenario(unique)
+ val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
.exec(httpRequest)
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
index 3dd8493..9fef2cf 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCache.scala
@@ -22,18 +22,18 @@
class FlushProjectsCache extends CacheFlushSimulation {
private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
- private val default: String = name
+ private val projectName = className
override def relativeRuntimeWeight = 2
- private val flushCache: ScenarioBuilder = scenario(unique)
+ private val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
.exec(httpRequest)
- private val createProject = new CreateProject(default)
+ private val createProject = new CreateProject(projectName)
private val getCacheEntriesAfterProject = new GetProjectsCacheEntries(this)
private val checkCacheEntriesAfterFlush = new CheckProjectsCacheFlushEntries(this)
- private val deleteProject = new DeleteProject(default)
+ private val deleteProject = new DeleteProject(projectName)
setUp(
createProject.test.inject(
@@ -44,7 +44,7 @@
nothingFor(stepWaitTime(getCacheEntriesAfterProject) seconds),
atOnceUsers(single)
),
- flushCache.inject(
+ test.inject(
nothingFor(stepWaitTime(this) seconds),
atOnceUsers(single)
),
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.scala
new file mode 100644
index 0000000..07b0c0b
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/FlushProjectsCacheThenRebuild.scala
@@ -0,0 +1,47 @@
+// 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.scenarios
+
+import io.gatling.core.Predef._
+import io.gatling.core.feeder.FeederBuilder
+import io.gatling.core.structure.ScenarioBuilder
+
+import scala.concurrent.duration._
+
+class FlushProjectsCacheThenRebuild extends GerritSimulation {
+ private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
+
+ private val test: ScenarioBuilder = scenario(uniqueName)
+ .feed(data)
+ .exec(httpRequest)
+
+ private val checkCacheEntriesAfterFlush = new CheckProjectsCacheFlushEntries
+ private val rebuildCache = new ListProjects
+
+ setUp(
+ test.inject(
+ nothingFor(stepWaitTime(this) seconds),
+ atOnceUsers(single)
+ ),
+ checkCacheEntriesAfterFlush.test.inject(
+ nothingFor(stepWaitTime(checkCacheEntriesAfterFlush) seconds),
+ atOnceUsers(single)
+ ),
+ rebuildCache.test.inject(
+ nothingFor(stepWaitTime(rebuildCache) seconds),
+ atOnceUsers(single)
+ ),
+ ).protocols(httpProtocol)
+}
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..7b31b3d 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
@@ -23,19 +23,22 @@
class GerritSimulation extends Simulation {
implicit val conf: GatlingGitConfiguration = GatlingGitConfiguration()
- private val pack: String = this.getClass.getPackage.getName
- private val path: String = pack.replaceAllLiterally(".", "/")
- protected val name: String = this.getClass.getSimpleName
- private val pathName: String = s"data/$path/$name"
- protected val resource: String = s"$pathName.json"
- protected val body: String = s"$pathName-body.json"
- protected val unique: String = name + "-" + this.hashCode()
+ private val packageName = getClass.getPackage.getName
+ private val path = packageName.replaceAllLiterally(".", "/")
+
+ protected val className: String = getClass.getSimpleName
+ private val pathName = s"data/$path/$className"
+ protected val resource = s"$pathName.json"
+ protected val body = s"$pathName-body.json"
+
+ protected val uniqueName: String = className + "-" + hashCode()
protected val single = 1
- private val powerFactor: Double = replaceProperty("power_factor", 1.0).toDouble
- protected val SecondsPerWeightUnit: Int = 2
+ val replicationDelay: Int = replaceProperty("replication_delay", 15).toInt
+ private val powerFactor = replaceProperty("power_factor", 1.0).toDouble
+ protected val SecondsPerWeightUnit = 2
val maxExecutionTime: Int = (SecondsPerWeightUnit * relativeRuntimeWeight * powerFactor).toInt
- private var cumulativeWaitTime: Int = 0
+ private var cumulativeWaitTime = 0
/**
* How long a scenario step should wait before starting to execute.
@@ -52,27 +55,29 @@
currentWaitTime
}
- protected val httpRequest: HttpRequestBuilder = http(unique).post("${url}")
+ protected val httpRequest: HttpRequestBuilder = http(uniqueName).post("${url}")
protected val httpProtocol: HttpProtocolBuilder = http.basicAuth(
conf.httpConfiguration.userName,
conf.httpConfiguration.password)
protected val keys: PartialFunction[(String, Any), Any] = {
+ case ("entries", entries) =>
+ replaceProperty("projects_entries", "1", entries.toString)
+ 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", className, project.toString)
+ precedes = replaceOverride(precedes)
+ replaceProperty("project", precedes)
case ("url", url) =>
var in = replaceOverride(url.toString)
in = replaceProperty("hostname", "localhost", in)
in = replaceProperty("http_port", 8080, in)
in = replaceProperty("http_scheme", "http", in)
replaceProperty("ssh_port", 29418, in)
- case ("number", number) =>
- val precedes = replaceKeyWith("_number", 0, number.toString)
- replaceProperty("number", 1, precedes)
- case ("project", project) =>
- var precedes = replaceKeyWith("_project", name, project.toString)
- precedes = replaceOverride(precedes)
- replaceProperty("project", precedes)
- case ("entries", entries) =>
- replaceProperty("projects_entries", "1", entries.toString)
}
private def replaceProperty(term: String, in: String): String = {
@@ -84,7 +89,7 @@
}
protected def replaceProperty(term: String, default: Any, in: String): String = {
- val property = pack + "." + term
+ val property = packageName + "." + term
var value = default
default match {
case _: String | _: Double =>
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GetMasterBranchRevision.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GetMasterBranchRevision.scala
index 4ceba60..1137ad5 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GetMasterBranchRevision.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GetMasterBranchRevision.scala
@@ -25,9 +25,9 @@
val revisionKey = "revision"
val revisionPattern: String = "\"" + revisionKey + "\": \"(.+)\""
- val test: ScenarioBuilder = scenario(unique)
+ val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
- .exec(http(unique).get("${url}")
+ .exec(http(uniqueName).get("${url}")
.check(regex(revisionPattern).saveAs(revisionKey)))
.exec(session => {
revision = Some(session(revisionKey).as[String])
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GetProjectsCacheEntries.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GetProjectsCacheEntries.scala
index e73559e..266c0b9 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GetProjectsCacheEntries.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/GetProjectsCacheEntries.scala
@@ -27,9 +27,9 @@
this.consumer = Some(consumer)
}
- val test: ScenarioBuilder = scenario(unique)
+ val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
- .exec(http(unique).get("${url}")
+ .exec(http(uniqueName).get("${url}")
.check(regex("\"" + memKey + "\": (\\d+)").saveAs(entriesKey)))
.exec(session => {
if (consumer.nonEmpty) {
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ListProjects.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ListProjects.scala
new file mode 100644
index 0000000..26aed0b
--- /dev/null
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ListProjects.scala
@@ -0,0 +1,33 @@
+// 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.scenarios
+
+import io.gatling.core.Predef._
+import io.gatling.core.feeder.FeederBuilder
+import io.gatling.core.structure.ScenarioBuilder
+import io.gatling.http.Predef.http
+
+class ListProjects extends GerritSimulation {
+ private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
+
+ val test: ScenarioBuilder = scenario(uniqueName)
+ .feed(data)
+ .exec(http(uniqueName).get("${url}"))
+
+ setUp(
+ test.inject(
+ atOnceUsers(single)
+ )).protocols(httpProtocol)
+}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
index 141c3cf..d6cb937 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ProjectSimulation.scala
@@ -15,9 +15,9 @@
package com.google.gerrit.scenarios
class ProjectSimulation extends GerritSimulation {
- protected var default: String = "project"
+ protected var projectName: String = "defaultTestProject"
override def replaceOverride(in: String): String = {
- replaceProperty("project", default, in)
+ replaceProperty("project", projectName, in)
}
}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
index 1af2dc5..0bd9e4a 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/ReplayRecordsFromFeeder.scala
@@ -22,22 +22,22 @@
class ReplayRecordsFromFeeder extends GitSimulation {
private val data: FeederBuilder = jsonFile(resource).convert(keys).circular
- private val default: String = name
+ private val projectName = className
override def relativeRuntimeWeight = 30
override def replaceOverride(in: String): String = {
- replaceKeyWith("_project", default, in)
+ replaceKeyWith("_project", projectName, in)
}
- private val test: ScenarioBuilder = scenario(unique)
+ private val test: ScenarioBuilder = scenario(uniqueName)
.repeat(10) {
feed(data)
.exec(gitRequest)
}
- private val createProject = new CreateProject(default)
- private val deleteProject = new DeleteProject(default)
+ private val createProject = new CreateProject(projectName)
+ private val deleteProject = new DeleteProject(projectName)
private val maxBeforeDelete: Int = maxExecutionTime - deleteProject.maxExecutionTime
setUp(
@@ -51,11 +51,11 @@
rampUsers(10) during (5 seconds),
constantUsersPerSec(20) during (15 seconds),
constantUsersPerSec(20) during (15 seconds) randomized
- ),
+ ).protocols(gitProtocol),
deleteProject.test.inject(
nothingFor(maxBeforeDelete seconds),
atOnceUsers(single)
),
- ).protocols(gitProtocol, httpProtocol)
+ ).protocols(httpProtocol)
.maxDuration(maxExecutionTime seconds)
}
diff --git a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
index 48f9fa8..067496a 100644
--- a/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
+++ b/e2e-tests/src/test/scala/com/google/gerrit/scenarios/SubmitChange.scala
@@ -23,8 +23,8 @@
class SubmitChange extends GerritSimulation {
private val data: FeederBuilder = jsonFile(resource).convert(keys).queue
- private val default: String = name
- private var createChange = new CreateChange(default)
+ private val projectName = className
+ private var createChange = new CreateChange(projectName)
override def relativeRuntimeWeight = 10
@@ -33,16 +33,16 @@
this.createChange = createChange
}
- val test: ScenarioBuilder = scenario(unique)
+ val test: ScenarioBuilder = scenario(uniqueName)
.feed(data)
.exec(session => {
session.set("number", createChange.number)
})
- .exec(http(unique).post("${url}${number}/submit"))
+ .exec(http(uniqueName).post("${url}${number}/submit"))
- private val createProject = new CreateProject(default)
+ private val createProject = new CreateProject(projectName)
private val approveChange = new ApproveChange(createChange)
- private val deleteProject = new DeleteProject(default)
+ private val deleteProject = new DeleteProject(projectName)
setUp(
createProject.test.inject(
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 0cd0402..f59daf5 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -49,12 +49,7 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Address;
@@ -64,8 +59,13 @@
import com.google.gerrit.entities.EmailHeader;
import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.GerritApi;
@@ -168,6 +168,8 @@
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
@@ -388,6 +390,15 @@
initSsh();
}
+ protected void restart() throws Exception {
+ server = GerritServer.restart(server, createModule(), createSshModule());
+ server.getTestInjector().injectMembers(this);
+ if (resetter != null) {
+ server.getTestInjector().injectMembers(resetter);
+ }
+ initSsh();
+ }
+
protected void reindexAccount(Account.Id accountId) {
accountIndexer.index(accountId);
}
@@ -429,13 +440,19 @@
baseConfig.setInt("receive", null, "changeUpdateThreads", 4);
Module module = createModule();
+ Module auditModule = createAuditModule();
+ Module sshModule = createSshModule();
if (classDesc.equals(methodDesc) && !classDesc.sandboxed() && !methodDesc.sandboxed()) {
if (commonServer == null) {
- commonServer = GerritServer.initAndStart(temporaryFolder, classDesc, baseConfig, module);
+ commonServer =
+ GerritServer.initAndStart(
+ temporaryFolder, classDesc, baseConfig, module, auditModule, sshModule);
}
server = commonServer;
} else {
- server = GerritServer.initAndStart(temporaryFolder, methodDesc, baseConfig, module);
+ server =
+ GerritServer.initAndStart(
+ temporaryFolder, methodDesc, baseConfig, module, auditModule, sshModule);
}
server.getTestInjector().injectMembers(this);
@@ -528,6 +545,16 @@
return null;
}
+ /** Override to bind an alternative audit Guice module */
+ public Module createAuditModule() {
+ return null;
+ }
+
+ /** Override to bind an additional Guice module for SSH injector */
+ public Module createSshModule() {
+ return null;
+ }
+
protected void initSsh() throws Exception {
if (testRequiresSsh
&& SshMode.useSsh()
@@ -742,6 +769,58 @@
return result;
}
+ protected PushOneCommit.Result createNParentsMergeCommitChange(String ref, List<String> fileNames)
+ throws Exception {
+ // This method creates n different commits and creates a merge commit pointing to all n parents.
+ // Each commit will contain all the fileNames. Commit i will have the following file names and
+ // their contents:
+ // {$file_1_name, ${file_1_name}-1}
+ // {$file_2_name, ${file_2_name}-1}, etc...
+ // The merge commit will have:
+ // {$file_1_name, ${file_1_name}-1}
+ // {$file_2_name, ${file_2_name}-2},
+ // {$file_3_name, ${file_3_name}-3}, etc...
+ // i.e. taking the ith file from the ith commit.
+ int n = fileNames.size();
+ ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+
+ List<PushOneCommit.Result> pushResults = new ArrayList<>();
+
+ for (int i = 1; i <= n; i++) {
+ int finalI = i;
+ pushResults.add(
+ pushFactory
+ .create(
+ admin.newIdent(),
+ testRepo,
+ "parent " + i,
+ fileNames.stream().collect(Collectors.toMap(f -> f, f -> f + "-" + finalI)))
+ .to(ref));
+
+ // reset HEAD in order to create a sibling of the first change
+ if (i < n) {
+ testRepo.reset(initial);
+ }
+ }
+
+ PushOneCommit m =
+ pushFactory.create(
+ admin.newIdent(),
+ testRepo,
+ "merge",
+ IntStream.range(1, n + 1)
+ .boxed()
+ .collect(
+ Collectors.toMap(
+ i -> fileNames.get((int) i - 1),
+ i -> fileNames.get((int) i - 1) + "-" + i)));
+
+ m.setParents(pushResults.stream().map(PushOneCommit.Result::getCommit).collect(toList()));
+ PushOneCommit.Result result = m.to(ref);
+ result.assertOkStatus();
+ return result;
+ }
+
protected PushOneCommit.Result createCommitAndPush(
TestRepository<InMemoryRepository> repo,
String ref,
@@ -802,6 +881,24 @@
private static final List<Character> RANDOM =
Chars.asList('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h');
+ protected PushOneCommit.Result amendChangeWithUploader(
+ PushOneCommit.Result change, Project.NameKey projectName, TestAccount account)
+ throws Exception {
+ TestRepository<InMemoryRepository> repo = cloneProject(projectName, account);
+ GitUtil.fetch(repo, "refs/*:refs/*");
+ repo.reset(change.getCommit());
+ PushOneCommit.Result result =
+ amendChange(
+ change.getChangeId(),
+ "refs/for/master",
+ account,
+ repo,
+ "new subject",
+ "new file",
+ "new content");
+ return result;
+ }
+
protected PushOneCommit.Result amendChange(String changeId) throws Exception {
return amendChange(changeId, "refs/for/master", admin, testRepo);
}
@@ -1369,6 +1466,7 @@
pwi.filter = filter;
pwi.notifyAbandonedChanges = true;
pwi.notifyNewChanges = true;
+ pwi.notifyNewPatchSets = true;
pwi.notifyAllComments = true;
});
}
@@ -1513,7 +1611,8 @@
protected List<CommentInfo> getChangeSortedComments(int changeNum) throws Exception {
List<CommentInfo> comments = new ArrayList<>();
- Map<String, List<CommentInfo>> commentsMap = gApi.changes().id(changeNum).comments();
+ Map<String, List<CommentInfo>> commentsMap =
+ gApi.changes().id(changeNum).commentsRequest().get();
for (Map.Entry<String, List<CommentInfo>> e : commentsMap.entrySet()) {
for (CommentInfo c : e.getValue()) {
c.path = e.getKey(); // Set the comment's path field.
diff --git a/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java b/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java
new file mode 100644
index 0000000..6acf486
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/AbstractLifecycleListenersTest.java
@@ -0,0 +1,109 @@
+// 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.acceptance;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.gerrit.sshd.commands.Query;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.google.inject.internal.UniqueAnnotations;
+import java.util.Collections;
+import org.kohsuke.args4j.Option;
+
+public class AbstractLifecycleListenersTest extends AbstractDaemonTest {
+ protected static class SimpleModule extends AbstractModule {
+ @Override
+ public void configure() {
+ bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+ .annotatedWith(Exports.named(Query.class))
+ .to(MyClassNameProvider.class);
+ bind(DynamicOptions.DynamicBean.class)
+ .annotatedWith(Exports.named(QueryChanges.class))
+ .to(MyClassNameProvider.class);
+ }
+ }
+
+ protected static class MyClassNameProvider implements DynamicOptions.ModulesClassNamesProvider {
+ @Override
+ public String getClassName() {
+ return "com.google.gerrit.acceptance.AbstractLifecycleListenersTest$MyOptions";
+ }
+
+ @Override
+ public Iterable<String> getModulesClassNames() {
+ return Collections.singleton(
+ "com.google.gerrit.acceptance.AbstractLifecycleListenersTest$MyOptions$MyOptionsModule");
+ }
+ }
+
+ public static class MyOptions implements DynamicOptions.DynamicBean {
+ @Option(name = "--opt")
+ public boolean opt;
+
+ public static class MyOptionsModule extends AbstractModule {
+ @Override
+ protected void configure() {
+ bind(LifecycleListener.class)
+ .annotatedWith(UniqueAnnotations.create())
+ .to(MyLifecycleListener.class);
+ }
+ }
+ }
+
+ protected static class MyLifecycleListener implements LifecycleListener {
+ protected final InvocationCheck invocationCheck;
+
+ @Inject
+ public MyLifecycleListener(InvocationCheck invocationCheck) {
+ this.invocationCheck = invocationCheck;
+ }
+
+ @Override
+ public void start() {
+ invocationCheck.setStartInvoked(true);
+ }
+
+ @Override
+ public void stop() {
+ invocationCheck.setStopInvoked(true);
+ }
+ }
+
+ @Singleton
+ public static class InvocationCheck {
+ private boolean isStartInvoked = false;
+ private boolean isStopInvoked = false;
+
+ public boolean isStartInvoked() {
+ return isStartInvoked;
+ }
+
+ public void setStartInvoked(boolean startInvoked) {
+ isStartInvoked = startInvoked;
+ }
+
+ public boolean isStopInvoked() {
+ return isStopInvoked;
+ }
+
+ public void setStopInvoked(boolean stopInvoked) {
+ isStopInvoked = stopInvoked;
+ }
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
index 020602b..a91bc49 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginFieldsTest.java
@@ -21,26 +21,36 @@
import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.PluginDefinedInfo;
import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.DynamicOptions.DynamicBean;
import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
+import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.restapi.change.GetChange;
import com.google.gerrit.server.restapi.change.QueryChanges;
import com.google.gerrit.sshd.commands.Query;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
import com.google.inject.Module;
+import java.util.Collection;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import org.kohsuke.args4j.Option;
public class AbstractPluginFieldsTest extends AbstractDaemonTest {
+ @Inject private ChangeOperations changeOperations;
+
protected static class MyInfo extends PluginDefinedInfo {
@Nullable String theAttribute;
@@ -91,6 +101,70 @@
}
}
+ protected static class PluginDefinedSimpleAttributeModule extends AbstractModule {
+ @Override
+ public void configure() {
+ DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+ .toInstance(
+ (cds, bp, p) -> {
+ Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+ cds.forEach(cd -> out.put(cd.getId(), new MyInfo("change " + cd.getId())));
+ return out;
+ });
+ }
+ }
+
+ protected static class PluginDefinedBulkExceptionModule extends AbstractModule {
+ @Override
+ protected void configure() {
+ DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+ .toInstance(
+ (cds, bp, p) -> {
+ throw new RuntimeException("Sample Exception");
+ });
+ }
+ }
+
+ protected static class PluginDefinedChangesByCommitBulkAttributeModule extends AbstractModule {
+ @Override
+ public void configure() {
+ DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+ .toInstance(
+ (cds, bp, p) -> {
+ Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+ cds.forEach(
+ cd ->
+ out.put(
+ cd.getId(),
+ !cd.commitMessage().contains("no-info")
+ ? new MyInfo("change " + cd.getId())
+ : null));
+ return out;
+ });
+ }
+ }
+
+ protected static class PluginDefinedSingleCallBulkAttributeModule extends AbstractModule {
+ @Override
+ public void configure() {
+ DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+ .to(SingleCallBulkFactoryAttribute.class);
+ }
+ }
+
+ protected static class SingleCallBulkFactoryAttribute implements ChangePluginDefinedInfoFactory {
+ public static int timesCreateCalled = 0;
+
+ @Override
+ public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+ Collection<ChangeData> cds, DynamicOptions.BeanProvider beanProvider, String plugin) {
+ timesCreateCalled++;
+ Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+ cds.forEach(cd -> out.put(cd.getId(), new MyInfo("change " + cd.getId())));
+ return out;
+ }
+ }
+
private static class MyOptions implements DynamicBean {
@Option(name = "--opt")
private String opt;
@@ -111,6 +185,32 @@
}
}
+ public static class BulkAttributeFactoryWithOption implements ChangePluginDefinedInfoFactory {
+ protected MyOptions opts;
+
+ @Override
+ public Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+ Collection<ChangeData> cds, DynamicOptions.BeanProvider beanProvider, String plugin) {
+ if (opts == null) {
+ opts = (MyOptions) beanProvider.getDynamicBean(plugin);
+ }
+ Map<Change.Id, PluginDefinedInfo> out = new HashMap<>();
+ cds.forEach(cd -> out.put(cd.getId(), new MyInfo("opt " + opts.opt)));
+ return out;
+ }
+ }
+
+ protected static class PluginDefinedOptionAttributeModule extends AbstractModule {
+ @Override
+ public void configure() {
+ DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class)
+ .to(BulkAttributeFactoryWithOption.class);
+ bind(DynamicBean.class).annotatedWith(Exports.named(Query.class)).to(MyOptions.class);
+ bind(DynamicBean.class).annotatedWith(Exports.named(QueryChanges.class)).to(MyOptions.class);
+ bind(DynamicBean.class).annotatedWith(Exports.named(GetChange.class)).to(MyOptions.class);
+ }
+ }
+
protected void getChangeWithNullAttribute(PluginInfoGetter getter) throws Exception {
Change.Id id = createChange().getChange().getId();
assertThat(getter.call(id)).isNull();
@@ -138,6 +238,113 @@
assertThat(getter.call(id)).isNull();
}
+ protected void getSingleChangeWithPluginDefinedBulkAttribute(BulkPluginInfoGetterWithId getter)
+ throws Exception {
+ Change.Id id = createChange().getChange().getId();
+
+ Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call(id);
+ assertThat(pluginInfos.get(id)).isNull();
+
+ try (AutoCloseable ignored =
+ installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+ pluginInfos = getter.call(id);
+ assertThat(pluginInfos.get(id)).containsExactly(new MyInfo("my-plugin", "change " + id));
+ }
+
+ pluginInfos = getter.call(id);
+ assertThat(pluginInfos.get(id)).isNull();
+ }
+
+ protected void getMultipleChangesWithPluginDefinedBulkAttribute(BulkPluginInfoGetter getter)
+ throws Exception {
+ Change.Id id1 = createChange().getChange().getId();
+ Change.Id id2 = createChange().getChange().getId();
+
+ Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+ assertThat(pluginInfos.get(id1)).isNull();
+ assertThat(pluginInfos.get(id2)).isNull();
+
+ try (AutoCloseable ignored =
+ installPlugin("my-plugin", PluginDefinedSimpleAttributeModule.class)) {
+ pluginInfos = getter.call();
+ assertThat(pluginInfos.get(id1)).containsExactly(new MyInfo("my-plugin", "change " + id1));
+ assertThat(pluginInfos.get(id2)).containsExactly(new MyInfo("my-plugin", "change " + id2));
+ }
+
+ pluginInfos = getter.call();
+ assertThat(pluginInfos.get(id1)).isNull();
+ assertThat(pluginInfos.get(id2)).isNull();
+ }
+
+ protected void getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+ BulkPluginInfoGetter getter) throws Exception {
+ Change.Id changeWithNoInfo = changeOperations.newChange().commitMessage("no-info").create();
+ Change.Id changeWithInfo = changeOperations.newChange().commitMessage("info").create();
+
+ Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+ assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+ assertThat(pluginInfos.get(changeWithInfo)).isNull();
+
+ try (AutoCloseable ignored =
+ installPlugin("my-plugin", PluginDefinedChangesByCommitBulkAttributeModule.class)) {
+ pluginInfos = getter.call();
+ assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+ assertThat(pluginInfos.get(changeWithInfo))
+ .containsExactly(new MyInfo("my-plugin", "change " + changeWithInfo));
+ }
+
+ pluginInfos = getter.call();
+ assertThat(pluginInfos.get(changeWithNoInfo)).isNull();
+ assertThat(pluginInfos.get(changeWithInfo)).isNull();
+ }
+
+ protected void getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+ BulkPluginInfoGetter getter) throws Exception {
+ Change.Id id1 = createChange().getChange().getId();
+ Change.Id id2 = createChange().getChange().getId();
+
+ Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+ assertThat(pluginInfos.get(id1)).isNull();
+ assertThat(pluginInfos.get(id2)).isNull();
+
+ try (AutoCloseable ignored =
+ installPlugin("my-plugin-1", PluginDefinedSimpleAttributeModule.class);
+ AutoCloseable ignored1 = installPlugin("my-plugin-2", SimpleAttributeModule.class)) {
+ pluginInfos = getter.call();
+ assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-1", "change " + id1));
+ assertThat(pluginInfos.get(id1)).contains(new MyInfo("my-plugin-2", "change " + id1));
+ assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-1", "change " + id2));
+ assertThat(pluginInfos.get(id2)).contains(new MyInfo("my-plugin-2", "change " + id2));
+ }
+
+ pluginInfos = getter.call();
+ assertThat(pluginInfos.get(id1)).isNull();
+ assertThat(pluginInfos.get(id2)).isNull();
+ }
+
+ protected void getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+ BulkPluginInfoGetter getter) throws Exception {
+ Change.Id id1 = createChange().getChange().getId();
+ Change.Id id2 = createChange().getChange().getId();
+ int timesCalled = SingleCallBulkFactoryAttribute.timesCreateCalled;
+
+ Map<Change.Id, List<PluginDefinedInfo>> pluginInfos = getter.call();
+ assertThat(pluginInfos.get(id1)).isNull();
+ assertThat(pluginInfos.get(id2)).isNull();
+
+ try (AutoCloseable ignored =
+ installPlugin("my-plugin", PluginDefinedSingleCallBulkAttributeModule.class)) {
+ pluginInfos = getter.call();
+ assertThat(pluginInfos.get(id1)).containsExactly(new MyInfo("my-plugin", "change " + id1));
+ assertThat(pluginInfos.get(id2)).containsExactly(new MyInfo("my-plugin", "change " + id2));
+ assertThat(SingleCallBulkFactoryAttribute.timesCreateCalled).isEqualTo(timesCalled + 1);
+ }
+
+ pluginInfos = getter.call();
+ assertThat(pluginInfos.get(id1)).isNull();
+ assertThat(pluginInfos.get(id2)).isNull();
+ }
+
protected void getChangeWithOption(
PluginInfoGetter getterWithoutOptions, PluginInfoGetterWithOptions getterWithOptions)
throws Exception {
@@ -154,17 +361,61 @@
assertThat(getterWithoutOptions.call(id)).isNull();
}
- protected static List<MyInfo> pluginInfoFromSingletonList(List<ChangeInfo> changeInfos) {
+ protected void getChangeWithPluginDefinedBulkAttributeOption(
+ BulkPluginInfoGetterWithId getterWithoutOptions,
+ BulkPluginInfoGetterWithIdAndOptions getterWithOptions)
+ throws Exception {
+ Change.Id id = createChange().getChange().getId();
+ assertThat(getterWithoutOptions.call(id).get(id)).isNull();
+
+ try (AutoCloseable ignored =
+ installPlugin("my-plugin", PluginDefinedOptionAttributeModule.class)) {
+ assertThat(getterWithoutOptions.call(id).get(id))
+ .containsExactly(new MyInfo("my-plugin", "opt null"));
+ assertThat(
+ getterWithOptions.call(id, ImmutableListMultimap.of("my-plugin--opt", "foo")).get(id))
+ .containsExactly(new MyInfo("my-plugin", "opt foo"));
+ }
+
+ assertThat(getterWithoutOptions.call(id).get(id)).isNull();
+ }
+
+ protected void getChangeWithPluginDefinedBulkAttributeWithException(
+ BulkPluginInfoGetterWithId getter) throws Exception {
+ Change.Id id = createChange().getChange().getId();
+ assertThat(getter.call(id).get(id)).isNull();
+
+ try (AutoCloseable ignored =
+ installPlugin("my-plugin", PluginDefinedBulkExceptionModule.class)) {
+ PluginDefinedInfo errorInfo = new PluginDefinedInfo();
+ List<PluginDefinedInfo> outputInfos = getter.call(id).get(id);
+ assertThat(outputInfos).hasSize(1);
+ assertThat(outputInfos.get(0).name).isEqualTo("my-plugin");
+ assertThat(outputInfos.get(0).message).isEqualTo("Something went wrong in plugin: my-plugin");
+ }
+
+ assertThat(getter.call(id).get(id)).isNull();
+ }
+
+ protected static List<PluginDefinedInfo> pluginInfoFromSingletonList(
+ List<ChangeInfo> changeInfos) {
assertThat(changeInfos).hasSize(1);
return pluginInfoFromChangeInfo(changeInfos.get(0));
}
- protected static List<MyInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
+ protected static List<PluginDefinedInfo> pluginInfoFromChangeInfo(ChangeInfo changeInfo) {
List<PluginDefinedInfo> pluginInfo = changeInfo.plugins;
if (pluginInfo == null) {
return null;
}
- return pluginInfo.stream().map(MyInfo.class::cast).collect(toImmutableList());
+ return pluginInfo.stream().map(PluginDefinedInfo.class::cast).collect(toImmutableList());
+ }
+
+ protected static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromChangeInfos(
+ List<ChangeInfo> changeInfos) {
+ Map<Change.Id, List<PluginDefinedInfo>> out = new HashMap<>();
+ changeInfos.forEach(ci -> out.put(Change.id(ci._number), pluginInfoFromChangeInfo(ci)));
+ return out;
}
/**
@@ -180,7 +431,8 @@
* @param plugins list of {@code MyInfo} objects, each as a raw map returned from Gson.
* @return decoded list of {@code MyInfo}s.
*/
- protected static List<MyInfo> decodeRawPluginsList(Gson gson, @Nullable Object plugins) {
+ protected static List<PluginDefinedInfo> decodeRawPluginsList(
+ Gson gson, @Nullable Object plugins) {
if (plugins == null) {
return null;
}
@@ -188,14 +440,44 @@
return gson.fromJson(gson.toJson(plugins), new TypeToken<List<MyInfo>>() {}.getType());
}
+ protected static Map<Change.Id, List<PluginDefinedInfo>> getPluginInfosFromChangeInfos(
+ Gson gson, List<Map<String, Object>> changeInfos) {
+ Map<Change.Id, List<PluginDefinedInfo>> out = new HashMap<>();
+ changeInfos.forEach(
+ change -> {
+ Double changeId =
+ (Double)
+ (change.get("number") != null ? change.get("number") : change.get("_number"));
+ out.put(
+ Change.id(changeId.intValue()), decodeRawPluginsList(gson, change.get("plugins")));
+ });
+ return out;
+ }
+
@FunctionalInterface
protected interface PluginInfoGetter {
- List<MyInfo> call(Change.Id id) throws Exception;
+ List<PluginDefinedInfo> call(Change.Id id) throws Exception;
+ }
+
+ @FunctionalInterface
+ protected interface BulkPluginInfoGetter {
+ Map<Change.Id, List<PluginDefinedInfo>> call() throws Exception;
+ }
+
+ @FunctionalInterface
+ protected interface BulkPluginInfoGetterWithId {
+ Map<Change.Id, List<PluginDefinedInfo>> call(Change.Id id) throws Exception;
+ }
+
+ @FunctionalInterface
+ protected interface BulkPluginInfoGetterWithIdAndOptions {
+ Map<Change.Id, List<PluginDefinedInfo>> call(
+ Change.Id id, ImmutableListMultimap<String, String> pluginOptions) throws Exception;
}
@FunctionalInterface
protected interface PluginInfoGetterWithOptions {
- List<MyInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
+ List<PluginDefinedInfo> call(Change.Id id, ImmutableListMultimap<String, String> pluginOptions)
throws Exception;
}
}
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 44e2d2d..6897488 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -26,6 +26,7 @@
import com.google.gerrit.server.ServerInitiated;
import com.google.gerrit.server.account.AccountsUpdate;
import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.group.InternalGroup;
import com.google.gerrit.server.group.db.GroupsUpdate;
@@ -101,6 +102,7 @@
.setPreferredEmail(email)
.addExternalIds(extIds));
+ ImmutableList.Builder<String> tags = ImmutableList.builder();
if (groupNames != null) {
for (String n : groupNames) {
AccountGroup.NameKey k = AccountGroup.nameKey(n);
@@ -109,10 +111,14 @@
throw new NoSuchGroupException(n);
}
addGroupMember(group.get().getGroupUUID(), id);
+ if (ServiceUserClassifier.SERVICE_USERS.equals(n)) {
+ tags.add("SERVICE_USER");
+ }
}
}
- account = TestAccount.create(id, username, email, fullName, displayName, httpPass);
+ account =
+ TestAccount.create(id, username, email, fullName, displayName, httpPass, tags.build());
if (username != null) {
accounts.put(username, account);
}
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index a5d8d19..03644a6 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -26,6 +26,7 @@
import com.google.gerrit.extensions.events.GroupIndexedListener;
import com.google.gerrit.extensions.events.ProjectIndexedListener;
import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.google.gerrit.extensions.events.TopicEditedListener;
import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.registration.DynamicSet;
@@ -58,6 +59,7 @@
private final DynamicSet<GroupIndexedListener> groupIndexedListeners;
private final DynamicSet<ProjectIndexedListener> projectIndexedListeners;
private final DynamicSet<CommitValidationListener> commitValidationListeners;
+ private final DynamicSet<TopicEditedListener> topicEditedListeners;
private final DynamicSet<ExceptionHook> exceptionHooks;
private final DynamicSet<PerformanceLogger> performanceLoggers;
private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
@@ -89,6 +91,7 @@
DynamicSet<GroupIndexedListener> groupIndexedListeners,
DynamicSet<ProjectIndexedListener> projectIndexedListeners,
DynamicSet<CommitValidationListener> commitValidationListeners,
+ DynamicSet<TopicEditedListener> topicEditedListeners,
DynamicSet<ExceptionHook> exceptionHooks,
DynamicSet<PerformanceLogger> performanceLoggers,
DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
@@ -116,6 +119,7 @@
this.groupIndexedListeners = groupIndexedListeners;
this.projectIndexedListeners = projectIndexedListeners;
this.commitValidationListeners = commitValidationListeners;
+ this.topicEditedListeners = topicEditedListeners;
this.exceptionHooks = exceptionHooks;
this.performanceLoggers = performanceLoggers;
this.projectCreationValidationListeners = projectCreationValidationListeners;
@@ -168,6 +172,10 @@
return add(commitValidationListeners, commitValidationListener);
}
+ public Registration add(TopicEditedListener topicEditedListener) {
+ return add(topicEditedListeners, topicEditedListener);
+ }
+
public Registration add(ExceptionHook exceptionHook) {
return add(exceptionHooks, exceptionHook);
}
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 2d62608..0025396 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -30,6 +30,12 @@
import com.google.gerrit.acceptance.config.GlobalPluginConfigs;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.acceptance.testsuite.account.AccountOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.PerCommentOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.PerDraftCommentOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.PerPatchsetOperationsImpl;
+import com.google.gerrit.acceptance.testsuite.change.PerRobotCommentOperationsImpl;
import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.acceptance.testsuite.group.GroupOperationsImpl;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -317,6 +323,7 @@
* @param desc server description.
* @param baseConfig default config values; merged with config from {@code desc}.
* @param testSysModule additional Guice module to use.
+ * @param testSshModule additional Guice module to use.
* @return started server.
* @throws Exception
*/
@@ -324,14 +331,16 @@
TemporaryFolder temporaryFolder,
Description desc,
Config baseConfig,
- @Nullable Module testSysModule)
+ @Nullable Module testSysModule,
+ @Nullable Module testAuditModule,
+ @Nullable Module testSshModule)
throws Exception {
Path site = temporaryFolder.newFolder().toPath();
try {
if (!desc.memory()) {
init(desc, baseConfig, site);
}
- return start(desc, baseConfig, site, testSysModule, null);
+ return start(desc, baseConfig, site, testSysModule, testAuditModule, testSshModule, null);
} catch (Exception e) {
throw e;
}
@@ -347,6 +356,7 @@
* initialize this directory. Can be retrieved from the returned instance via {@link
* #getSitePath()}.
* @param testSysModule optional additional module to add to the system injector.
+ * @param testSshModule optional additional module to add to the ssh injector.
* @param inMemoryRepoManager {@link InMemoryRepositoryManager} that should be used if the site is
* started in memory
* @param additionalArgs additional command-line arguments for the daemon program; only allowed if
@@ -359,6 +369,8 @@
Config baseConfig,
Path site,
@Nullable Module testSysModule,
+ @Nullable Module testAuditModule,
+ @Nullable Module testSshModule,
@Nullable InMemoryRepositoryManager inMemoryRepoManager,
String... additionalArgs)
throws Exception {
@@ -377,10 +389,14 @@
},
site);
daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
- daemon.setAuditEventModuleForTesting(new FakeGroupAuditService.Module());
+ daemon.setAuditEventModuleForTesting(
+ MoreObjects.firstNonNull(testAuditModule, new FakeGroupAuditService.Module()));
if (testSysModule != null) {
daemon.addAdditionalSysModuleForTesting(testSysModule);
}
+ if (testSshModule != null) {
+ daemon.addAdditionalSshModuleForTesting(testSshModule);
+ }
daemon.setEnableSshd(desc.useSsh());
if (desc.memory()) {
@@ -506,6 +522,11 @@
bind(GroupOperations.class).to(GroupOperationsImpl.class);
bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
bind(RequestScopeOperations.class).to(RequestScopeOperationsImpl.class);
+ bind(ChangeOperations.class).to(ChangeOperationsImpl.class);
+ factory(PerPatchsetOperationsImpl.Factory.class);
+ factory(PerCommentOperationsImpl.Factory.class);
+ factory(PerDraftCommentOperationsImpl.Factory.class);
+ factory(PerRobotCommentOperationsImpl.Factory.class);
factory(PushOneCommit.Factory.class);
install(InProcessProtocol.module());
install(new NoSshModule());
@@ -600,7 +621,24 @@
server.close();
server.daemon.stop();
- return start(server.desc, cfg, site, null, inMemoryRepoManager);
+ return start(server.desc, cfg, site, null, null, null, inMemoryRepoManager);
+ }
+
+ public static GerritServer restart(
+ GerritServer server, @Nullable Module testSysModule, @Nullable Module testSshModule)
+ throws Exception {
+ checkState(server.desc.sandboxed(), "restarting as slave requires @Sandboxed");
+ Config cfg = server.testInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+ Path site = server.testInjector.getInstance(Key.get(Path.class, SitePath.class));
+
+ InMemoryRepositoryManager inMemoryRepoManager = null;
+ if (hasBinding(server.testInjector, InMemoryRepositoryManager.class)) {
+ inMemoryRepoManager = server.testInjector.getInstance(InMemoryRepositoryManager.class);
+ }
+
+ server.close();
+ server.daemon.stop();
+ return start(server.desc, cfg, site, testSysModule, null, testSshModule, inMemoryRepoManager);
}
private static boolean hasBinding(Injector injector, Class<?> clazz) {
diff --git a/java/com/google/gerrit/acceptance/SshSession.java b/java/com/google/gerrit/acceptance/SshSession.java
index 6ecf85f..6698657 100644
--- a/java/com/google/gerrit/acceptance/SshSession.java
+++ b/java/com/google/gerrit/acceptance/SshSession.java
@@ -65,6 +65,22 @@
}
}
+ @SuppressWarnings("resource")
+ public int execAndReturnStatus(String command) throws Exception {
+ ChannelExec channel = (ChannelExec) getSession().openChannel("exec");
+ try {
+ channel.setCommand(command);
+ InputStream err = channel.getErrStream();
+ channel.connect();
+
+ Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
+ error = s.hasNext() ? s.next() : null;
+ return channel.getExitStatus();
+ } finally {
+ channel.disconnect();
+ }
+ }
+
private boolean hasError() {
return error != null;
}
diff --git a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
index c38f5fa..dcb49a5 100644
--- a/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
+++ b/java/com/google/gerrit/acceptance/StandaloneSiteTest.java
@@ -187,7 +187,14 @@
private GerritServer startImpl(@Nullable Module testSysModule, String... additionalArgs)
throws Exception {
return GerritServer.start(
- serverDesc, baseConfig, sitePaths.site_path, testSysModule, null, additionalArgs);
+ serverDesc,
+ baseConfig,
+ sitePaths.site_path,
+ testSysModule,
+ null,
+ null,
+ null,
+ additionalArgs);
}
protected static void runGerrit(String... args) throws Exception {
diff --git a/java/com/google/gerrit/acceptance/TestAccount.java b/java/com/google/gerrit/acceptance/TestAccount.java
index e9c0899..d5908f4 100644
--- a/java/com/google/gerrit/acceptance/TestAccount.java
+++ b/java/com/google/gerrit/acceptance/TestAccount.java
@@ -48,8 +48,10 @@
@Nullable String email,
@Nullable String fullName,
@Nullable String displayName,
- @Nullable String httpPassword) {
- return new AutoValue_TestAccount(id, username, email, fullName, displayName, httpPassword);
+ @Nullable String httpPassword,
+ ImmutableList<String> tags) {
+ return new AutoValue_TestAccount(
+ id, username, email, fullName, displayName, httpPassword, tags);
}
public abstract Account.Id id();
@@ -69,6 +71,8 @@
@Nullable
public abstract String httpPassword();
+ public abstract ImmutableList<String> tags();
+
public PersonIdent newIdent() {
return new PersonIdent(fullName(), email());
}
diff --git a/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java b/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java
new file mode 100644
index 0000000..ddaf341
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/GracefulCommand.java
@@ -0,0 +1,31 @@
+// 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.acceptance.ssh;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.sshd.CommandMetaData;
+
+@CommandMetaData(
+ name = "graceful",
+ description = "Test command for graceful shutdown",
+ runsAt = MASTER_OR_SLAVE)
+public class GracefulCommand extends TestCommand {
+
+ @Override
+ boolean isGraceful() {
+ return true;
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java b/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java
new file mode 100644
index 0000000..ed635c8
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/NonGracefulCommand.java
@@ -0,0 +1,31 @@
+// 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.acceptance.ssh;
+
+import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
+
+import com.google.gerrit.sshd.CommandMetaData;
+
+@CommandMetaData(
+ name = "non-graceful",
+ description = "Test command for immediate shutdown",
+ runsAt = MASTER_OR_SLAVE)
+public class NonGracefulCommand extends TestCommand {
+
+ @Override
+ boolean isGraceful() {
+ return false;
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/TestCommand.java b/java/com/google/gerrit/acceptance/ssh/TestCommand.java
new file mode 100644
index 0000000..7839578
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/TestCommand.java
@@ -0,0 +1,49 @@
+// 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.acceptance.ssh;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.sshd.SshCommand;
+import java.util.concurrent.CyclicBarrier;
+import org.kohsuke.args4j.Option;
+
+public abstract class TestCommand extends SshCommand {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ public static final CyclicBarrier syncPoint = new CyclicBarrier(2);
+
+ @Option(
+ name = "--duration",
+ aliases = {"-d"},
+ required = true,
+ usage = "Duration of the command execution in seconds")
+ private int duration;
+
+ @Override
+ protected void run() throws UnloggedFailure, Failure, Exception {
+ logger.atFine().log("Starting command.");
+ if (isGraceful()) {
+ enableGracefulStop();
+ }
+ try {
+ syncPoint.await();
+ Thread.sleep(duration * 1000);
+ logger.atFine().log("Stopping command.");
+ } catch (Exception e) {
+ throw die("Command ended prematurely.", e);
+ }
+ }
+
+ abstract boolean isGraceful();
+}
diff --git a/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
new file mode 100644
index 0000000..626092b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/ssh/TestSshCommandModule.java
@@ -0,0 +1,25 @@
+// 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.acceptance.ssh;
+
+import com.google.gerrit.sshd.CommandModule;
+
+public class TestSshCommandModule extends CommandModule {
+ @Override
+ protected void configure() {
+ command("graceful").to(GracefulCommand.class);
+ command("non-graceful").to(NonGracefulCommand.class);
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
new file mode 100644
index 0000000..c4e4192
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperations.java
@@ -0,0 +1,139 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+
+/**
+ * An aggregation of operations on changes for test purposes.
+ *
+ * <p>To execute the operations, no Gerrit permissions are necessary.
+ *
+ * <p><strong>Note:</strong> This interface is not implemented using the REST or extension API.
+ * Hence, it cannot be used for testing those APIs.
+ */
+public interface ChangeOperations {
+
+ /**
+ * Starts the fluent chain for querying or modifying a change. Please see the methods of {@link
+ * PerChangeOperations} for details on possible operations.
+ *
+ * @return an aggregation of operations on a specific change
+ */
+ PerChangeOperations change(Change.Id changeId);
+
+ /**
+ * Starts the fluent chain to create a change. The returned builder can be used to specify the
+ * attributes of the new change. To create the change for real, {@link
+ * TestChangeCreation.Builder#create()} must be called.
+ *
+ * <p>Example:
+ *
+ * <pre>
+ * Change.Id createdChangeId = changeOperations
+ * .newChange()
+ * .file("file1")
+ * .content("Line 1\nLine2\n")
+ * .create();
+ * </pre>
+ *
+ * <p><strong>Note:</strong> There must be at least one existing user and repository.
+ *
+ * @return a builder to create the new change
+ */
+ TestChangeCreation.Builder newChange();
+
+ /** An aggregation of methods on a specific change. */
+ interface PerChangeOperations {
+
+ /**
+ * Checks whether the change exists.
+ *
+ * @return {@code true} if the change exists
+ */
+ boolean exists();
+
+ /**
+ * Retrieves the change.
+ *
+ * <p><strong>Note:</strong> This call will fail with an exception if the requested change
+ * doesn't exist. If you want to check for the existence of a change, use {@link #exists()}
+ * instead.
+ *
+ * @return the corresponding {@code TestChange}
+ */
+ TestChange get();
+
+ /**
+ * Starts the fluent chain to create a new patchset. The returned builder can be used to specify
+ * the attributes of the new patchset. To create the patchset for real, {@link
+ * TestPatchsetCreation.Builder#create()} must be called.
+ *
+ * <p>Example:
+ *
+ * <pre>
+ * PatchSet.Id createdPatchsetId = changeOperations
+ * .change(changeId)
+ * .newPatchset()
+ * .file("file1")
+ * .content("Line 1\nLine2\n")
+ * .create();
+ * </pre>
+ *
+ * @return builder to create a new patchset
+ */
+ TestPatchsetCreation.Builder newPatchset();
+
+ /**
+ * Starts the fluent chain for querying or modifying a patchset. Please see the methods of
+ * {@link PerPatchsetOperations} for details on possible operations.
+ *
+ * @return an aggregation of operations on a specific patchset
+ */
+ PerPatchsetOperations patchset(PatchSet.Id patchsetId);
+
+ /**
+ * Like {@link #patchset(PatchSet.Id)} but for the current patchset.
+ *
+ * @return an aggregation of operations on a specific patchset
+ */
+ PerPatchsetOperations currentPatchset();
+
+ /**
+ * Starts the fluent chain for querying or modifying a published comment. Please see the methods
+ * of {@link PerCommentOperations} for details on possible operations.
+ *
+ * @return an aggregation of operations on a specific comment
+ */
+ PerCommentOperations comment(String commentUuid);
+
+ /**
+ * Starts the fluent chain for querying or modifying a draft comment. Please see the methods of
+ * {@link PerDraftCommentOperations} for details on possible operations.
+ *
+ * @return an aggregation of operations on a specific draft comment
+ */
+ PerDraftCommentOperations draftComment(String commentUuid);
+
+ /**
+ * Starts the fluent chain for querying or modifying a robot comment. Please see the methods of
+ * {@link PerRobotCommentOperations} for details on possible operations.
+ *
+ * @return an aggregation of operations on a specific robot comment
+ */
+ PerRobotCommentOperations robotComment(String commentUuid);
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
new file mode 100644
index 0000000..3b15b57
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImpl.java
@@ -0,0 +1,568 @@
+// 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.acceptance.testsuite.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.ChangeFinder;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.edit.tree.TreeCreator;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.Objects;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.MergeStrategy;
+import org.eclipse.jgit.merge.Merger;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.ChangeIdUtil;
+
+/**
+ * The implementation of {@link ChangeOperations}.
+ *
+ * <p>There is only one implementation of {@link ChangeOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class ChangeOperationsImpl implements ChangeOperations {
+ private final Sequences seq;
+ private final ChangeInserter.Factory changeInserterFactory;
+ private final PatchSetInserter.Factory patchsetInserterFactory;
+ private final GitRepositoryManager repositoryManager;
+ private final AccountResolver resolver;
+ private final IdentifiedUser.GenericFactory userFactory;
+ private final PersonIdent serverIdent;
+ private final BatchUpdate.Factory batchUpdateFactory;
+ private final ProjectCache projectCache;
+ private final ChangeFinder changeFinder;
+ private final PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory;
+ private final PerCommentOperationsImpl.Factory perCommentOperationsFactory;
+ private final PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory;
+ private final PerRobotCommentOperationsImpl.Factory perRobotCommentOperationsFactory;
+
+ @Inject
+ public ChangeOperationsImpl(
+ Sequences seq,
+ ChangeInserter.Factory changeInserterFactory,
+ PatchSetInserter.Factory patchsetInserterFactory,
+ GitRepositoryManager repositoryManager,
+ AccountResolver resolver,
+ IdentifiedUser.GenericFactory userFactory,
+ @GerritPersonIdent PersonIdent serverIdent,
+ BatchUpdate.Factory batchUpdateFactory,
+ ProjectCache projectCache,
+ ChangeFinder changeFinder,
+ PerPatchsetOperationsImpl.Factory perPatchsetOperationsFactory,
+ PerCommentOperationsImpl.Factory perCommentOperationsFactory,
+ PerDraftCommentOperationsImpl.Factory perDraftCommentOperationsFactory,
+ PerRobotCommentOperationsImpl.Factory perRobotCommentOperationsFactory) {
+ this.seq = seq;
+ this.changeInserterFactory = changeInserterFactory;
+ this.patchsetInserterFactory = patchsetInserterFactory;
+ this.repositoryManager = repositoryManager;
+ this.resolver = resolver;
+ this.userFactory = userFactory;
+ this.serverIdent = serverIdent;
+ this.batchUpdateFactory = batchUpdateFactory;
+ this.projectCache = projectCache;
+ this.changeFinder = changeFinder;
+ this.perPatchsetOperationsFactory = perPatchsetOperationsFactory;
+ this.perCommentOperationsFactory = perCommentOperationsFactory;
+ this.perDraftCommentOperationsFactory = perDraftCommentOperationsFactory;
+ this.perRobotCommentOperationsFactory = perRobotCommentOperationsFactory;
+ }
+
+ @Override
+ public PerChangeOperations change(Change.Id changeId) {
+ return new PerChangeOperationsImpl(changeId);
+ }
+
+ @Override
+ public TestChangeCreation.Builder newChange() {
+ return TestChangeCreation.builder(this::createChange);
+ }
+
+ private Change.Id createChange(TestChangeCreation changeCreation) throws Exception {
+ Change.Id changeId = Change.id(seq.nextChangeId());
+ Project.NameKey project = getTargetProject(changeCreation);
+
+ try (Repository repository = repositoryManager.openRepository(project);
+ ObjectInserter objectInserter = repository.newObjectInserter();
+ RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+ Timestamp now = TimeUtil.nowTs();
+ IdentifiedUser changeOwner = getChangeOwner(changeCreation);
+ PersonIdent authorAndCommitter =
+ changeOwner.newCommitterIdent(now, serverIdent.getTimeZone());
+ ObjectId commitId =
+ createCommit(repository, revWalk, objectInserter, changeCreation, authorAndCommitter);
+
+ String refName = RefNames.fullName(changeCreation.branch());
+ ChangeInserter inserter = getChangeInserter(changeId, refName, commitId);
+
+ try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+ batchUpdate.setRepository(repository, revWalk, objectInserter);
+ batchUpdate.insertChange(inserter);
+ batchUpdate.execute();
+ }
+ return changeId;
+ }
+ }
+
+ private Project.NameKey getTargetProject(TestChangeCreation changeCreation) {
+ if (changeCreation.project().isPresent()) {
+ return changeCreation.project().get();
+ }
+
+ return getArbitraryProject();
+ }
+
+ private Project.NameKey getArbitraryProject() {
+ Project.NameKey allProjectsName = projectCache.getAllProjects().getNameKey();
+ Project.NameKey allUsersName = projectCache.getAllUsers().getNameKey();
+ Optional<Project.NameKey> arbitraryProject =
+ projectCache.all().stream()
+ .filter(
+ name ->
+ !Objects.equals(name, allProjectsName) && !Objects.equals(name, allUsersName))
+ .findFirst();
+ checkState(
+ arbitraryProject.isPresent(),
+ "At least one repository must be available on the Gerrit server");
+ return arbitraryProject.get();
+ }
+
+ private IdentifiedUser getChangeOwner(TestChangeCreation changeCreation)
+ throws IOException, ConfigInvalidException {
+ if (changeCreation.owner().isPresent()) {
+ return userFactory.create(changeCreation.owner().get());
+ }
+
+ return getArbitraryUser();
+ }
+
+ private IdentifiedUser getArbitraryUser() throws ConfigInvalidException, IOException {
+ ImmutableSet<Account.Id> foundAccounts = resolver.resolveIgnoreVisibility("").asIdSet();
+ checkState(
+ !foundAccounts.isEmpty(),
+ "At least one user account must be available on the Gerrit server");
+ return userFactory.create(foundAccounts.iterator().next());
+ }
+
+ private ObjectId createCommit(
+ Repository repository,
+ RevWalk revWalk,
+ ObjectInserter objectInserter,
+ TestChangeCreation changeCreation,
+ PersonIdent authorAndCommitter)
+ throws IOException, BadRequestException {
+ ImmutableList<ObjectId> parentCommits = getParentCommits(repository, revWalk, changeCreation);
+ TreeCreator treeCreator =
+ getTreeCreator(objectInserter, parentCommits, changeCreation.mergeStrategy());
+ ObjectId tree = createNewTree(repository, treeCreator, changeCreation.treeModifications());
+ String commitMessage = correctCommitMessage(changeCreation.commitMessage());
+ return createCommit(
+ objectInserter, tree, parentCommits, authorAndCommitter, authorAndCommitter, commitMessage);
+ }
+
+ private ImmutableList<ObjectId> getParentCommits(
+ Repository repository, RevWalk revWalk, TestChangeCreation changeCreation) {
+
+ return changeCreation
+ .parents()
+ .map(parents -> resolveParents(repository, revWalk, parents))
+ .orElseGet(() -> asImmutableList(getTip(repository, changeCreation.branch())));
+ }
+
+ private ImmutableList<ObjectId> resolveParents(
+ Repository repository, RevWalk revWalk, ImmutableList<TestCommitIdentifier> parents) {
+ return parents.stream()
+ .map(parent -> resolveCommit(repository, revWalk, parent))
+ .collect(toImmutableList());
+ }
+
+ private ObjectId resolveCommit(
+ Repository repository, RevWalk revWalk, TestCommitIdentifier parentCommit) {
+ switch (parentCommit.getKind()) {
+ case BRANCH:
+ return resolveBranchTip(repository, parentCommit.branch());
+ case CHANGE_ID:
+ return resolveChange(parentCommit.changeId());
+ case COMMIT_SHA_1:
+ return resolveCommitFromSha1(revWalk, parentCommit.commitSha1());
+ case PATCHSET_ID:
+ return resolvePatchset(parentCommit.patchsetId());
+ default:
+ throw new IllegalStateException(
+ String.format("No parent behavior implemented for %s.", parentCommit.getKind()));
+ }
+ }
+
+ private static ObjectId resolveBranchTip(Repository repository, String branchName) {
+ return getTip(repository, branchName)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format(
+ "Tip of branch %s not found and hence can't be used as parent.",
+ branchName)));
+ }
+
+ private static Optional<ObjectId> getTip(Repository repository, String branch) {
+ try {
+ Optional<Ref> ref = Optional.ofNullable(repository.findRef(branch));
+ return ref.map(Ref::getObjectId);
+ } catch (IOException e) {
+ throw new StorageException(e);
+ }
+ }
+
+ private ObjectId resolveChange(Change.Id changeId) {
+ Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
+ return changeNotes
+ .map(ChangeNotes::getCurrentPatchSet)
+ .map(PatchSet::commitId)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format(
+ "Change %s not found and hence can't be used as parent.", changeId)));
+ }
+
+ private static RevCommit resolveCommitFromSha1(RevWalk revWalk, ObjectId commitSha1) {
+ try {
+ return revWalk.parseCommit(commitSha1);
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ String.format("Commit %s not found and hence can't be used as parent/base.", commitSha1),
+ e);
+ }
+ }
+
+ private ObjectId resolvePatchset(PatchSet.Id patchsetId) {
+ Optional<ChangeNotes> changeNotes = changeFinder.findOne(patchsetId.changeId());
+ return changeNotes
+ .map(ChangeNotes::getPatchSets)
+ .map(patchsets -> patchsets.get(patchsetId))
+ .map(PatchSet::commitId)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format(
+ "Patchset %s not found and hence can't be used as parent.", patchsetId)));
+ }
+
+ private static <T> ImmutableList<T> asImmutableList(Optional<T> value) {
+ return Streams.stream(value).collect(toImmutableList());
+ }
+
+ private static TreeCreator getTreeCreator(
+ RevWalk revWalk, ObjectId customBaseCommit, ImmutableList<ObjectId> parentCommits) {
+ RevCommit commit = resolveCommitFromSha1(revWalk, customBaseCommit);
+ // Use actual parents; relevant for example when a file is restored (->
+ // RestoreFileModification).
+ return TreeCreator.basedOnTree(commit.getTree(), parentCommits);
+ }
+
+ private static TreeCreator getTreeCreator(
+ ObjectInserter objectInserter,
+ ImmutableList<ObjectId> parentCommits,
+ MergeStrategy mergeStrategy) {
+ if (parentCommits.isEmpty()) {
+ return TreeCreator.basedOnEmptyTree();
+ }
+ ObjectId baseTreeId = merge(objectInserter, parentCommits, mergeStrategy);
+ return TreeCreator.basedOnTree(baseTreeId, parentCommits);
+ }
+
+ private static ObjectId merge(
+ ObjectInserter objectInserter,
+ ImmutableList<ObjectId> parentCommits,
+ MergeStrategy mergeStrategy) {
+ try {
+ Merger merger = mergeStrategy.newMerger(objectInserter, new Config());
+ boolean mergeSuccessful = merger.merge(parentCommits.toArray(new AnyObjectId[0]));
+ if (!mergeSuccessful) {
+ throw new IllegalStateException(
+ "Conflicts encountered while merging the specified parents. Use"
+ + " mergeOfButBaseOnFirst() instead to avoid these conflicts and define any"
+ + " other desired file contents with file().content().");
+ }
+ return merger.getResultTreeId();
+ } catch (IOException e) {
+ throw new IllegalStateException(
+ "Creating the merge commits of the specified parents failed for an unknown reason.", e);
+ }
+ }
+
+ private static ObjectId createNewTree(
+ Repository repository,
+ TreeCreator treeCreator,
+ ImmutableList<TreeModification> treeModifications)
+ throws IOException {
+ treeCreator.addTreeModifications(treeModifications);
+ return treeCreator.createNewTreeAndGetId(repository);
+ }
+
+ private String correctCommitMessage(String desiredCommitMessage) throws BadRequestException {
+ String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage);
+
+ if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
+ ObjectId id = CommitMessageUtil.generateChangeId();
+ commitMessage = ChangeIdUtil.insertId(commitMessage, id);
+ }
+
+ return commitMessage;
+ }
+
+ private ObjectId createCommit(
+ ObjectInserter objectInserter,
+ ObjectId tree,
+ ImmutableList<ObjectId> parentCommitIds,
+ PersonIdent author,
+ PersonIdent committer,
+ String commitMessage)
+ throws IOException {
+ CommitBuilder builder = new CommitBuilder();
+ builder.setTreeId(tree);
+ builder.setParentIds(parentCommitIds);
+ builder.setAuthor(author);
+ builder.setCommitter(committer);
+ builder.setMessage(commitMessage);
+ ObjectId newCommitId = objectInserter.insert(builder);
+ objectInserter.flush();
+ return newCommitId;
+ }
+
+ private ChangeInserter getChangeInserter(Change.Id changeId, String refName, ObjectId commitId) {
+ ChangeInserter inserter = changeInserterFactory.create(changeId, commitId, refName);
+ inserter.setMessage(String.format("Uploaded patchset %d.", inserter.getPatchSetId().get()));
+ return inserter;
+ }
+
+ private class PerChangeOperationsImpl implements PerChangeOperations {
+
+ private final Change.Id changeId;
+
+ public PerChangeOperationsImpl(Change.Id changeId) {
+ this.changeId = changeId;
+ }
+
+ @Override
+ public boolean exists() {
+ return changeFinder.findOne(changeId).isPresent();
+ }
+
+ @Override
+ public TestChange get() {
+ return toTestChange(getChangeNotes().getChange());
+ }
+
+ private ChangeNotes getChangeNotes() {
+ Optional<ChangeNotes> changeNotes = changeFinder.findOne(changeId);
+ checkState(changeNotes.isPresent(), "Tried to get non-existing test change.");
+ return changeNotes.get();
+ }
+
+ private TestChange toTestChange(Change change) {
+ return TestChange.builder()
+ .numericChangeId(change.getId())
+ .changeId(change.getKey().get())
+ .build();
+ }
+
+ @Override
+ public TestPatchsetCreation.Builder newPatchset() {
+ return TestPatchsetCreation.builder(this::createPatchset);
+ }
+
+ private PatchSet.Id createPatchset(TestPatchsetCreation patchsetCreation)
+ throws IOException, RestApiException, UpdateException {
+ ChangeNotes changeNotes = getChangeNotes();
+ Project.NameKey project = changeNotes.getProjectName();
+ try (Repository repository = repositoryManager.openRepository(project);
+ ObjectInserter objectInserter = repository.newObjectInserter();
+ RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+ Timestamp now = TimeUtil.nowTs();
+ ObjectId newPatchsetCommit =
+ createPatchsetCommit(
+ repository, revWalk, objectInserter, changeNotes, patchsetCreation, now);
+
+ PatchSet.Id patchsetId =
+ ChangeUtil.nextPatchSetId(repository, changeNotes.getCurrentPatchSet().id());
+ PatchSetInserter patchSetInserter =
+ getPatchSetInserter(changeNotes, newPatchsetCommit, patchsetId);
+
+ IdentifiedUser changeOwner = userFactory.create(changeNotes.getChange().getOwner());
+ try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, changeOwner, now)) {
+ batchUpdate.setRepository(repository, revWalk, objectInserter);
+ batchUpdate.addOp(changeId, patchSetInserter);
+ batchUpdate.execute();
+ }
+ return patchsetId;
+ }
+ }
+
+ private ObjectId createPatchsetCommit(
+ Repository repository,
+ RevWalk revWalk,
+ ObjectInserter objectInserter,
+ ChangeNotes changeNotes,
+ TestPatchsetCreation patchsetCreation,
+ Timestamp now)
+ throws IOException, BadRequestException {
+ ObjectId oldPatchsetCommitId = changeNotes.getCurrentPatchSet().commitId();
+ RevCommit oldPatchsetCommit = repository.parseCommit(oldPatchsetCommitId);
+
+ ImmutableList<ObjectId> parentCommitIds =
+ getParents(repository, revWalk, patchsetCreation, oldPatchsetCommit);
+ TreeCreator treeCreator = getTreeCreator(revWalk, oldPatchsetCommit, parentCommitIds);
+ ObjectId tree = createNewTree(repository, treeCreator, patchsetCreation.treeModifications());
+
+ String commitMessage =
+ correctCommitMessage(
+ changeNotes.getChange().getKey().get(),
+ patchsetCreation.commitMessage().orElseGet(oldPatchsetCommit::getFullMessage));
+
+ PersonIdent author = getAuthor(oldPatchsetCommit);
+ PersonIdent committer = getCommitter(oldPatchsetCommit, now);
+ return createCommit(objectInserter, tree, parentCommitIds, author, committer, commitMessage);
+ }
+
+ private String correctCommitMessage(String oldChangeId, String desiredCommitMessage)
+ throws BadRequestException {
+ String commitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(desiredCommitMessage);
+
+ // Remove initial 'I' and treat the rest as ObjectId. This is not the cleanest approach but
+ // unfortunately, we don't seem to have other utility code which takes the string-based
+ // change-id and ensures that it is part of the commit message.
+ ObjectId id = ObjectId.fromString(oldChangeId.substring(1));
+ commitMessage = ChangeIdUtil.insertId(commitMessage, id, false);
+
+ return commitMessage;
+ }
+
+ private PersonIdent getAuthor(RevCommit oldPatchsetCommit) {
+ return Optional.ofNullable(oldPatchsetCommit.getAuthorIdent()).orElse(serverIdent);
+ }
+
+ private PersonIdent getCommitter(RevCommit oldPatchsetCommit, Timestamp now) {
+ PersonIdent oldPatchsetCommitter =
+ Optional.ofNullable(oldPatchsetCommit.getCommitterIdent()).orElse(serverIdent);
+ if (asSeconds(now) == asSeconds(oldPatchsetCommitter.getWhen())) {
+ /* We need to ensure that the resulting commit SHA-1 is different from the old patchset.
+ * In real situations, this automatically happens as two patchsets won't have exactly the
+ * same commit timestamp even when the tree and commit message are the same. In tests,
+ * we can easily end up with the same timestamp as Git uses second precision for timestamps.
+ * We could of course require that tests must use TestTimeUtil#setClockStep but
+ * that would be an unnecessary nuisance for test writers. Hence, go with a simple solution
+ * here and simply add a second. */
+ now = Timestamp.from(now.toInstant().plusSeconds(1));
+ }
+ return new PersonIdent(oldPatchsetCommitter, now);
+ }
+
+ private long asSeconds(Date date) {
+ return date.getTime() / 1000;
+ }
+
+ private ImmutableList<ObjectId> getParents(
+ Repository repository,
+ RevWalk revWalk,
+ TestPatchsetCreation patchsetCreation,
+ RevCommit oldPatchsetCommit) {
+ return patchsetCreation
+ .parents()
+ .map(parents -> resolveParents(repository, revWalk, parents))
+ .orElseGet(
+ () -> Arrays.stream(oldPatchsetCommit.getParents()).collect(toImmutableList()));
+ }
+
+ private PatchSetInserter getPatchSetInserter(
+ ChangeNotes changeNotes, ObjectId newPatchsetCommit, PatchSet.Id patchsetId) {
+ PatchSetInserter patchSetInserter =
+ patchsetInserterFactory.create(changeNotes, patchsetId, newPatchsetCommit);
+ patchSetInserter.setCheckAddPatchSetPermission(false);
+ patchSetInserter.setMessage(String.format("Uploaded patchset %d.", patchsetId.get()));
+ return patchSetInserter;
+ }
+
+ @Override
+ public PerPatchsetOperations patchset(PatchSet.Id patchsetId) {
+ return perPatchsetOperationsFactory.create(getChangeNotes(), patchsetId);
+ }
+
+ @Override
+ public PerPatchsetOperations currentPatchset() {
+ ChangeNotes changeNotes = getChangeNotes();
+ return perPatchsetOperationsFactory.create(
+ changeNotes, changeNotes.getChange().currentPatchSetId());
+ }
+
+ @Override
+ public PerCommentOperations comment(String commentUuid) {
+ ChangeNotes changeNotes = getChangeNotes();
+ return perCommentOperationsFactory.create(changeNotes, commentUuid);
+ }
+
+ @Override
+ public PerDraftCommentOperations draftComment(String commentUuid) {
+ ChangeNotes changeNotes = getChangeNotes();
+ return perDraftCommentOperationsFactory.create(changeNotes, commentUuid);
+ }
+
+ @Override
+ public PerRobotCommentOperations robotComment(String commentUuid) {
+ ChangeNotes changeNotes = getChangeNotes();
+ return perRobotCommentOperationsFactory.create(changeNotes, commentUuid);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/CommentSide.java b/java/com/google/gerrit/acceptance/testsuite/change/CommentSide.java
new file mode 100644
index 0000000..b7e720b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/CommentSide.java
@@ -0,0 +1,36 @@
+// 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.acceptance.testsuite.change;
+
+/**
+ * Marks the commit that contains the comment (also known as side). Used by {@link
+ * TestCommentCreation} and {@link TestRobotCommentCreation}.
+ */
+enum CommentSide {
+ PATCHSET_COMMIT(1),
+ AUTO_MERGE_COMMIT(0),
+ PARENT_COMMIT(-1),
+ SECOND_PARENT_COMMIT(-2);
+
+ private final short numericSide;
+
+ CommentSide(int numericSide) {
+ this.numericSide = (short) numericSide;
+ }
+
+ public short getNumericSide() {
+ return numericSide;
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/FileBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/FileBuilder.java
new file mode 100644
index 0000000..c8514a7
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/FileBuilder.java
@@ -0,0 +1,33 @@
+// 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.acceptance.testsuite.change;
+
+import java.util.function.Function;
+
+/**
+ * Builder for the file specification of line/range comments. Used by {@link TestCommentCreation}
+ * and {@link TestRobotCommentCreation}.
+ */
+public class FileBuilder<T> {
+ private final Function<String, T> nextStepProvider;
+
+ public FileBuilder(Function<String, T> nextStepProvider) {
+ this.nextStepProvider = nextStepProvider;
+ }
+ /** File on which the comment should be added. */
+ public T ofFile(String file) {
+ return nextStepProvider.apply(file);
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
new file mode 100644
index 0000000..d0ccd5b
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/FileContentBuilder.java
@@ -0,0 +1,77 @@
+// 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.acceptance.testsuite.change;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.DeleteFileModification;
+import com.google.gerrit.server.edit.tree.RenameFileModification;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.function.Consumer;
+
+/** Builder to simplify file content specification. */
+public class FileContentBuilder<T> {
+ private final T builder;
+ private final String filePath;
+ private final Consumer<TreeModification> modificationToBuilderAdder;
+
+ FileContentBuilder(
+ T builder, String filePath, Consumer<TreeModification> modificationToBuilderAdder) {
+ checkNotNull(Strings.emptyToNull(filePath), "File path must not be null or empty.");
+ this.builder = builder;
+ this.filePath = filePath;
+ this.modificationToBuilderAdder = modificationToBuilderAdder;
+ }
+
+ /** Content of the file. Must not be empty. */
+ public T content(String content) {
+ checkNotNull(
+ Strings.emptyToNull(content),
+ "Empty file content is not supported. Adjust test API if necessary.");
+ modificationToBuilderAdder.accept(
+ new ChangeFileContentModification(filePath, RawInputUtil.create(content)));
+ return builder;
+ }
+
+ /** Deletes the file. */
+ public T delete() {
+ modificationToBuilderAdder.accept(new DeleteFileModification(filePath));
+ return builder;
+ }
+
+ /**
+ * Renames the file while keeping its content.
+ *
+ * <p>If you want to both rename the file and adjust its content, delete the old path via {@link
+ * #delete()} and provide the desired content for the new path via {@link #content(String)}. If
+ * you use that approach, make sure to use a new content which is similar enough to the old (at
+ * least 60% line similarity) as otherwise Gerrit/Git won't identify it as a rename.
+ *
+ * <p>To create copied files, you need to go even one step further. Also rename the file you copy
+ * at the same time (-> delete old path + add two paths with the old content)! If you also want to
+ * adjust the content of the copy, you need to also slightly modify the content of the renamed
+ * file. Adjust the content of the copy slightly more if you want to control which file ends up as
+ * copy and which as rename (but keep the 60% line similarity threshold in mind).
+ *
+ * @param newFilePath new path of the file
+ */
+ public T renameTo(String newFilePath) {
+ modificationToBuilderAdder.accept(new RenameFileModification(filePath, newFilePath));
+ return builder;
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/MultipleParentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/MultipleParentBuilder.java
new file mode 100644
index 0000000..63d8c0a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/MultipleParentBuilder.java
@@ -0,0 +1,47 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.common.collect.ImmutableList;
+import java.util.function.Function;
+
+/** Builder to simplify specifying multiple parents for a change. */
+public class MultipleParentBuilder<T> {
+ private final Function<ImmutableList<TestCommitIdentifier>, T> parentsToBuilderAdder;
+ private final ImmutableList.Builder<TestCommitIdentifier> parents;
+
+ public MultipleParentBuilder(
+ Function<ImmutableList<TestCommitIdentifier>, T> parentsToBuilderAdder,
+ TestCommitIdentifier firstParent) {
+ this.parentsToBuilderAdder = parentsToBuilderAdder;
+ parents = ImmutableList.builder();
+ parents.add(firstParent);
+ }
+
+ /** Adds an intermediate parent. */
+ public ParentBuilder<MultipleParentBuilder<T>> followedBy() {
+ return new ParentBuilder<>(
+ parent -> {
+ parents.add(parent);
+ return this;
+ });
+ }
+
+ /** Adds the last parent. */
+ public ParentBuilder<T> and() {
+ return new ParentBuilder<>(
+ (parent) -> parentsToBuilderAdder.apply(parents.add(parent).build()));
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ParentBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/ParentBuilder.java
new file mode 100644
index 0000000..b57aa6d
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ParentBuilder.java
@@ -0,0 +1,52 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import java.util.function.Function;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Builder to simplify parent specification of a change. */
+public class ParentBuilder<T> {
+ private final Function<TestCommitIdentifier, T> parentToBuilderAdder;
+
+ public ParentBuilder(Function<TestCommitIdentifier, T> parentToBuilderAdder) {
+ this.parentToBuilderAdder = parentToBuilderAdder;
+ }
+
+ /** Use the commit identified by the specified SHA-1. */
+ public T commit(ObjectId commitSha1) {
+ return parentToBuilderAdder.apply(TestCommitIdentifier.ofCommitSha1(commitSha1));
+ }
+
+ /**
+ * Use the commit which is at the tip of the specified branch. Short branch names (without
+ * refs/heads) are automatically expanded.
+ */
+ public T tipOfBranch(String branchName) {
+ return parentToBuilderAdder.apply(TestCommitIdentifier.ofBranch(branchName));
+ }
+
+ /** Use the current patchset commit of the indicated change. */
+ public T change(Change.Id changeId) {
+ return parentToBuilderAdder.apply(TestCommitIdentifier.ofChangeId(changeId));
+ }
+
+ /** Use the commit identified by the specified patchset. */
+ public T patchset(PatchSet.Id patchsetId) {
+ return parentToBuilderAdder.apply(TestCommitIdentifier.ofPatchsetId(patchsetId));
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperations.java
new file mode 100644
index 0000000..aa2827c
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperations.java
@@ -0,0 +1,29 @@
+// 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.acceptance.testsuite.change;
+
+/** An aggregation of methods on a specific, published comment. */
+public interface PerCommentOperations {
+
+ /**
+ * Retrieves the published comment.
+ *
+ * <p><strong>Note:</strong> This call will fail with an exception if the requested comment
+ * doesn't exist or if it is a comment of another type.
+ *
+ * @return the corresponding {@code TestComment}
+ */
+ TestHumanComment get();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperationsImpl.java
new file mode 100644
index 0000000..0218731
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerCommentOperationsImpl.java
@@ -0,0 +1,66 @@
+// 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.acceptance.testsuite.change;
+
+import static com.google.common.collect.MoreCollectors.onlyElement;
+
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/**
+ * The implementation of {@link PerCommentOperations}.
+ *
+ * <p>There is only one implementation of {@link PerCommentOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class PerCommentOperationsImpl implements PerCommentOperations {
+ private final CommentsUtil commentsUtil;
+
+ private final ChangeNotes changeNotes;
+ private final String commentUuid;
+
+ public interface Factory {
+ PerCommentOperationsImpl create(ChangeNotes changeNotes, String commentUuid);
+ }
+
+ @Inject
+ public PerCommentOperationsImpl(
+ CommentsUtil commentsUtil, @Assisted ChangeNotes changeNotes, @Assisted String commentUuid) {
+ this.commentsUtil = commentsUtil;
+ this.changeNotes = changeNotes;
+ this.commentUuid = commentUuid;
+ }
+
+ @Override
+ public TestHumanComment get() {
+ HumanComment comment =
+ commentsUtil.publishedHumanCommentsByChange(changeNotes).stream()
+ .filter(foundComment -> foundComment.key.uuid.equals(commentUuid))
+ .collect(onlyElement());
+ return toTestComment(comment);
+ }
+
+ static TestHumanComment toTestComment(HumanComment comment) {
+ return TestHumanComment.builder()
+ .uuid(comment.key.uuid)
+ .parentUuid(comment.parentUuid)
+ .tag(comment.tag)
+ .unresolved(comment.unresolved)
+ .build();
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperations.java
new file mode 100644
index 0000000..cc1e844
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperations.java
@@ -0,0 +1,29 @@
+// 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.acceptance.testsuite.change;
+
+/** An aggregation of methods on a specific draft comment. */
+public interface PerDraftCommentOperations {
+
+ /**
+ * Retrieves the draft comment.
+ *
+ * <p><strong>Note:</strong> This call will fail with an exception if the requested comment
+ * doesn't exist or if it is a comment of another type.
+ *
+ * @return the corresponding {@code TestComment}
+ */
+ TestHumanComment get();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java
new file mode 100644
index 0000000..db264c5
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerDraftCommentOperationsImpl.java
@@ -0,0 +1,58 @@
+// 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.acceptance.testsuite.change;
+
+import static com.google.common.collect.MoreCollectors.onlyElement;
+import static com.google.gerrit.acceptance.testsuite.change.PerCommentOperationsImpl.toTestComment;
+
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/**
+ * The implementation of {@link PerDraftCommentOperationsImpl}.
+ *
+ * <p>There is only one implementation of {@link PerDraftCommentOperations}. Nevertheless, we keep
+ * the separation between interface and implementation to enhance clarity.
+ */
+public class PerDraftCommentOperationsImpl implements PerDraftCommentOperations {
+ private final CommentsUtil commentsUtil;
+
+ private final ChangeNotes changeNotes;
+ private final String commentUuid;
+
+ public interface Factory {
+ PerDraftCommentOperationsImpl create(ChangeNotes changeNotes, String commentUuid);
+ }
+
+ @Inject
+ public PerDraftCommentOperationsImpl(
+ CommentsUtil commentsUtil, @Assisted ChangeNotes changeNotes, @Assisted String commentUuid) {
+ this.commentsUtil = commentsUtil;
+ this.changeNotes = changeNotes;
+ this.commentUuid = commentUuid;
+ }
+
+ @Override
+ public TestHumanComment get() {
+ HumanComment comment =
+ commentsUtil.draftByChange(changeNotes).stream()
+ .filter(foundComment -> foundComment.key.uuid.equals(commentUuid))
+ .collect(onlyElement());
+ return toTestComment(comment);
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
new file mode 100644
index 0000000..f4c70bd
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperations.java
@@ -0,0 +1,91 @@
+// 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.acceptance.testsuite.change;
+
+/** An aggregation of methods on a specific patchset. */
+public interface PerPatchsetOperations {
+
+ /**
+ * Retrieves the patchset.
+ *
+ * <p><strong>Note:</strong> This call will fail with an exception if the requested patchset
+ * doesn't exist.
+ *
+ * @return the corresponding {@code TestPatchset}
+ */
+ TestPatchset get();
+
+ /**
+ * Starts the fluent chain to create a new, published comment. The returned builder can be used to
+ * specify the attributes of the comment. To create the comment for real, {@link
+ * TestCommentCreation.Builder#create()} must be called.
+ *
+ * <p>Example:
+ *
+ * <pre>
+ * String createdCommentUuid = changeOperations
+ * .change(changeId)
+ * .currentPatchset()
+ * .newComment()
+ * .onLine(2)
+ * .ofFile("file1")
+ * .create();
+ * </pre>
+ *
+ * @return builder to create a new comment
+ */
+ TestCommentCreation.Builder newComment();
+
+ /**
+ * Starts the fluent chain to create a new draft comment. The returned builder can be used to
+ * specify the attributes of the draft comment. To create the draft comment for real, {@link
+ * TestCommentCreation.Builder#create()} must be called.
+ *
+ * <p>Example:
+ *
+ * <pre>
+ * String createdDraftCommentUuid = changeOperations
+ * .change(changeId)
+ * .currentPatchset()
+ * .newDraftComment()
+ * .onLine(2)
+ * .ofFile("file1")
+ * .create();
+ * </pre>
+ *
+ * @return builder to create a new comment
+ */
+ TestCommentCreation.Builder newDraftComment();
+
+ /**
+ * Starts the fluent chain to create a new robot comment. The returned builder can be used to
+ * specify the attributes of the robot comment. To create the robot comment for real, {@link
+ * TestRobotCommentCreation.Builder#create()} must be called.
+ *
+ * <p>Example:
+ *
+ * <pre>
+ * String createdRobotCommentUuid = changeOperations
+ * .change(changeId)
+ * .currentPatchset()
+ * .newRobotComment()
+ * .onLine(2)
+ * .ofFile("file1")
+ * .create();
+ * </pre>
+ *
+ * @return builder to create a new comment
+ */
+ TestRobotCommentCreation.Builder newRobotComment();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
new file mode 100644
index 0000000..eda6c7e
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerPatchsetOperationsImpl.java
@@ -0,0 +1,274 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Comment.Status;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.extensions.client.Comment;
+import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.IdentifiedUser.GenericFactory;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * The implementation of {@link PerPatchsetOperations}.
+ *
+ * <p>There is only one implementation of {@link PerPatchsetOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class PerPatchsetOperationsImpl implements PerPatchsetOperations {
+ private final GitRepositoryManager repositoryManager;
+ private final IdentifiedUser.GenericFactory userFactory;
+ private final BatchUpdate.Factory batchUpdateFactory;
+ private final CommentsUtil commentsUtil;
+
+ private final ChangeNotes changeNotes;
+ private final PatchSet.Id patchsetId;
+
+ public interface Factory {
+ PerPatchsetOperationsImpl create(ChangeNotes changeNotes, PatchSet.Id patchsetId);
+ }
+
+ @Inject
+ private PerPatchsetOperationsImpl(
+ GitRepositoryManager repositoryManager,
+ GenericFactory userFactory,
+ BatchUpdate.Factory batchUpdateFactory,
+ CommentsUtil commentsUtil,
+ @Assisted ChangeNotes changeNotes,
+ @Assisted PatchSet.Id patchsetId) {
+ this.repositoryManager = repositoryManager;
+ this.userFactory = userFactory;
+ this.batchUpdateFactory = batchUpdateFactory;
+ this.commentsUtil = commentsUtil;
+ this.changeNotes = changeNotes;
+ this.patchsetId = patchsetId;
+ }
+
+ @Override
+ public TestPatchset get() {
+ PatchSet patchset = changeNotes.getPatchSets().get(patchsetId);
+ return TestPatchset.builder().patchsetId(patchsetId).commitId(patchset.commitId()).build();
+ }
+
+ @Override
+ public TestCommentCreation.Builder newComment() {
+ return TestCommentCreation.builder(this::createComment, Status.PUBLISHED);
+ }
+
+ @Override
+ public TestCommentCreation.Builder newDraftComment() {
+ return TestCommentCreation.builder(this::createComment, Status.DRAFT);
+ }
+
+ @Override
+ public TestRobotCommentCreation.Builder newRobotComment() {
+ return TestRobotCommentCreation.builder(this::createRobotComment);
+ }
+
+ private String createComment(TestCommentCreation commentCreation)
+ throws IOException, RestApiException, UpdateException {
+ Project.NameKey project = changeNotes.getProjectName();
+
+ try (Repository repository = repositoryManager.openRepository(project);
+ ObjectInserter objectInserter = repository.newObjectInserter();
+ RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+ Timestamp now = TimeUtil.nowTs();
+
+ IdentifiedUser author = getAuthor(commentCreation);
+ CommentAdditionOp commentAdditionOp = new CommentAdditionOp(commentCreation);
+ try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
+ batchUpdate.setRepository(repository, revWalk, objectInserter);
+ batchUpdate.addOp(changeNotes.getChangeId(), commentAdditionOp);
+ batchUpdate.execute();
+ }
+ return commentAdditionOp.createdCommentUuid;
+ }
+ }
+
+ private IdentifiedUser getAuthor(TestCommentCreation commentCreation) {
+ Account.Id authorId = commentCreation.author().orElse(changeNotes.getChange().getOwner());
+ return userFactory.create(authorId);
+ }
+
+ private IdentifiedUser getAuthor(TestRobotCommentCreation robotCommentCreation) {
+ Account.Id authorId = robotCommentCreation.author().orElse(changeNotes.getChange().getOwner());
+ return userFactory.create(authorId);
+ }
+
+ private static Comment.Range toCommentRange(TestRange range) {
+ Comment.Range commentRange = new Range();
+ commentRange.startLine = range.start().line();
+ commentRange.startCharacter = range.start().charOffset();
+ commentRange.endLine = range.end().line();
+ commentRange.endCharacter = range.end().charOffset();
+ return commentRange;
+ }
+
+ private class CommentAdditionOp implements BatchUpdateOp {
+ private String createdCommentUuid;
+ private final TestCommentCreation commentCreation;
+
+ public CommentAdditionOp(TestCommentCreation commentCreation) {
+ this.commentCreation = commentCreation;
+ }
+
+ @Override
+ public boolean updateChange(ChangeContext context) {
+ HumanComment comment = toNewComment(context, commentCreation);
+ ChangeUpdate changeUpdate = context.getUpdate(patchsetId);
+ changeUpdate.putComment(commentCreation.status(), comment);
+ // For published comments, only the tag set on the ChangeUpdate (and not on the HumanComment)
+ // matters.
+ commentCreation.tag().ifPresent(changeUpdate::setTag);
+ createdCommentUuid = comment.key.uuid;
+ return true;
+ }
+
+ private HumanComment toNewComment(ChangeContext context, TestCommentCreation commentCreation) {
+ String message = commentCreation.message().orElse("The text of a test comment.");
+
+ String filePath = commentCreation.file().orElse(Patch.PATCHSET_LEVEL);
+ 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(),
+ createdOn,
+ filePath,
+ patchsetId,
+ side,
+ message,
+ unresolved,
+ parentUuid);
+ // For draft comments, only the tag set on the HumanComment (and not on the ChangeUpdate)
+ // matters.
+ commentCreation.tag().ifPresent(tag -> newComment.tag = tag);
+
+ commentCreation.line().ifPresent(line -> newComment.setLineNbrAndRange(line, null));
+ // Specification of range trumps explicit line specification.
+ commentCreation
+ .range()
+ .map(PerPatchsetOperationsImpl::toCommentRange)
+ .ifPresent(range -> newComment.setLineNbrAndRange(null, range));
+
+ commentsUtil.setCommentCommitId(
+ newComment, context.getChange(), changeNotes.getPatchSets().get(patchsetId));
+ return newComment;
+ }
+ }
+
+ private String createRobotComment(TestRobotCommentCreation robotCommentCreation)
+ throws IOException, RestApiException, UpdateException {
+ Project.NameKey project = changeNotes.getProjectName();
+
+ try (Repository repository = repositoryManager.openRepository(project);
+ ObjectInserter objectInserter = repository.newObjectInserter();
+ RevWalk revWalk = new RevWalk(objectInserter.newReader())) {
+ Timestamp now = TimeUtil.nowTs();
+
+ IdentifiedUser author = getAuthor(robotCommentCreation);
+ RobotCommentAdditionOp robotCommentAdditionOp =
+ new RobotCommentAdditionOp(robotCommentCreation);
+ try (BatchUpdate batchUpdate = batchUpdateFactory.create(project, author, now)) {
+ batchUpdate.setRepository(repository, revWalk, objectInserter);
+ batchUpdate.addOp(changeNotes.getChangeId(), robotCommentAdditionOp);
+ batchUpdate.execute();
+ }
+ return robotCommentAdditionOp.createdRobotCommentUuid;
+ }
+ }
+
+ private class RobotCommentAdditionOp implements BatchUpdateOp {
+ private String createdRobotCommentUuid;
+ private final TestRobotCommentCreation robotCommentCreation;
+
+ public RobotCommentAdditionOp(TestRobotCommentCreation robotCommentCreation) {
+ this.robotCommentCreation = robotCommentCreation;
+ }
+
+ @Override
+ public boolean updateChange(ChangeContext context) {
+ RobotComment robotComment = toNewRobotComment(context, robotCommentCreation);
+ ChangeUpdate changeUpdate = context.getUpdate(patchsetId);
+ changeUpdate.putRobotComment(robotComment);
+ // For robot comments, only the tag set on the ChangeUpdate (and not on the RobotComment)
+ // matters.
+ robotCommentCreation.tag().ifPresent(changeUpdate::setTag);
+ createdRobotCommentUuid = robotComment.key.uuid;
+ return true;
+ }
+
+ private RobotComment toNewRobotComment(
+ ChangeContext context, TestRobotCommentCreation robotCommentCreation) {
+ String message = robotCommentCreation.message().orElse("The text of a test robot comment.");
+
+ String filePath = robotCommentCreation.file().orElse(Patch.PATCHSET_LEVEL);
+ short side = robotCommentCreation.side().orElse(CommentSide.PATCHSET_COMMIT).getNumericSide();
+ String robotId = robotCommentCreation.robotId().orElse("robot");
+ String robotRunId = robotCommentCreation.robotId().orElse("1");
+ RobotComment newRobotComment =
+ commentsUtil.newRobotComment(
+ context, filePath, patchsetId, side, message, robotId, robotRunId);
+
+ // TODO(paiking): This should not be needed, as the tag only matters in ChangeUpdate.
+ robotCommentCreation.tag().ifPresent(tag -> newRobotComment.tag = tag);
+
+ robotCommentCreation.line().ifPresent(line -> newRobotComment.setLineNbrAndRange(line, null));
+ // Specification of range trumps explicit line specification.
+ robotCommentCreation
+ .range()
+ .map(PerPatchsetOperationsImpl::toCommentRange)
+ .ifPresent(range -> newRobotComment.setLineNbrAndRange(null, range));
+
+ robotCommentCreation
+ .parentUuid()
+ .ifPresent(parentUuid -> newRobotComment.parentUuid = parentUuid);
+ robotCommentCreation.url().ifPresent(url -> newRobotComment.url = url);
+ if (!robotCommentCreation.properties().isEmpty()) {
+ newRobotComment.properties = robotCommentCreation.properties();
+ }
+
+ commentsUtil.setCommentCommitId(
+ newRobotComment, context.getChange(), changeNotes.getPatchSets().get(patchsetId));
+ return newRobotComment;
+ }
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperations.java b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperations.java
new file mode 100644
index 0000000..c9718aa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperations.java
@@ -0,0 +1,29 @@
+// 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.acceptance.testsuite.change;
+
+/** An aggregation of methods on a specific, robot comment. */
+public interface PerRobotCommentOperations {
+
+ /**
+ * Retrieves the robot comment.
+ *
+ * <p><strong>Note:</strong> This call will fail with an exception if the requested comment
+ * doesn't exist or if it is a comment of another type.
+ *
+ * @return the corresponding {@code TestRobotComment}
+ */
+ TestRobotComment get();
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperationsImpl.java
new file mode 100644
index 0000000..075c451
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PerRobotCommentOperationsImpl.java
@@ -0,0 +1,64 @@
+// 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.acceptance.testsuite.change;
+
+import static com.google.common.collect.MoreCollectors.onlyElement;
+
+import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/**
+ * The implementation of {@link PerRobotCommentOperations}.
+ *
+ * <p>There is only one implementation of {@link PerRobotCommentOperations}. Nevertheless, we keep
+ * the separation between interface and implementation to enhance clarity.
+ */
+public class PerRobotCommentOperationsImpl implements PerRobotCommentOperations {
+ private final CommentsUtil commentsUtil;
+
+ private final ChangeNotes changeNotes;
+ private final String commentUuid;
+
+ public interface Factory {
+ PerRobotCommentOperationsImpl create(ChangeNotes changeNotes, String commentUuid);
+ }
+
+ @Inject
+ public PerRobotCommentOperationsImpl(
+ CommentsUtil commentsUtil, @Assisted ChangeNotes changeNotes, @Assisted String commentUuid) {
+ this.commentsUtil = commentsUtil;
+ this.changeNotes = changeNotes;
+ this.commentUuid = commentUuid;
+ }
+
+ @Override
+ public TestRobotComment get() {
+ RobotComment comment =
+ commentsUtil.robotCommentsByChange(changeNotes).stream()
+ .filter(foundComment -> foundComment.key.uuid.equals(commentUuid))
+ .collect(onlyElement());
+ return toTestRobotComment(comment);
+ }
+
+ static TestRobotComment toTestRobotComment(RobotComment robotComment) {
+ return TestRobotComment.builder()
+ .uuid(robotComment.key.uuid)
+ .parentUuid(robotComment.parentUuid)
+ .build();
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/PositionBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/PositionBuilder.java
new file mode 100644
index 0000000..b061c81
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/PositionBuilder.java
@@ -0,0 +1,34 @@
+// 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.acceptance.testsuite.change;
+
+import java.util.function.IntFunction;
+
+/**
+ * Builder to simplify a position specification. Used by {@link TestCommentCreation} and {@link
+ * TestRobotCommentCreation}.
+ */
+public class PositionBuilder<T> {
+ private final IntFunction<T> nextStepProvider;
+
+ public PositionBuilder(IntFunction<T> nextStepProvider) {
+ this.nextStepProvider = nextStepProvider;
+ }
+
+ /** Character offset within the line. A value of 0 indicates the beginning of the line. */
+ public T charOffset(int characterOffset) {
+ return nextStepProvider.apply(characterOffset);
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java b/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java
new file mode 100644
index 0000000..b9639f5
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/StartAwarePositionBuilder.java
@@ -0,0 +1,49 @@
+// 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.acceptance.testsuite.change;
+
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+/**
+ * Builder for the end position of a range. Used by {@link TestCommentCreation} and {@link
+ * TestRobotCommentCreation}.
+ */
+public class StartAwarePositionBuilder<T> {
+ private final TestRange.Builder testRangeBuilder;
+ private final Consumer<TestRange> rangeConsumer;
+ private final Function<String, T> fileFunction;
+
+ public StartAwarePositionBuilder(
+ TestRange.Builder testRangeBuilder,
+ Consumer<TestRange> rangeConsumer,
+ Function<String, T> fileFunction) {
+ this.testRangeBuilder = testRangeBuilder;
+ this.rangeConsumer = rangeConsumer;
+ this.fileFunction = fileFunction;
+ }
+
+ /** Line of the end position of the range. */
+ public PositionBuilder<FileBuilder<T>> toLine(int endLine) {
+ return new PositionBuilder<>(
+ endCharOffset -> {
+ TestRange.Position end =
+ TestRange.Position.builder().line(endLine).charOffset(endCharOffset).build();
+ TestRange range = testRangeBuilder.setEnd(end).build();
+ rangeConsumer.accept(range);
+ return new FileBuilder<>(fileFunction);
+ });
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java
new file mode 100644
index 0000000..ea2acaa
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChange.java
@@ -0,0 +1,48 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+
+/** Representation of a change used for testing purposes. */
+@AutoValue
+public abstract class TestChange {
+
+ /**
+ * The numeric change ID, sometimes also called change number or legacy change ID. Unique per
+ * host.
+ */
+ public abstract Change.Id numericChangeId();
+
+ /**
+ * The Change-Id as specified in the commit message. Consists of an {@code I} followed by a 40-hex
+ * string. Only unique per project-branch.
+ */
+ public abstract String changeId();
+
+ static Builder builder() {
+ return new AutoValue_TestChange.Builder();
+ }
+
+ @AutoValue.Builder
+ abstract static class Builder {
+ abstract Builder numericChangeId(Change.Id numericChangeId);
+
+ abstract Builder changeId(String changeId);
+
+ abstract TestChange build();
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
new file mode 100644
index 0000000..5871e17
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestChangeCreation.java
@@ -0,0 +1,143 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.merge.MergeStrategy;
+
+/** Initial attributes of the change. If not provided, arbitrary values will be used. */
+@AutoValue
+public abstract class TestChangeCreation {
+ public abstract Optional<Project.NameKey> project();
+
+ public abstract String branch();
+
+ public abstract Optional<Account.Id> owner();
+
+ public abstract String commitMessage();
+
+ public abstract ImmutableList<TreeModification> treeModifications();
+
+ public abstract Optional<ImmutableList<TestCommitIdentifier>> parents();
+
+ public abstract MergeStrategy mergeStrategy();
+
+ abstract ThrowingFunction<TestChangeCreation, Change.Id> changeCreator();
+
+ public static Builder builder(ThrowingFunction<TestChangeCreation, Change.Id> changeCreator) {
+ return new AutoValue_TestChangeCreation.Builder()
+ .changeCreator(changeCreator)
+ .branch(Constants.R_HEADS + Constants.MASTER)
+ .commitMessage("A test change")
+ // Which value we choose here doesn't matter. All relevant code paths set the desired value.
+ .mergeStrategy(MergeStrategy.OURS);
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ /** Target project/Repository of the change. Must be an existing project. */
+ public abstract Builder project(Project.NameKey project);
+
+ /**
+ * Target branch of the change. Neither needs to exist nor needs to point to an actual commit.
+ */
+ public abstract Builder branch(String branch);
+
+ /** The change owner. Must be an existing user account. */
+ public abstract Builder owner(Account.Id owner);
+
+ /**
+ * The commit message. The message may contain a {@code Change-Id} footer but does not need to.
+ * If the footer is absent, it will be generated.
+ */
+ public abstract Builder commitMessage(String commitMessage);
+
+ /** Modified file of the change. The file content is specified via the returned builder. */
+ public FileContentBuilder<Builder> file(String filePath) {
+ return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+ }
+
+ abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
+
+ /**
+ * Parent commit of the change. The commit can be specified via various means in the returned
+ * builder.
+ */
+ public ParentBuilder<Builder> childOf() {
+ return new ParentBuilder<>(parentCommit -> parents(ImmutableList.of(parentCommit)));
+ }
+
+ /**
+ * Parent commits of the change. Each parent commit can be specified via various means in the
+ * returned builder. The order of the parents matters and is preserved (first parent commit in
+ * fluent change -> first parent of the change).
+ *
+ * <p>This method will automatically merge the parent commits and use the resulting commit as
+ * base for the change. Use {@link #file(String)} for additional file adjustments on top of that
+ * merge commit.
+ *
+ * <p><strong>Note:</strong> If this method fails with a merge conflict, use {@link
+ * #mergeOfButBaseOnFirst()} instead and specify all other necessary file contents manually via
+ * {@link #file(String)}.
+ */
+ public ParentBuilder<MultipleParentBuilder<Builder>> mergeOf() {
+ return new ParentBuilder<>(parent -> mergeBuilder(MergeStrategy.RECURSIVE, parent));
+ }
+
+ /**
+ * Parent commits of the change. Each parent commit can be specified via various means in the
+ * returned builder. The order of the parents matters and is preserved (first parent commit in
+ * fluent change -> first parent of the change).
+ *
+ * <p>This method will use the first specified parent commit as base for the resulting change.
+ * This approach is especially useful if merging the parents is not possible.
+ */
+ public ParentBuilder<MultipleParentBuilder<Builder>> mergeOfButBaseOnFirst() {
+ return new ParentBuilder<>(parent -> mergeBuilder(MergeStrategy.OURS, parent));
+ }
+
+ MultipleParentBuilder<Builder> mergeBuilder(
+ MergeStrategy mergeStrategy, TestCommitIdentifier parent) {
+ mergeStrategy(mergeStrategy);
+ return new MultipleParentBuilder<>(this::parents, parent);
+ }
+
+ abstract Builder parents(ImmutableList<TestCommitIdentifier> parents);
+
+ abstract Builder mergeStrategy(MergeStrategy mergeStrategy);
+
+ abstract Builder changeCreator(ThrowingFunction<TestChangeCreation, Change.Id> changeCreator);
+
+ abstract TestChangeCreation autoBuild();
+
+ /**
+ * Creates the change.
+ *
+ * @return the {@code Change.Id} of the created change
+ */
+ public Change.Id create() {
+ TestChangeCreation changeUpdate = autoBuild();
+ return changeUpdate.changeCreator().applyAndThrowSilently(changeUpdate);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
new file mode 100644
index 0000000..2031bde
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestCommentCreation.java
@@ -0,0 +1,219 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.acceptance.testsuite.change.TestRange.Position;
+import com.google.gerrit.common.Nullable;
+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;
+
+/**
+ * Attributes of the human comment. If not provided, arbitrary values will be used. This class is
+ * very similar to {@link TestRobotCommentCreation} to allow separation between robot and human
+ * comments.
+ */
+@AutoValue
+public abstract class TestCommentCreation {
+
+ public abstract Optional<String> message();
+
+ public abstract Optional<String> file();
+
+ public abstract Optional<Integer> line();
+
+ public abstract Optional<TestRange> range();
+
+ public abstract Optional<CommentSide> side();
+
+ public abstract Optional<Boolean> unresolved();
+
+ public abstract Optional<String> parentUuid();
+
+ public abstract Optional<String> tag();
+
+ public abstract Optional<Account.Id> author();
+
+ public abstract Optional<Instant> createdOn();
+
+ abstract Comment.Status status();
+
+ abstract ThrowingFunction<TestCommentCreation, String> commentCreator();
+
+ public static Builder builder(
+ ThrowingFunction<TestCommentCreation, String> commentCreator, Comment.Status commentStatus) {
+ return new AutoValue_TestCommentCreation.Builder()
+ .commentCreator(commentCreator)
+ .status(commentStatus);
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public Builder noMessage() {
+ return message("");
+ }
+
+ /** Message text of the comment. */
+ public abstract Builder message(String message);
+
+ /** Indicates a patchset-level comment. */
+ public Builder onPatchsetLevel() {
+ return file(Patch.PATCHSET_LEVEL);
+ }
+
+ /** Indicates a file comment. The comment will be on the specified file. */
+ public Builder onFileLevelOf(String filePath) {
+ return file(filePath).line(null).range(null);
+ }
+
+ /**
+ * Starts the fluent change to create a line comment. The line comment will be at the indicated
+ * line. Lines start with 1.
+ */
+ public FileBuilder<Builder> onLine(int line) {
+ return new FileBuilder<>(file -> file(file).line(line).range(null));
+ }
+
+ /**
+ * Starts the fluent chain to create a range comment. The range begins at the specified line.
+ * Lines start at 1. The start position (line, charOffset) is inclusive, the end position (line,
+ * charOffset) is exclusive.
+ */
+ public PositionBuilder<StartAwarePositionBuilder<Builder>> fromLine(int startLine) {
+ return new PositionBuilder<>(
+ startCharOffset -> {
+ Position start = Position.builder().line(startLine).charOffset(startCharOffset).build();
+ TestRange.Builder testRangeBuilder = TestRange.builder().setStart(start);
+ return new StartAwarePositionBuilder<>(testRangeBuilder, this::range, this::file);
+ });
+ }
+
+ /** File on which the comment should be added. */
+ abstract Builder file(String filePath);
+
+ /** Line on which the comment should be added. */
+ abstract Builder line(@Nullable Integer line);
+
+ /** Range on which the comment should be added. */
+ abstract Builder range(@Nullable TestRange range);
+
+ /**
+ * Indicates that the comment refers to a file, line, range, ... in the commit of the patchset.
+ *
+ * <p>On the UI, such comments are shown on the right side of a diff view when a diff against
+ * base is selected. See {@link #onParentCommit()} for comments shown on the left side.
+ */
+ public Builder onPatchsetCommit() {
+ return side(CommentSide.PATCHSET_COMMIT);
+ }
+
+ /**
+ * Indicates that the comment refers to a file, line, range, ... in the parent commit of the
+ * patchset.
+ *
+ * <p>On the UI, such comments are shown on the left side of a diff view when a diff against
+ * base is selected. See {@link #onPatchsetCommit()} for comments shown on the right side.
+ *
+ * <p>For merge commits, this indicates the first parent commit.
+ */
+ public Builder onParentCommit() {
+ return side(CommentSide.PARENT_COMMIT);
+ }
+
+ /** Like {@link #onParentCommit()} but for the second parent of a merge commit. */
+ public Builder onSecondParentCommit() {
+ return side(CommentSide.SECOND_PARENT_COMMIT);
+ }
+
+ /**
+ * Like {@link #onParentCommit()} but for the AutoMerge commit created from the parents of a
+ * merge commit.
+ */
+ public Builder onAutoMergeCommit() {
+ return side(CommentSide.AUTO_MERGE_COMMIT);
+ }
+
+ abstract Builder side(CommentSide side);
+
+ /** Indicates a resolved comment. */
+ public Builder resolved() {
+ return unresolved(false);
+ }
+
+ /** Indicates an unresolved comment. */
+ public Builder unresolved() {
+ return unresolved(true);
+ }
+
+ abstract Builder unresolved(boolean unresolved);
+
+ /**
+ * UUID of another comment to which this comment is a reply. This comment must have similar
+ * attributes (e.g. file, line, side) as the parent comment. The parent comment must be a
+ * published comment.
+ */
+ public abstract Builder parentUuid(String parentUuid);
+
+ /** Tag to attach to the comment. */
+ public abstract Builder tag(String value);
+
+ /** Author of the comment. Must be an existing user account. */
+ 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.
+ */
+ abstract Builder status(Comment.Status value);
+
+ abstract Builder commentCreator(ThrowingFunction<TestCommentCreation, String> commentCreator);
+
+ abstract TestCommentCreation autoBuild();
+
+ /**
+ * Creates the comment.
+ *
+ * @return the UUID of the created comment
+ */
+ public String create() {
+ TestCommentCreation commentCreation = autoBuild();
+ return commentCreation.commentCreator().applyAndThrowSilently(commentCreation);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestCommitIdentifier.java b/java/com/google/gerrit/acceptance/testsuite/change/TestCommitIdentifier.java
new file mode 100644
index 0000000..a352607
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestCommitIdentifier.java
@@ -0,0 +1,61 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoOneOf;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Attributes, each one uniquely identifying a commit. */
+@AutoOneOf(TestCommitIdentifier.Kind.class)
+public abstract class TestCommitIdentifier {
+ public enum Kind {
+ COMMIT_SHA_1,
+ BRANCH,
+ CHANGE_ID,
+ PATCHSET_ID
+ }
+
+ public abstract Kind getKind();
+
+ /** SHA-1 of the commit. */
+ public abstract ObjectId commitSha1();
+
+ /** Branch whose tip points to the desired commit. */
+ public abstract String branch();
+
+ /** Numeric ID of the change whose current patchset points to the desired commit. */
+ public abstract Change.Id changeId();
+
+ /** ID of the patchset representing the desired commit. */
+ public abstract PatchSet.Id patchsetId();
+
+ public static TestCommitIdentifier ofCommitSha1(ObjectId commitSha1) {
+ return AutoOneOf_TestCommitIdentifier.commitSha1(commitSha1);
+ }
+
+ public static TestCommitIdentifier ofBranch(String branchName) {
+ return AutoOneOf_TestCommitIdentifier.branch(branchName);
+ }
+
+ public static TestCommitIdentifier ofChangeId(Change.Id changeId) {
+ return AutoOneOf_TestCommitIdentifier.changeId(changeId);
+ }
+
+ public static TestCommitIdentifier ofPatchsetId(PatchSet.Id patchsetId) {
+ return AutoOneOf_TestCommitIdentifier.patchsetId(patchsetId);
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestHumanComment.java b/java/com/google/gerrit/acceptance/testsuite/change/TestHumanComment.java
new file mode 100644
index 0000000..3a7f2ae
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestHumanComment.java
@@ -0,0 +1,53 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
+/** Representation of a human comment used for testing purposes. */
+@AutoValue
+public abstract class TestHumanComment {
+
+ /** The UUID of the comment. Should be unique. */
+ public abstract String uuid();
+
+ /** UUID of another comment to which this comment is a reply. */
+ public abstract Optional<String> parentUuid();
+
+ /** Tag of a comment. */
+ public abstract Optional<String> tag();
+
+ /** Unresolved state of a comment. */
+ public abstract boolean unresolved();
+
+ static Builder builder() {
+ return new AutoValue_TestHumanComment.Builder();
+ }
+
+ @AutoValue.Builder
+ abstract static class Builder {
+ abstract Builder uuid(String uuid);
+
+ abstract Builder parentUuid(@Nullable String parentUuid);
+
+ abstract Builder tag(@Nullable String tag);
+
+ abstract Builder unresolved(boolean unresolved);
+
+ abstract TestHumanComment build();
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java
new file mode 100644
index 0000000..1ba242a
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchset.java
@@ -0,0 +1,43 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.PatchSet;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Representation of a patchset used for testing purposes. */
+@AutoValue
+public abstract class TestPatchset {
+
+ /** The numeric patchset ID. */
+ public abstract PatchSet.Id patchsetId();
+
+ /** The commit SHA-1 of the patchset. */
+ public abstract ObjectId commitId();
+
+ static Builder builder() {
+ return new AutoValue_TestPatchset.Builder();
+ }
+
+ @AutoValue.Builder
+ abstract static class Builder {
+ abstract Builder patchsetId(PatchSet.Id patchsetId);
+
+ abstract Builder commitId(ObjectId commitId);
+
+ abstract TestPatchset build();
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
new file mode 100644
index 0000000..fe9d909
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestPatchsetCreation.java
@@ -0,0 +1,99 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.Optional;
+
+/** Initial attributes of the patchset. If not provided, arbitrary values will be used. */
+@AutoValue
+public abstract class TestPatchsetCreation {
+
+ public abstract Optional<String> commitMessage();
+
+ public abstract ImmutableList<TreeModification> treeModifications();
+
+ public abstract Optional<ImmutableList<TestCommitIdentifier>> parents();
+
+ abstract ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator();
+
+ public static TestPatchsetCreation.Builder builder(
+ ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator) {
+ return new AutoValue_TestPatchsetCreation.Builder().patchsetCreator(patchsetCreator);
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public abstract Builder commitMessage(String commitMessage);
+
+ /** Modified file of the patchset. The file content is specified via the returned builder. */
+ public FileContentBuilder<Builder> file(String filePath) {
+ return new FileContentBuilder<>(this, filePath, treeModificationsBuilder()::add);
+ }
+
+ abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
+
+ /**
+ * Parent commit of the change. The commit can be specified via various means in the returned
+ * builder.
+ *
+ * <p>This method will just change the parent but not influence the contents of the patchset
+ * commit.
+ *
+ * <p>It's possible to switch from a change representing a merge commit to a change not being a
+ * merge commit with this method.
+ */
+ public ParentBuilder<Builder> parent() {
+ return new ParentBuilder<>(parent -> parents(ImmutableList.of(parent)));
+ }
+
+ /**
+ * Parent commits of the change. Each parent commit can be specified via various means in the
+ * returned builder. The order of the parents matters and is preserved (first parent commit in
+ * fluent change -> first parent of the change).
+ *
+ * <p>This method will just change the parents but not influence the contents of the patchset
+ * commit.
+ *
+ * <p>It's possible to switch from a change representing a non-merge commit to a change which is
+ * a merge commit with this method.
+ */
+ public ParentBuilder<MultipleParentBuilder<Builder>> parents() {
+ return new ParentBuilder<>(parent -> new MultipleParentBuilder<>(this::parents, parent));
+ }
+
+ abstract Builder parents(ImmutableList<TestCommitIdentifier> value);
+
+ abstract TestPatchsetCreation.Builder patchsetCreator(
+ ThrowingFunction<TestPatchsetCreation, PatchSet.Id> patchsetCreator);
+
+ abstract TestPatchsetCreation autoBuild();
+
+ /**
+ * Creates the patchset.
+ *
+ * @return the {@code PatchSet.Id} of the created patchset
+ */
+ public PatchSet.Id create() {
+ TestPatchsetCreation patchsetCreation = autoBuild();
+ return patchsetCreation.patchsetCreator().applyAndThrowSilently(patchsetCreation);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestRange.java b/java/com/google/gerrit/acceptance/testsuite/change/TestRange.java
new file mode 100644
index 0000000..f5cb7db
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestRange.java
@@ -0,0 +1,67 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+
+/** Representation of a range used for testing purposes. */
+@AutoValue
+public abstract class TestRange {
+
+ /** Start position of the range. (inclusive) */
+ public abstract Position start();
+
+ /** End position of the range. (exclusive) */
+ public abstract Position end();
+
+ static Builder builder() {
+ return new AutoValue_TestRange.Builder();
+ }
+
+ @AutoValue.Builder
+ abstract static class Builder {
+
+ abstract Builder setStart(Position start);
+
+ abstract Builder setEnd(Position end);
+
+ abstract TestRange build();
+ }
+
+ /** Position (start/end) of a range. */
+ @AutoValue
+ public abstract static class Position {
+
+ /** 1-based line. */
+ public abstract int line();
+
+ /** 0-based character offset within the line. */
+ public abstract int charOffset();
+
+ static Builder builder() {
+ return new AutoValue_TestRange_Position.Builder();
+ }
+
+ @AutoValue.Builder
+ abstract static class Builder {
+
+ abstract Builder line(int line);
+
+ abstract Builder charOffset(int characterOffset);
+
+ abstract Position build();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestRobotComment.java b/java/com/google/gerrit/acceptance/testsuite/change/TestRobotComment.java
new file mode 100644
index 0000000..76fb52f
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestRobotComment.java
@@ -0,0 +1,43 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
+/** Representation of a robot comment used for testing purposes. */
+@AutoValue
+public abstract class TestRobotComment {
+
+ /** The UUID of the comment. Should be unique. */
+ public abstract String uuid();
+
+ /** UUID of another comment to which this comment is a reply. */
+ public abstract Optional<String> parentUuid();
+
+ static Builder builder() {
+ return new AutoValue_TestRobotComment.Builder();
+ }
+
+ @AutoValue.Builder
+ abstract static class Builder {
+ abstract Builder uuid(String uuid);
+
+ abstract Builder parentUuid(@Nullable String parentUuid);
+
+ abstract TestRobotComment build();
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/TestRobotCommentCreation.java b/java/com/google/gerrit/acceptance/testsuite/change/TestRobotCommentCreation.java
new file mode 100644
index 0000000..558af3f
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/TestRobotCommentCreation.java
@@ -0,0 +1,200 @@
+// 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.acceptance.testsuite.change;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.acceptance.testsuite.change.TestRange.Position;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Patch;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Attributes of the robot comment. If not provided, arbitrary values will be used. This class is
+ * very similar to {@link TestCommentCreation} to allow separation between robot and human comments.
+ */
+@AutoValue
+public abstract class TestRobotCommentCreation {
+
+ public abstract Optional<String> message();
+
+ public abstract Optional<String> file();
+
+ public abstract Optional<Integer> line();
+
+ public abstract Optional<TestRange> range();
+
+ public abstract Optional<CommentSide> side();
+
+ public abstract Optional<String> parentUuid();
+
+ public abstract Optional<String> tag();
+
+ public abstract Optional<Account.Id> author();
+
+ public abstract Optional<String> robotId();
+
+ public abstract Optional<String> robotRunId();
+
+ public abstract Optional<String> url();
+
+ public abstract ImmutableMap<String, String> properties();
+
+ abstract ThrowingFunction<TestRobotCommentCreation, String> commentCreator();
+
+ public static Builder builder(ThrowingFunction<TestRobotCommentCreation, String> commentCreator) {
+ return new AutoValue_TestRobotCommentCreation.Builder().commentCreator(commentCreator);
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public Builder noMessage() {
+ return message("");
+ }
+
+ /** Message text of the comment. */
+ public abstract Builder message(String message);
+
+ /** Indicates a patchset-level comment. */
+ public Builder onPatchsetLevel() {
+ return file(Patch.PATCHSET_LEVEL);
+ }
+
+ /** Indicates a file comment. The comment will be on the specified file. */
+ public Builder onFileLevelOf(String filePath) {
+ return file(filePath).line(null).range(null);
+ }
+
+ /**
+ * Starts the fluent change to create a line comment. The line comment will be at the indicated
+ * line. Lines start with 1.
+ */
+ public FileBuilder<Builder> onLine(int line) {
+ return new FileBuilder<>(file -> file(file).line(line).range(null));
+ }
+
+ /**
+ * Starts the fluent chain to create a range comment. The range begins at the specified line.
+ * Lines start at 1. The start position (line, charOffset) is inclusive, the end position (line,
+ * charOffset) is exclusive.
+ */
+ public PositionBuilder<StartAwarePositionBuilder<Builder>> fromLine(int startLine) {
+ return new PositionBuilder<>(
+ startCharOffset -> {
+ Position start = Position.builder().line(startLine).charOffset(startCharOffset).build();
+ TestRange.Builder testRangeBuilder = TestRange.builder().setStart(start);
+ return new StartAwarePositionBuilder<>(testRangeBuilder, this::range, this::file);
+ });
+ }
+
+ /** File on which the comment should be added. */
+ abstract Builder file(String filePath);
+
+ /** Line on which the comment should be added. */
+ abstract Builder line(@Nullable Integer line);
+
+ /** Range on which the comment should be added. */
+ abstract Builder range(@Nullable TestRange range);
+
+ /**
+ * Indicates that the comment refers to a file, line, range, ... in the commit of the patchset.
+ *
+ * <p>On the UI, such comments are shown on the right side of a diff view when a diff against
+ * base is selected. See {@link #onParentCommit()} for comments shown on the left side.
+ */
+ public Builder onPatchsetCommit() {
+ return side(CommentSide.PATCHSET_COMMIT);
+ }
+
+ /**
+ * Indicates that the comment refers to a file, line, range, ... in the parent commit of the
+ * patchset.
+ *
+ * <p>On the UI, such comments are shown on the left side of a diff view when a diff against
+ * base is selected. See {@link #onPatchsetCommit()} for comments shown on the right side.
+ *
+ * <p>For merge commits, this indicates the first parent commit.
+ */
+ public Builder onParentCommit() {
+ return side(CommentSide.PARENT_COMMIT);
+ }
+
+ /** Like {@link #onParentCommit()} but for the second parent of a merge commit. */
+ public Builder onSecondParentCommit() {
+ return side(CommentSide.SECOND_PARENT_COMMIT);
+ }
+
+ /**
+ * Like {@link #onParentCommit()} but for the AutoMerge commit created from the parents of a
+ * merge commit.
+ */
+ public Builder onAutoMergeCommit() {
+ return side(CommentSide.AUTO_MERGE_COMMIT);
+ }
+
+ abstract Builder side(CommentSide side);
+
+ /**
+ * UUID of another comment to which this comment is a reply. This comment must have similar
+ * attributes (e.g. file, line, side) as the parent comment. The parent comment must be a
+ * published comment.
+ */
+ public abstract Builder parentUuid(String parentUuid);
+
+ /** Tag to attach to the comment. */
+ public abstract Builder tag(String value);
+
+ /** Author of the comment. Must be an existing user account. */
+ public abstract Builder author(Account.Id accountId);
+
+ /** Id of the robot that created the comment. */
+ public abstract Builder robotId(String robotId);
+
+ /** An ID of the run of the robot that created the comment. */
+ public abstract Builder robotRunId(String robotRunId);
+
+ /** Url for more information for the robot comment. */
+ public abstract Builder url(String url);
+
+ /** Robot specific properties as map that maps arbitrary keys to values. */
+ public abstract Builder properties(Map<String, String> properties);
+
+ abstract ImmutableMap.Builder<String, String> propertiesBuilder();
+
+ public Builder addProperty(String key, String value) {
+ propertiesBuilder().put(key, value);
+ return this;
+ }
+
+ abstract Builder commentCreator(
+ ThrowingFunction<TestRobotCommentCreation, String> commentCreator);
+
+ abstract TestRobotCommentCreation autoBuild();
+
+ /**
+ * Creates the robot comment.
+ *
+ * @return the UUID of the created robot comment
+ */
+ public String create() {
+ TestRobotCommentCreation commentCreation = autoBuild();
+ return commentCreation.commentCreator().applyAndThrowSilently(commentCreation);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 60b3720..f6e5de3 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -14,6 +14,7 @@
package com.google.gerrit.acceptance.testsuite.project;
+import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
import static com.google.gerrit.server.project.ProjectConfig.PROJECT_CONFIG;
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -26,11 +27,11 @@
import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.server.config.AllProjectsName;
@@ -43,12 +44,10 @@
import com.google.inject.Inject;
import java.io.IOException;
import java.util.ArrayList;
-import java.util.Collections;
import org.apache.commons.lang.RandomStringUtils;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
@@ -91,11 +90,13 @@
CreateProjectArgs args = new CreateProjectArgs();
args.setProjectName(name);
- args.branch = Collections.singletonList(Constants.R_HEADS + Constants.MASTER);
+ args.permissionsOnly = projectCreation.permissionOnly().orElse(false);
+ args.branch =
+ projectCreation.branches().stream().map(RefNames::fullName).collect(toImmutableList());
args.createEmptyCommit = projectCreation.createEmptyCommit().orElse(true);
projectCreation.parent().ifPresent(p -> args.newParent = p);
// ProjectCreator wants non-null owner IDs.
- args.ownerIds = new ArrayList<>();
+ args.ownerIds = new ArrayList<>(projectCreation.owners());
projectCreation.submitType().ifPresent(st -> args.submitType = st);
projectCreator.createProject(args);
return Project.nameKey(name);
@@ -211,9 +212,7 @@
}
private RevCommit headOrNull(String branch) {
- if (!branch.startsWith(Constants.R_REFS)) {
- branch = RefNames.REFS_HEADS + branch;
- }
+ branch = RefNames.fullName(branch);
try (Repository repo = repoManager.openRepository(nameKey);
RevWalk rw = new RevWalk(repo)) {
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
index 99e045c..3337fc3 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectCreation.java
@@ -14,11 +14,18 @@
package com.google.gerrit.acceptance.testsuite.project;
+import static java.util.Objects.requireNonNull;
+
import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.client.SubmitType;
import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.lib.Constants;
@AutoValue
public abstract class TestProjectCreation {
@@ -27,15 +34,23 @@
public abstract Optional<Project.NameKey> parent();
+ public abstract ImmutableSet<String> branches();
+
public abstract Optional<Boolean> createEmptyCommit();
+ public abstract Optional<Boolean> permissionOnly();
+
public abstract Optional<SubmitType> submitType();
+ public abstract ImmutableSet<AccountGroup.UUID> owners();
+
abstract ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator();
public static Builder builder(
ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator) {
- return new AutoValue_TestProjectCreation.Builder().projectCreator(projectCreator);
+ return new AutoValue_TestProjectCreation.Builder()
+ .branches(Constants.R_HEADS + Constants.MASTER)
+ .projectCreator(projectCreator);
}
@AutoValue.Builder
@@ -46,13 +61,33 @@
public abstract TestProjectCreation.Builder submitType(SubmitType submitType);
+ /**
+ * Branches which should be created in the repository (with an empty root commit). The
+ * "refs/heads/" prefix of the branch name can be omitted. The specified branches are ignored if
+ * {@link #noEmptyCommit()} is used.
+ */
+ public TestProjectCreation.Builder branches(String branch1, String... otherBranches) {
+ return branches(Sets.union(ImmutableSet.of(branch1), ImmutableSet.copyOf(otherBranches)));
+ }
+
+ abstract TestProjectCreation.Builder branches(Set<String> branches);
+
public abstract TestProjectCreation.Builder createEmptyCommit(boolean value);
+ public abstract TestProjectCreation.Builder permissionOnly(boolean value);
+
/** Skips the empty commit on creation. This means that project's branches will not exist. */
public TestProjectCreation.Builder noEmptyCommit() {
return createEmptyCommit(false);
}
+ public TestProjectCreation.Builder addOwner(AccountGroup.UUID owner) {
+ ownersBuilder().add(requireNonNull(owner, "owner"));
+ return this;
+ }
+
+ abstract ImmutableSet.Builder<AccountGroup.UUID> ownersBuilder();
+
abstract TestProjectCreation.Builder projectCreator(
ThrowingFunction<TestProjectCreation, Project.NameKey> projectCreator);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
index 734854b..ea20931 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdate.java
@@ -15,7 +15,7 @@
package com.google.gerrit.acceptance.testsuite.project;
import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.common.data.AccessSection.GLOBAL_CAPABILITIES;
+import static com.google.gerrit.entities.AccessSection.GLOBAL_CAPABILITIES;
import static java.util.Objects.requireNonNull;
import com.google.auto.value.AutoValue;
@@ -23,11 +23,11 @@
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.config.AllProjectsName;
import java.util.Optional;
diff --git a/java/com/google/gerrit/common/FooterConstants.java b/java/com/google/gerrit/common/FooterConstants.java
index 3ec809c..656d850 100644
--- a/java/com/google/gerrit/common/FooterConstants.java
+++ b/java/com/google/gerrit/common/FooterConstants.java
@@ -20,6 +20,9 @@
/** The change ID as used to track patch sets. */
public static final FooterKey CHANGE_ID = new FooterKey("Change-Id");
+ /** Link is an alternative footer that may be used to track patch sets. */
+ public static final FooterKey LINK = new FooterKey("Link");
+
/** The footer telling us who reviewed the change. */
public static final FooterKey REVIEWED_BY = new FooterKey("Reviewed-by");
diff --git a/java/com/google/gerrit/common/UsedAt.java b/java/com/google/gerrit/common/UsedAt.java
index e38ad9a..73b1d40 100644
--- a/java/com/google/gerrit/common/UsedAt.java
+++ b/java/com/google/gerrit/common/UsedAt.java
@@ -19,6 +19,8 @@
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
@@ -28,6 +30,7 @@
*/
@Target({METHOD, TYPE, FIELD})
@Retention(RUNTIME)
+@Repeatable(UsedAt.Uses.class)
public @interface UsedAt {
/** Enumeration of projects that call a method/type/field. */
enum Project {
@@ -37,9 +40,18 @@
PLUGIN_CODE_OWNERS,
PLUGIN_DELETE_PROJECT,
PLUGIN_SERVICEUSER,
+ PLUGIN_HIGH_AVAILABILITY,
+ PLUGIN_MULTI_SITE,
PLUGINS_ALL, // Use this project if a method/type is generally made available to all plugins.
}
/** Reference to the project that uses the method annotated with this annotation. */
Project value();
+
+ /** Allows to mark method/type/field with multiple UsedAt annotations. */
+ @Retention(RUNTIME)
+ @Target(ElementType.TYPE)
+ @interface Uses {
+ UsedAt[] value();
+ }
}
diff --git a/java/com/google/gerrit/common/data/AccessSection.java b/java/com/google/gerrit/common/data/AccessSection.java
deleted file mode 100644
index 0974c47..0000000
--- a/java/com/google/gerrit/common/data/AccessSection.java
+++ /dev/null
@@ -1,166 +0,0 @@
-// Copyright (C) 2010 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.common.data;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static java.util.Objects.requireNonNull;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Project;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.Consumer;
-
-/** Portion of a {@link Project} describing access rules. */
-@AutoValue
-public abstract class AccessSection implements Comparable<AccessSection> {
- /** Special name given to the global capabilities; not a valid reference. */
- public static final String GLOBAL_CAPABILITIES = "GLOBAL_CAPABILITIES";
- /** Pattern that matches all references in a project. */
- public static final String ALL = "refs/*";
-
- /** Pattern that matches all branches in a project. */
- public static final String HEADS = "refs/heads/*";
-
- /** Prefix that triggers a regular expression pattern. */
- public static final String REGEX_PREFIX = "^";
-
- /** Name of the access section. It could be a ref pattern or something else. */
- public abstract String getName();
-
- public abstract ImmutableList<Permission> getPermissions();
-
- public static AccessSection create(String name) {
- return builder(name).build();
- }
-
- public static Builder builder(String name) {
- return new AutoValue_AccessSection.Builder().setName(name).setPermissions(ImmutableList.of());
- }
-
- /** @return true if the name is likely to be a valid reference section name. */
- public static boolean isValidRefSectionName(String name) {
- return name.startsWith("refs/") || name.startsWith("^refs/");
- }
-
- @Nullable
- public Permission getPermission(String name) {
- requireNonNull(name);
- for (Permission p : getPermissions()) {
- if (p.getName().equalsIgnoreCase(name)) {
- return p;
- }
- }
- return null;
- }
-
- @Override
- public final int compareTo(AccessSection o) {
- return comparePattern().compareTo(o.comparePattern());
- }
-
- private String comparePattern() {
- if (getName().startsWith(REGEX_PREFIX)) {
- return getName().substring(REGEX_PREFIX.length());
- }
- return getName();
- }
-
- @Override
- public final String toString() {
- return "AccessSection[" + getName() + "]";
- }
-
- public Builder toBuilder() {
- Builder b = autoToBuilder();
- b.getPermissions().stream().map(Permission::toBuilder).forEach(p -> b.addPermission(p));
- return b;
- }
-
- protected abstract Builder autoToBuilder();
-
- @AutoValue.Builder
- public abstract static class Builder {
- private final List<Permission.Builder> permissionBuilders;
-
- protected Builder() {
- permissionBuilders = new ArrayList<>();
- }
-
- public abstract Builder setName(String name);
-
- public abstract String getName();
-
- public Builder modifyPermissions(Consumer<List<Permission.Builder>> modification) {
- modification.accept(permissionBuilders);
- return this;
- }
-
- public Builder addPermission(Permission.Builder permission) {
- requireNonNull(permission, "permission must be non-null");
- return modifyPermissions(p -> p.add(permission));
- }
-
- public Builder remove(Permission.Builder permission) {
- requireNonNull(permission, "permission must be non-null");
- return removePermission(permission.getName());
- }
-
- public Builder removePermission(String name) {
- requireNonNull(name, "name must be non-null");
- return modifyPermissions(
- p -> p.removeIf(permissionBuilder -> name.equalsIgnoreCase(permissionBuilder.getName())));
- }
-
- public Permission.Builder upsertPermission(String permissionName) {
- requireNonNull(permissionName, "permissionName must be non-null");
-
- Optional<Permission.Builder> maybePermission =
- permissionBuilders.stream()
- .filter(p -> p.getName().equalsIgnoreCase(permissionName))
- .findAny();
- if (maybePermission.isPresent()) {
- return maybePermission.get();
- }
-
- Permission.Builder permission = Permission.builder(permissionName);
- modifyPermissions(p -> p.add(permission));
- return permission;
- }
-
- public AccessSection build() {
- setPermissions(
- permissionBuilders.stream().map(Permission.Builder::build).collect(toImmutableList()));
- if (getPermissions().size()
- > getPermissions().stream()
- .map(Permission::getName)
- .map(String::toLowerCase)
- .distinct()
- .count()) {
- throw new IllegalArgumentException("duplicate permissions: " + getPermissions());
- }
- return autoBuild();
- }
-
- protected abstract AccessSection autoBuild();
-
- protected abstract ImmutableList<Permission> getPermissions();
-
- abstract Builder setPermissions(ImmutableList<Permission> permissions);
- }
-}
diff --git a/java/com/google/gerrit/common/data/ContributorAgreement.java b/java/com/google/gerrit/common/data/ContributorAgreement.java
deleted file mode 100644
index 0f10367..0000000
--- a/java/com/google/gerrit/common/data/ContributorAgreement.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// Copyright (C) 2012 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.common.data;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.entities.Project;
-import java.util.List;
-
-/** Portion of a {@link Project} describing a single contributor agreement. */
-@AutoValue
-public abstract class ContributorAgreement implements Comparable<ContributorAgreement> {
- public abstract String getName();
-
- @Nullable
- public abstract String getDescription();
-
- public abstract ImmutableList<PermissionRule> getAccepted();
-
- @Nullable
- public abstract GroupReference getAutoVerify();
-
- @Nullable
- public abstract String getAgreementUrl();
-
- public abstract ImmutableList<String> getExcludeProjectsRegexes();
-
- public abstract ImmutableList<String> getMatchProjectsRegexes();
-
- public static ContributorAgreement.Builder builder(String name) {
- return new AutoValue_ContributorAgreement.Builder()
- .setName(name)
- .setAccepted(ImmutableList.of())
- .setExcludeProjectsRegexes(ImmutableList.of())
- .setMatchProjectsRegexes(ImmutableList.of());
- }
-
- @Override
- public final int compareTo(ContributorAgreement o) {
- return getName().compareTo(o.getName());
- }
-
- @Override
- public final String toString() {
- return "ContributorAgreement[" + getName() + "]";
- }
-
- public abstract Builder toBuilder();
-
- @AutoValue.Builder
- public abstract static class Builder {
- public abstract Builder setName(String name);
-
- public abstract Builder setDescription(@Nullable String description);
-
- public abstract Builder setAccepted(ImmutableList<PermissionRule> accepted);
-
- public abstract Builder setAutoVerify(@Nullable GroupReference autoVerify);
-
- public abstract Builder setAgreementUrl(@Nullable String agreementUrl);
-
- public abstract Builder setExcludeProjectsRegexes(List<String> excludeProjectsRegexes);
-
- public abstract Builder setMatchProjectsRegexes(List<String> matchProjectsRegexes);
-
- public abstract ContributorAgreement build();
- }
-}
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 10a66cc..51d9ecd 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -14,6 +14,8 @@
package com.google.gerrit.common.data;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
diff --git a/java/com/google/gerrit/common/data/LabelFunction.java b/java/com/google/gerrit/common/data/LabelFunction.java
deleted file mode 100644
index 6af675b..0000000
--- a/java/com/google/gerrit/common/data/LabelFunction.java
+++ /dev/null
@@ -1,123 +0,0 @@
-// 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.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.PatchSetApproval;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.Optional;
-
-/**
- * Functions for determining submittability based on label votes.
- *
- * <p>Only describes built-in label functions. Admins can extend the logic arbitrarily using Prolog
- * rules, in which case the choice of function in the project config is ignored.
- *
- * <p>Function semantics are documented in {@code config-labels.txt}, and actual behavior is
- * implemented both in Prolog in {@code gerrit_common.pl} and in the {@link #check} method.
- */
-public enum LabelFunction {
- ANY_WITH_BLOCK("AnyWithBlock", true, false, false),
- MAX_WITH_BLOCK("MaxWithBlock", true, true, true),
- MAX_NO_BLOCK("MaxNoBlock", false, true, true),
- NO_BLOCK("NoBlock"),
- NO_OP("NoOp"),
- PATCH_SET_LOCK("PatchSetLock");
-
- public static final Map<String, LabelFunction> ALL;
-
- static {
- Map<String, LabelFunction> all = new LinkedHashMap<>();
- for (LabelFunction f : values()) {
- all.put(f.getFunctionName(), f);
- }
- ALL = Collections.unmodifiableMap(all);
- }
-
- public static Optional<LabelFunction> parse(@Nullable String str) {
- return Optional.ofNullable(ALL.get(str));
- }
-
- private final String name;
- private final boolean isBlock;
- private final boolean isRequired;
- private final boolean requiresMaxValue;
-
- LabelFunction(String name) {
- this(name, false, false, false);
- }
-
- LabelFunction(String name, boolean isBlock, boolean isRequired, boolean requiresMaxValue) {
- this.name = name;
- this.isBlock = isBlock;
- this.isRequired = isRequired;
- this.requiresMaxValue = requiresMaxValue;
- }
-
- /** The function name as defined in documentation and {@code project.config}. */
- public String getFunctionName() {
- return name;
- }
-
- /** Whether the label is a "block" label, meaning a minimum vote will prevent submission. */
- public boolean isBlock() {
- return isBlock;
- }
-
- /** Whether the label is a mandatory label, meaning absence of votes will prevent submission. */
- public boolean isRequired() {
- return isRequired;
- }
-
- /** Whether the label requires a vote with the maximum value to allow submission. */
- public boolean isMaxValueRequired() {
- return requiresMaxValue;
- }
-
- public SubmitRecord.Label check(LabelType labelType, Iterable<PatchSetApproval> approvals) {
- SubmitRecord.Label submitRecordLabel = new SubmitRecord.Label();
- submitRecordLabel.label = labelType.getName();
-
- submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
- if (isRequired) {
- submitRecordLabel.status = SubmitRecord.Label.Status.NEED;
- }
-
- for (PatchSetApproval a : approvals) {
- if (a.value() == 0) {
- continue;
- }
-
- if (isBlock && labelType.isMaxNegative(a)) {
- submitRecordLabel.appliedBy = a.accountId();
- submitRecordLabel.status = SubmitRecord.Label.Status.REJECT;
- return submitRecordLabel;
- }
-
- if (labelType.isMaxPositive(a) || !requiresMaxValue) {
- submitRecordLabel.appliedBy = a.accountId();
-
- submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
- if (isRequired) {
- submitRecordLabel.status = SubmitRecord.Label.Status.OK;
- }
- }
- }
-
- return submitRecordLabel;
- }
-}
diff --git a/java/com/google/gerrit/common/data/LabelType.java b/java/com/google/gerrit/common/data/LabelType.java
deleted file mode 100644
index d272810..0000000
--- a/java/com/google/gerrit/common/data/LabelType.java
+++ /dev/null
@@ -1,301 +0,0 @@
-// Copyright (C) 2008 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.common.data;
-
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.LabelValue;
-import com.google.gerrit.entities.PatchSetApproval;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-
-@AutoValue
-public abstract class LabelType {
- public static final boolean DEF_ALLOW_POST_SUBMIT = true;
- public static final boolean DEF_CAN_OVERRIDE = true;
- public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
- public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
- public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
- public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
- public static final boolean DEF_COPY_ANY_SCORE = false;
- public static final boolean DEF_COPY_MAX_SCORE = false;
- public static final boolean DEF_COPY_MIN_SCORE = false;
- public static final ImmutableList<Short> DEF_COPY_VALUES = ImmutableList.of();
- public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
-
- public static LabelType withDefaultValues(String name) {
- checkName(name);
- List<LabelValue> values = new ArrayList<>(2);
- values.add(LabelValue.create((short) 0, "Rejected"));
- values.add(LabelValue.create((short) 1, "Approved"));
- return create(name, values);
- }
-
- public static String checkName(String name) throws IllegalArgumentException {
- checkNameInternal(name);
- if ("SUBM".equals(name)) {
- throw new IllegalArgumentException("Reserved label name \"" + name + "\"");
- }
- return name;
- }
-
- public static String checkNameInternal(String name) throws IllegalArgumentException {
- if (name == null || name.isEmpty()) {
- throw new IllegalArgumentException("Empty label name");
- }
- for (int i = 0; i < name.length(); i++) {
- char c = name.charAt(i);
- if ((i == 0 && c == '-')
- || !((c >= 'a' && c <= 'z')
- || (c >= 'A' && c <= 'Z')
- || (c >= '0' && c <= '9')
- || c == '-')) {
- throw new IllegalArgumentException("Illegal label name \"" + name + "\"");
- }
- }
- return name;
- }
-
- private static ImmutableList<LabelValue> sortValues(List<LabelValue> values) {
- if (values.isEmpty()) {
- return ImmutableList.of();
- }
- values = values.stream().sorted(comparing(LabelValue::getValue)).collect(toList());
- short v = values.get(0).getValue();
- short i = 0;
- ImmutableList.Builder<LabelValue> result = ImmutableList.builder();
- // Fill in any missing values with empty text.
- while (i < values.size()) {
- while (v < values.get(i).getValue()) {
- result.add(LabelValue.create(v++, ""));
- }
- v++;
- result.add(values.get(i++));
- }
- return result.build();
- }
-
- public abstract String getName();
-
- public abstract LabelFunction getFunction();
-
- public abstract boolean isCopyAnyScore();
-
- public abstract boolean isCopyMinScore();
-
- public abstract boolean isCopyMaxScore();
-
- public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
-
- public abstract boolean isCopyAllScoresOnTrivialRebase();
-
- public abstract boolean isCopyAllScoresIfNoCodeChange();
-
- public abstract boolean isCopyAllScoresIfNoChange();
-
- public abstract ImmutableList<Short> getCopyValues();
-
- public abstract boolean isAllowPostSubmit();
-
- public abstract boolean isIgnoreSelfApproval();
-
- public abstract short getDefaultValue();
-
- public abstract ImmutableList<LabelValue> getValues();
-
- public abstract short getMaxNegative();
-
- public abstract short getMaxPositive();
-
- public abstract boolean isCanOverride();
-
- @Nullable
- public abstract ImmutableList<String> getRefPatterns();
-
- public abstract ImmutableMap<Short, LabelValue> getByValue();
-
- public static LabelType create(String name, List<LabelValue> valueList) {
- return LabelType.builder(name, valueList).build();
- }
-
- public static LabelType.Builder builder(String name, List<LabelValue> valueList) {
- return (new AutoValue_LabelType.Builder())
- .setName(name)
- .setValues(valueList)
- .setDefaultValue((short) 0)
- .setFunction(LabelFunction.MAX_WITH_BLOCK)
- .setMaxNegative(Short.MIN_VALUE)
- .setMaxPositive(Short.MAX_VALUE)
- .setCanOverride(DEF_CAN_OVERRIDE)
- .setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
- .setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
- .setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
- .setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
- .setCopyAnyScore(DEF_COPY_ANY_SCORE)
- .setCopyMaxScore(DEF_COPY_MAX_SCORE)
- .setCopyMinScore(DEF_COPY_MIN_SCORE)
- .setCopyValues(DEF_COPY_VALUES)
- .setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT)
- .setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
- }
-
- public boolean matches(PatchSetApproval psa) {
- return psa.labelId().get().equalsIgnoreCase(getName());
- }
-
- public LabelValue getMin() {
- if (getValues().isEmpty()) {
- return null;
- }
- return getValues().get(0);
- }
-
- public LabelValue getMax() {
- if (getValues().isEmpty()) {
- return null;
- }
- return getValues().get(getValues().size() - 1);
- }
-
- public boolean isMaxNegative(PatchSetApproval ca) {
- return getMaxNegative() == ca.value();
- }
-
- public boolean isMaxPositive(PatchSetApproval ca) {
- return getMaxPositive() == ca.value();
- }
-
- public LabelValue getValue(short value) {
- return getByValue().get(value);
- }
-
- public LabelValue getValue(PatchSetApproval ca) {
- return getByValue().get(ca.value());
- }
-
- public LabelId getLabelId() {
- return LabelId.create(getName());
- }
-
- @Override
- public final String toString() {
- StringBuilder sb = new StringBuilder(getName()).append('[');
- LabelValue min = getMin();
- LabelValue max = getMax();
- if (min != null && max != null) {
- sb.append(
- new PermissionRange(Permission.forLabel(getName()), min.getValue(), max.getValue())
- .toString()
- .trim());
- } else if (min != null) {
- sb.append(min.formatValue().trim());
- } else if (max != null) {
- sb.append(max.formatValue().trim());
- }
- sb.append(']');
- return sb.toString();
- }
-
- public abstract Builder toBuilder();
-
- @AutoValue.Builder
- public abstract static class Builder {
- public abstract Builder setName(String name);
-
- public abstract Builder setFunction(LabelFunction function);
-
- public abstract Builder setCanOverride(boolean canOverride);
-
- public abstract Builder setAllowPostSubmit(boolean allowPostSubmit);
-
- public abstract Builder setIgnoreSelfApproval(boolean ignoreSelfApproval);
-
- public abstract Builder setRefPatterns(@Nullable List<String> refPatterns);
-
- public abstract Builder setValues(List<LabelValue> values);
-
- public abstract Builder setDefaultValue(short defaultValue);
-
- public abstract Builder setCopyAnyScore(boolean copyAnyScore);
-
- public abstract Builder setCopyMinScore(boolean copyMinScore);
-
- public abstract Builder setCopyMaxScore(boolean copyMaxScore);
-
- public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
- boolean copyAllScoresOnMergeFirstParentUpdate);
-
- public abstract Builder setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase);
-
- public abstract Builder setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange);
-
- public abstract Builder setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange);
-
- public abstract Builder setCopyValues(Collection<Short> copyValues);
-
- public abstract Builder setMaxNegative(short maxNegative);
-
- public abstract Builder setMaxPositive(short maxPositive);
-
- public abstract ImmutableList<LabelValue> getValues();
-
- protected abstract String getName();
-
- protected abstract ImmutableList<Short> getCopyValues();
-
- protected abstract Builder setByValue(ImmutableMap<Short, LabelValue> byValue);
-
- @Nullable
- protected abstract ImmutableList<String> getRefPatterns();
-
- protected abstract LabelType autoBuild();
-
- public LabelType build() throws IllegalArgumentException {
- setName(checkName(getName()));
- if (getRefPatterns() == null || getRefPatterns().isEmpty()) {
- // Empty to null
- setRefPatterns(null);
- }
-
- List<LabelValue> valueList = sortValues(getValues());
- setValues(valueList);
- if (!valueList.isEmpty()) {
- if (valueList.get(0).getValue() < 0) {
- setMaxNegative(valueList.get(0).getValue());
- }
- if (valueList.get(valueList.size() - 1).getValue() > 0) {
- setMaxPositive(valueList.get(valueList.size() - 1).getValue());
- }
- }
-
- ImmutableMap.Builder<Short, LabelValue> byValue = ImmutableMap.builder();
- for (LabelValue v : valueList) {
- byValue.put(v.getValue(), v);
- }
- setByValue(byValue.build());
-
- setCopyValues(ImmutableList.sortedCopyOf(getCopyValues()));
-
- return autoBuild();
- }
- }
-}
diff --git a/java/com/google/gerrit/common/data/LabelTypes.java b/java/com/google/gerrit/common/data/LabelTypes.java
deleted file mode 100644
index 1647658..0000000
--- a/java/com/google/gerrit/common/data/LabelTypes.java
+++ /dev/null
@@ -1,108 +0,0 @@
-// Copyright (C) 2009 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.common.data;
-
-import com.google.gerrit.entities.LabelId;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-public class LabelTypes {
- protected List<LabelType> labelTypes;
- private transient volatile Map<String, LabelType> byLabel;
- private transient volatile Map<String, Integer> positions;
-
- protected LabelTypes() {}
-
- public LabelTypes(List<? extends LabelType> approvals) {
- labelTypes = Collections.unmodifiableList(new ArrayList<>(approvals));
- }
-
- public List<LabelType> getLabelTypes() {
- return labelTypes;
- }
-
- public LabelType byLabel(LabelId labelId) {
- return byLabel().get(labelId.get().toLowerCase());
- }
-
- public LabelType byLabel(String labelName) {
- return byLabel().get(labelName.toLowerCase());
- }
-
- private Map<String, LabelType> byLabel() {
- if (byLabel == null) {
- synchronized (this) {
- if (byLabel == null) {
- Map<String, LabelType> l = new HashMap<>();
- if (labelTypes != null) {
- for (LabelType t : labelTypes) {
- l.put(t.getName().toLowerCase(), t);
- }
- }
- byLabel = l;
- }
- }
- }
- return byLabel;
- }
-
- @Override
- public String toString() {
- return labelTypes.toString();
- }
-
- public Comparator<String> nameComparator() {
- final Map<String, Integer> positions = positions();
- return new Comparator<String>() {
- @Override
- public int compare(String left, String right) {
- int lp = position(left);
- int rp = position(right);
- int cmp = lp - rp;
- if (cmp == 0) {
- cmp = left.compareTo(right);
- }
- return cmp;
- }
-
- private int position(String name) {
- Integer p = positions.get(name);
- return p != null ? p : positions.size();
- }
- };
- }
-
- private Map<String, Integer> positions() {
- if (positions == null) {
- synchronized (this) {
- if (positions == null) {
- Map<String, Integer> p = new HashMap<>();
- if (labelTypes != null) {
- int i = 0;
- for (LabelType t : labelTypes) {
- p.put(t.getName(), i++);
- }
- }
- positions = p;
- }
- }
- }
- return positions;
- }
-}
diff --git a/java/com/google/gerrit/common/data/Permission.java b/java/com/google/gerrit/common/data/Permission.java
deleted file mode 100644
index 6190957..0000000
--- a/java/com/google/gerrit/common/data/Permission.java
+++ /dev/null
@@ -1,292 +0,0 @@
-// Copyright (C) 2010 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.common.data;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.GroupReference;
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-import java.util.function.Consumer;
-
-/** A single permission within an {@link AccessSection} of a project. */
-@AutoValue
-public abstract class Permission implements Comparable<Permission> {
- public static final String ABANDON = "abandon";
- public static final String ADD_PATCH_SET = "addPatchSet";
- public static final String CREATE = "create";
- public static final String CREATE_SIGNED_TAG = "createSignedTag";
- public static final String CREATE_TAG = "createTag";
- public static final String DELETE = "delete";
- public static final String DELETE_CHANGES = "deleteChanges";
- public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
- public static final String EDIT_ASSIGNEE = "editAssignee";
- public static final String EDIT_HASHTAGS = "editHashtags";
- public static final String EDIT_TOPIC_NAME = "editTopicName";
- public static final String FORGE_AUTHOR = "forgeAuthor";
- public static final String FORGE_COMMITTER = "forgeCommitter";
- public static final String FORGE_SERVER = "forgeServerAsCommitter";
- public static final String LABEL = "label-";
- public static final String LABEL_AS = "labelAs-";
- public static final String OWNER = "owner";
- public static final String PUSH = "push";
- public static final String PUSH_MERGE = "pushMerge";
- public static final String READ = "read";
- public static final String REBASE = "rebase";
- public static final String REMOVE_REVIEWER = "removeReviewer";
- public static final String REVERT = "revert";
- public static final String SUBMIT = "submit";
- public static final String SUBMIT_AS = "submitAs";
- public static final String TOGGLE_WORK_IN_PROGRESS_STATE = "toggleWipState";
- public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
-
- private static final List<String> NAMES_LC;
- private static final int LABEL_INDEX;
- private static final int LABEL_AS_INDEX;
-
- static {
- NAMES_LC = new ArrayList<>();
- NAMES_LC.add(ABANDON.toLowerCase());
- NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
- NAMES_LC.add(CREATE.toLowerCase());
- NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
- NAMES_LC.add(CREATE_TAG.toLowerCase());
- NAMES_LC.add(DELETE.toLowerCase());
- NAMES_LC.add(DELETE_CHANGES.toLowerCase());
- NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
- NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
- NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
- NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
- NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
- NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
- NAMES_LC.add(FORGE_SERVER.toLowerCase());
- NAMES_LC.add(LABEL.toLowerCase());
- NAMES_LC.add(LABEL_AS.toLowerCase());
- NAMES_LC.add(OWNER.toLowerCase());
- NAMES_LC.add(PUSH.toLowerCase());
- NAMES_LC.add(PUSH_MERGE.toLowerCase());
- NAMES_LC.add(READ.toLowerCase());
- NAMES_LC.add(REBASE.toLowerCase());
- NAMES_LC.add(REMOVE_REVIEWER.toLowerCase());
- NAMES_LC.add(REVERT.toLowerCase());
- NAMES_LC.add(SUBMIT.toLowerCase());
- NAMES_LC.add(SUBMIT_AS.toLowerCase());
- NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase());
- NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
-
- LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
- LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
- }
-
- /** @return true if the name is recognized as a permission name. */
- public static boolean isPermission(String varName) {
- return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
- }
-
- public static boolean hasRange(String varName) {
- return isLabel(varName) || isLabelAs(varName);
- }
-
- /** @return true if the permission name is actually for a review label. */
- public static boolean isLabel(String varName) {
- return varName.startsWith(LABEL) && LABEL.length() < varName.length();
- }
-
- /** @return true if the permission is for impersonated review labels. */
- public static boolean isLabelAs(String var) {
- return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
- }
-
- /** @return permission name for the given review label. */
- public static String forLabel(String labelName) {
- return LABEL + labelName;
- }
-
- /** @return permission name to apply a label for another user. */
- public static String forLabelAs(String labelName) {
- return LABEL_AS + labelName;
- }
-
- public static String extractLabel(String varName) {
- if (isLabel(varName)) {
- return varName.substring(LABEL.length());
- } else if (isLabelAs(varName)) {
- return varName.substring(LABEL_AS.length());
- }
- return null;
- }
-
- public static boolean canBeOnAllProjects(String ref, String permissionName) {
- if (AccessSection.ALL.equals(ref)) {
- return !OWNER.equals(permissionName);
- }
- return true;
- }
-
- public abstract String getName();
-
- protected abstract boolean isExclusiveGroup();
-
- public abstract ImmutableList<PermissionRule> getRules();
-
- public static Builder builder(String name) {
- return new AutoValue_Permission.Builder()
- .setName(name)
- .setExclusiveGroup(false)
- .setRules(ImmutableList.of());
- }
-
- public static Permission create(String name) {
- return builder(name).build();
- }
-
- public String getLabel() {
- return extractLabel(getName());
- }
-
- public boolean getExclusiveGroup() {
- // Only permit exclusive group behavior on non OWNER permissions,
- // otherwise an owner might lose access to a delegated subspace.
- //
- return isExclusiveGroup() && !OWNER.equals(getName());
- }
-
- @Nullable
- public PermissionRule getRule(GroupReference group) {
- for (PermissionRule r : getRules()) {
- if (sameGroup(r, group)) {
- return r;
- }
- }
-
- return null;
- }
-
- private static boolean sameGroup(PermissionRule rule, GroupReference group) {
- if (group.getUUID() != null && rule.getGroup().getUUID() != null) {
- return group.getUUID().equals(rule.getGroup().getUUID());
- } else if (group.getName() != null && rule.getGroup().getName() != null) {
- return group.getName().equals(rule.getGroup().getName());
- } else {
- return false;
- }
- }
-
- @Override
- public final int compareTo(Permission b) {
- int cmp = index(this) - index(b);
- if (cmp == 0) {
- cmp = getName().compareTo(b.getName());
- }
- return cmp;
- }
-
- private static int index(Permission a) {
- if (isLabel(a.getName())) {
- return LABEL_INDEX;
- } else if (isLabelAs(a.getName())) {
- return LABEL_AS_INDEX;
- }
-
- int index = NAMES_LC.indexOf(a.getName().toLowerCase());
- return 0 <= index ? index : NAMES_LC.size();
- }
-
- @Override
- public final String toString() {
- StringBuilder bldr = new StringBuilder();
- bldr.append(getName()).append(" ");
- if (isExclusiveGroup()) {
- bldr.append("[exclusive] ");
- }
- bldr.append("[");
- Iterator<PermissionRule> it = getRules().iterator();
- while (it.hasNext()) {
- bldr.append(it.next());
- if (it.hasNext()) {
- bldr.append(", ");
- }
- }
- bldr.append("]");
- return bldr.toString();
- }
-
- protected abstract Builder autoToBuilder();
-
- public Builder toBuilder() {
- Builder b = autoToBuilder();
- getRules().stream().map(PermissionRule::toBuilder).forEach(r -> b.add(r));
- return b;
- }
-
- @AutoValue.Builder
- public abstract static class Builder {
- private final List<PermissionRule.Builder> rulesBuilders;
-
- Builder() {
- rulesBuilders = new ArrayList<>();
- }
-
- public abstract Builder setName(String value);
-
- public abstract String getName();
-
- public abstract Builder setExclusiveGroup(boolean value);
-
- public Builder modifyRules(Consumer<List<PermissionRule.Builder>> modification) {
- modification.accept(rulesBuilders);
- return this;
- }
-
- public Builder add(PermissionRule.Builder rule) {
- return modifyRules(r -> r.add(rule));
- }
-
- public Builder remove(PermissionRule rule) {
- if (rule != null) {
- return removeRule(rule.getGroup());
- }
- return this;
- }
-
- public Builder removeRule(GroupReference group) {
- return modifyRules(rules -> rules.removeIf(rule -> sameGroup(rule.build(), group)));
- }
-
- public Builder clearRules() {
- return modifyRules(r -> r.clear());
- }
-
- public Permission build() {
- setRules(
- rulesBuilders.stream().map(PermissionRule.Builder::build).collect(toImmutableList()));
- return autoBuild();
- }
-
- public List<PermissionRule.Builder> getRulesBuilders() {
- return rulesBuilders;
- }
-
- protected abstract ImmutableList<PermissionRule> getRules();
-
- protected abstract Builder setRules(ImmutableList<PermissionRule> rules);
-
- protected abstract Permission autoBuild();
- }
-}
diff --git a/java/com/google/gerrit/common/data/PermissionRange.java b/java/com/google/gerrit/common/data/PermissionRange.java
deleted file mode 100644
index 97c3731..0000000
--- a/java/com/google/gerrit/common/data/PermissionRange.java
+++ /dev/null
@@ -1,144 +0,0 @@
-// Copyright (C) 2010 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.common.data;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Represents a closed interval [min, max] with a name. The special value [0, 0] is understood to be
- * the empty range.
- */
-public class PermissionRange implements Comparable<PermissionRange> {
- public static class WithDefaults extends PermissionRange {
- protected int defaultMin;
- protected int defaultMax;
-
- protected WithDefaults() {}
-
- public WithDefaults(String name, int min, int max, int defMin, int defMax) {
- super(name, min, max);
- setDefaultRange(defMin, defMax);
- }
-
- public int getDefaultMin() {
- return defaultMin;
- }
-
- public int getDefaultMax() {
- return defaultMax;
- }
-
- public void setDefaultRange(int min, int max) {
- defaultMin = min;
- defaultMax = max;
- }
-
- /** @return all values between {@link #getMin()} and {@link #getMax()} */
- public List<Integer> getValuesAsList() {
- ArrayList<Integer> r = new ArrayList<>(getRangeSize());
- for (int i = min; i <= max; i++) {
- r.add(i);
- }
- return r;
- }
-
- /** @return number of values between {@link #getMin()} and {@link #getMax()} */
- public int getRangeSize() {
- return max - min;
- }
- }
-
- protected String name;
- protected int min;
- protected int max;
-
- protected PermissionRange() {}
-
- public PermissionRange(String name, int min, int max) {
- this.name = name;
-
- if (min <= max) {
- this.min = min;
- this.max = max;
- } else {
- this.min = 0;
- this.max = 0;
- }
- }
-
- public String getName() {
- return name;
- }
-
- public boolean isLabel() {
- return Permission.isLabel(getName());
- }
-
- public String getLabel() {
- return Permission.extractLabel(getName());
- }
-
- public int getMin() {
- return min;
- }
-
- public int getMax() {
- return max;
- }
-
- /** True if the value is within the range. */
- public boolean contains(int value) {
- return getMin() <= value && value <= getMax();
- }
-
- /** Normalize the value to fit within the bounds of the range. */
- public int squash(int value) {
- return Math.min(Math.max(getMin(), value), getMax());
- }
-
- /** True both {@link #getMin()} and {@link #getMax()} are 0. */
- public boolean isEmpty() {
- return getMin() == 0 && getMax() == 0;
- }
-
- @Override
- public int compareTo(PermissionRange o) {
- return getName().compareTo(o.getName());
- }
-
- @Override
- public String toString() {
- StringBuilder r = new StringBuilder();
- if (getMin() < 0 && getMax() == 0) {
- r.append(getMin());
- r.append(' ');
- } else {
- if (getMin() != getMax()) {
- if (0 <= getMin()) {
- r.append('+');
- }
- r.append(getMin());
- r.append("..");
- }
- if (0 <= getMax()) {
- r.append('+');
- }
- r.append(getMax());
- r.append(' ');
- }
- return r.toString();
- }
-}
diff --git a/java/com/google/gerrit/common/data/PermissionRule.java b/java/com/google/gerrit/common/data/PermissionRule.java
deleted file mode 100644
index 1d2230c..0000000
--- a/java/com/google/gerrit/common/data/PermissionRule.java
+++ /dev/null
@@ -1,271 +0,0 @@
-// Copyright (C) 2010 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.common.data;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.entities.GroupReference;
-
-@AutoValue
-public abstract class PermissionRule implements Comparable<PermissionRule> {
- public enum Action {
- ALLOW,
- DENY,
- BLOCK,
-
- INTERACTIVE,
- BATCH
- }
-
- public abstract Action getAction();
-
- public abstract boolean getForce();
-
- public abstract int getMin();
-
- public abstract int getMax();
-
- public abstract GroupReference getGroup();
-
- public static PermissionRule.Builder builder(GroupReference group) {
- return builder().setGroup(group);
- }
-
- public static PermissionRule create(GroupReference group) {
- return builder().setGroup(group).build();
- }
-
- protected static Builder builder() {
- return new AutoValue_PermissionRule.Builder()
- .setMin(0)
- .setMax(0)
- .setAction(Action.ALLOW)
- .setForce(false);
- }
-
- static PermissionRule merge(PermissionRule src, PermissionRule dest) {
- PermissionRule.Builder result = dest.toBuilder();
- if (dest.getAction() != src.getAction()) {
- if (dest.getAction() == Action.BLOCK || src.getAction() == Action.BLOCK) {
- result.setAction(Action.BLOCK);
-
- } else if (dest.getAction() == Action.DENY || src.getAction() == Action.DENY) {
- result.setAction(Action.DENY);
-
- } else if (dest.getAction() == Action.BATCH || src.getAction() == Action.BATCH) {
- result.setAction(Action.BATCH);
- }
- }
-
- result.setForce(dest.getForce() || src.getForce());
- result.setRange(Math.min(dest.getMin(), src.getMin()), Math.max(dest.getMax(), src.getMax()));
- return result.build();
- }
-
- public boolean isDeny() {
- return getAction() == Action.DENY;
- }
-
- public boolean isBlock() {
- return getAction() == Action.BLOCK;
- }
-
- @Override
- public int compareTo(PermissionRule o) {
- int cmp = action(this) - action(o);
- if (cmp == 0) {
- cmp = range(o) - range(this);
- }
- if (cmp == 0) {
- cmp = group(this).compareTo(group(o));
- }
- return cmp;
- }
-
- private static int action(PermissionRule a) {
- switch (a.getAction()) {
- case DENY:
- return 0;
- case ALLOW:
- case BATCH:
- case BLOCK:
- case INTERACTIVE:
- default:
- return 1 + a.getAction().ordinal();
- }
- }
-
- private static int range(PermissionRule a) {
- return Math.abs(a.getMin()) + Math.abs(a.getMax());
- }
-
- private static String group(PermissionRule a) {
- return a.getGroup().getName() != null ? a.getGroup().getName() : "";
- }
-
- @Override
- public final String toString() {
- return asString(true);
- }
-
- public String asString(boolean canUseRange) {
- StringBuilder r = new StringBuilder();
-
- switch (getAction()) {
- case ALLOW:
- break;
-
- case DENY:
- r.append("deny ");
- break;
-
- case BLOCK:
- r.append("block ");
- break;
-
- case INTERACTIVE:
- r.append("interactive ");
- break;
-
- case BATCH:
- r.append("batch ");
- break;
- }
-
- if (getForce()) {
- r.append("+force ");
- }
-
- if (canUseRange && (getMin() != 0 || getMax() != 0)) {
- if (0 <= getMin()) {
- r.append('+');
- }
- r.append(getMin());
- r.append("..");
- if (0 <= getMax()) {
- r.append('+');
- }
- r.append(getMax());
- r.append(' ');
- }
-
- r.append(getGroup().toConfigValue());
-
- return r.toString();
- }
-
- public static PermissionRule fromString(String src, boolean mightUseRange) {
- final String orig = src;
- final PermissionRule.Builder rule = PermissionRule.builder();
-
- src = src.trim();
-
- if (src.startsWith("deny ")) {
- rule.setAction(Action.DENY);
- src = src.substring("deny ".length()).trim();
-
- } else if (src.startsWith("block ")) {
- rule.setAction(Action.BLOCK);
- src = src.substring("block ".length()).trim();
-
- } else if (src.startsWith("interactive ")) {
- rule.setAction(Action.INTERACTIVE);
- src = src.substring("interactive ".length()).trim();
-
- } else if (src.startsWith("batch ")) {
- rule.setAction(Action.BATCH);
- src = src.substring("batch ".length()).trim();
- }
-
- if (src.startsWith("+force ")) {
- rule.setForce(true);
- src = src.substring("+force ".length()).trim();
- }
-
- if (mightUseRange && !GroupReference.isGroupReference(src)) {
- int sp = src.indexOf(' ');
- String range = src.substring(0, sp);
-
- if (range.matches("^([+-]?\\d+)\\.\\.([+-]?\\d+)$")) {
- int dotdot = range.indexOf("..");
- int min = parseInt(range.substring(0, dotdot));
- int max = parseInt(range.substring(dotdot + 2));
- rule.setRange(min, max);
- } else {
- throw new IllegalArgumentException("Invalid range in rule: " + orig);
- }
-
- src = src.substring(sp + 1).trim();
- }
-
- String groupName = GroupReference.extractGroupName(src);
- if (groupName != null) {
- GroupReference group = GroupReference.create(groupName);
- rule.setGroup(group);
- } else {
- throw new IllegalArgumentException("Rule must include group: " + orig);
- }
-
- return rule.build();
- }
-
- public boolean hasRange() {
- return getMin() != 0 || getMax() != 0;
- }
-
- public static int parseInt(String value) {
- if (value.startsWith("+")) {
- value = value.substring(1);
- }
- return Integer.parseInt(value);
- }
-
- public abstract Builder toBuilder();
-
- @AutoValue.Builder
- public abstract static class Builder {
- public Builder setDeny() {
- return setAction(Action.DENY);
- }
-
- public Builder setBlock() {
- return setAction(Action.BLOCK);
- }
-
- public Builder setRange(int newMin, int newMax) {
- if (newMax < newMin) {
- setMin(newMax);
- setMax(newMin);
- } else {
- setMin(newMin);
- setMax(newMax);
- }
- return this;
- }
-
- public abstract Builder setAction(Action action);
-
- public abstract Builder setGroup(GroupReference groupReference);
-
- public abstract Builder setForce(boolean newForce);
-
- public abstract Builder setMin(int min);
-
- public abstract Builder setMax(int max);
-
- public abstract GroupReference getGroup();
-
- public abstract PermissionRule build();
- }
-}
diff --git a/java/com/google/gerrit/common/data/SubmitRecord.java b/java/com/google/gerrit/common/data/SubmitRecord.java
deleted file mode 100644
index fe5843ad..0000000
--- a/java/com/google/gerrit/common/data/SubmitRecord.java
+++ /dev/null
@@ -1,185 +0,0 @@
-// Copyright (C) 2011 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.common.data;
-
-import com.google.gerrit.entities.Account;
-import java.util.Collection;
-import java.util.List;
-import java.util.Objects;
-
-/** Describes the state and edits required to submit a change. */
-public class SubmitRecord {
- public static boolean allRecordsOK(Collection<SubmitRecord> in) {
- if (in == null || in.isEmpty()) {
- // If the list is null or empty, it means that this Gerrit installation does not
- // have any form of validation rules.
- // Hence, the permission system should be used to determine if the change can be merged
- // or not.
- return true;
- }
-
- // The change can be submitted, unless at least one plugin prevents it.
- return in.stream().map(SubmitRecord::status).allMatch(SubmitRecord.Status::allowsSubmission);
- }
-
- public enum Status {
- // NOTE: These values are persisted in the index, so deleting or changing
- // the name of any values requires a schema upgrade.
-
- /** The change is ready for submission. */
- OK,
-
- /** Something is preventing this change from being submitted. */
- NOT_READY,
-
- /** The change has been closed. */
- CLOSED,
-
- /** The change was submitted bypassing submit rules. */
- FORCED,
-
- /**
- * An internal server error occurred preventing computation.
- *
- * <p>Additional detail may be available in {@link SubmitRecord#errorMessage}.
- */
- RULE_ERROR;
-
- private boolean allowsSubmission() {
- return this == OK || this == FORCED;
- }
- }
-
- public Status status;
- public List<Label> labels;
- public List<SubmitRequirement> requirements;
- public String errorMessage;
-
- public static class Label {
- public enum Status {
- // NOTE: These values are persisted in the index, so deleting or changing
- // the name of any values requires a schema upgrade.
-
- /**
- * This label provides what is necessary for submission.
- *
- * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
- * to the change.
- */
- OK,
-
- /**
- * This label prevents the change from being submitted.
- *
- * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
- * to the change.
- */
- REJECT,
-
- /** The label is required for submission, but has not been satisfied. */
- NEED,
-
- /**
- * The label may be set, but it's neither necessary for submission nor does it block
- * submission if set.
- */
- MAY,
-
- /**
- * The label is required for submission, but is impossible to complete. The likely cause is
- * access has not been granted correctly by the project owner or site administrator.
- */
- IMPOSSIBLE
- }
-
- public String label;
- public Status status;
- public Account.Id appliedBy;
-
- @Override
- public String toString() {
- StringBuilder sb = new StringBuilder();
- sb.append(label).append(": ").append(status);
- if (appliedBy != null) {
- sb.append(" by ").append(appliedBy);
- }
- return sb.toString();
- }
-
- @Override
- public boolean equals(Object o) {
- if (o instanceof Label) {
- Label l = (Label) o;
- return Objects.equals(label, l.label)
- && Objects.equals(status, l.status)
- && Objects.equals(appliedBy, l.appliedBy);
- }
- return false;
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(label, status, appliedBy);
- }
- }
-
- @Override
- public String toString() {
- StringBuilder sb = new StringBuilder();
- sb.append(status);
- if (status == Status.RULE_ERROR && errorMessage != null) {
- sb.append('(').append(errorMessage).append(')');
- }
- sb.append('[');
- if (labels != null) {
- String delimiter = "";
- for (Label label : labels) {
- sb.append(delimiter).append(label);
- delimiter = ", ";
- }
- }
- sb.append("],[");
- if (requirements != null) {
- String delimiter = "";
- for (SubmitRequirement requirement : requirements) {
- sb.append(delimiter).append(requirement);
- delimiter = ", ";
- }
- }
- sb.append(']');
- return sb.toString();
- }
-
- @Override
- public boolean equals(Object o) {
- if (o instanceof SubmitRecord) {
- SubmitRecord r = (SubmitRecord) o;
- return Objects.equals(status, r.status)
- && Objects.equals(labels, r.labels)
- && Objects.equals(errorMessage, r.errorMessage)
- && Objects.equals(requirements, r.requirements);
- }
- return false;
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(status, labels, errorMessage, requirements);
- }
-
- private Status status() {
- return status;
- }
-}
diff --git a/java/com/google/gerrit/common/data/SubmitRequirement.java b/java/com/google/gerrit/common/data/SubmitRequirement.java
deleted file mode 100644
index 2c341bf..0000000
--- a/java/com/google/gerrit/common/data/SubmitRequirement.java
+++ /dev/null
@@ -1,60 +0,0 @@
-// 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.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.CharMatcher;
-
-/** Describes a requirement to submit a change. */
-@AutoValue
-@AutoValue.CopyAnnotations
-public abstract class SubmitRequirement {
- private static final CharMatcher TYPE_MATCHER =
- CharMatcher.inRange('a', 'z')
- .or(CharMatcher.inRange('A', 'Z'))
- .or(CharMatcher.inRange('0', '9'))
- .or(CharMatcher.anyOf("-_"));
-
- @AutoValue.Builder
- public abstract static class Builder {
- public abstract Builder setType(String value);
-
- public abstract Builder setFallbackText(String value);
-
- public SubmitRequirement build() {
- SubmitRequirement requirement = autoBuild();
- checkState(
- validateType(requirement.type()),
- "SubmitRequirement's type contains non alphanumerical symbols.");
- return requirement;
- }
-
- abstract SubmitRequirement autoBuild();
- }
-
- public abstract String fallbackText();
-
- public abstract String type();
-
- public static Builder builder() {
- return new AutoValue_SubmitRequirement.Builder();
- }
-
- private static boolean validateType(String type) {
- return TYPE_MATCHER.matchesAllOf(type);
- }
-}
diff --git a/java/com/google/gerrit/common/data/SubmitTypeRecord.java b/java/com/google/gerrit/common/data/SubmitTypeRecord.java
deleted file mode 100644
index afb3bac..0000000
--- a/java/com/google/gerrit/common/data/SubmitTypeRecord.java
+++ /dev/null
@@ -1,77 +0,0 @@
-// Copyright (C) 2012 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.common.data;
-
-import com.google.gerrit.extensions.client.SubmitType;
-
-/** Describes the submit type for a change. */
-public class SubmitTypeRecord {
- public enum Status {
- /** The type was computed successfully */
- OK,
-
- /**
- * An internal server error occurred preventing computation.
- *
- * <p>Additional detail may be available in {@link SubmitTypeRecord#errorMessage}
- */
- RULE_ERROR
- }
-
- public static SubmitTypeRecord OK(SubmitType type) {
- return new SubmitTypeRecord(Status.OK, type, null);
- }
-
- public static SubmitTypeRecord error(String err) {
- return new SubmitTypeRecord(SubmitTypeRecord.Status.RULE_ERROR, null, err);
- }
-
- /** Status enum value of the record. */
- public final Status status;
-
- /** Submit type of the record; never null if {@link #status} is {@code OK}. */
- public final SubmitType type;
-
- /** Submit type of the record; always null if {@link #status} is {@code OK}. */
- public final String errorMessage;
-
- private SubmitTypeRecord(Status status, SubmitType type, String errorMessage) {
- if (type == SubmitType.INHERIT) {
- throw new IllegalArgumentException("Cannot output submit type " + type);
- }
- this.status = status;
- this.type = type;
- this.errorMessage = errorMessage;
- }
-
- public boolean isOk() {
- return status == Status.OK;
- }
-
- @Override
- public String toString() {
- StringBuilder sb = new StringBuilder();
- sb.append(status);
- if (status == Status.RULE_ERROR && errorMessage != null) {
- sb.append(" (").append(errorMessage).append(")");
- }
- if (type != null) {
- sb.append('[');
- sb.append(type.name());
- sb.append(']');
- }
- return sb.toString();
- }
-}
diff --git a/java/com/google/gerrit/common/data/SubscribeSection.java b/java/com/google/gerrit/common/data/SubscribeSection.java
deleted file mode 100644
index 533a2f0..0000000
--- a/java/com/google/gerrit/common/data/SubscribeSection.java
+++ /dev/null
@@ -1,164 +0,0 @@
-// 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.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.Project;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Set;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.transport.RefSpec;
-
-/** Portion of a {@link Project} describing superproject subscription rules. */
-@AutoValue
-public abstract class SubscribeSection {
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
- public abstract Project.NameKey project();
-
- protected abstract ImmutableList<RefSpec> matchingRefSpecs();
-
- protected abstract ImmutableList<RefSpec> multiMatchRefSpecs();
-
- public static Builder builder(Project.NameKey project) {
- return new AutoValue_SubscribeSection.Builder().project(project);
- }
-
- public abstract Builder toBuilder();
-
- @AutoValue.Builder
- public abstract static class Builder {
- public abstract Builder project(Project.NameKey project);
-
- abstract ImmutableList.Builder<RefSpec> matchingRefSpecsBuilder();
-
- abstract ImmutableList.Builder<RefSpec> multiMatchRefSpecsBuilder();
-
- public Builder addMatchingRefSpec(String matchingRefSpec) {
- matchingRefSpecsBuilder()
- .add(new RefSpec(matchingRefSpec, RefSpec.WildcardMode.REQUIRE_MATCH));
- return this;
- }
-
- public Builder addMultiMatchRefSpec(String multiMatchRefSpec) {
- multiMatchRefSpecsBuilder()
- .add(new RefSpec(multiMatchRefSpec, RefSpec.WildcardMode.ALLOW_MISMATCH));
- return this;
- }
-
- public abstract SubscribeSection build();
- }
-
- /**
- * Determines if the <code>branch</code> could trigger a superproject update as allowed via this
- * subscribe section.
- *
- * @param branch the branch to check
- * @return if the branch could trigger a superproject update
- */
- public boolean appliesTo(BranchNameKey branch) {
- for (RefSpec r : matchingRefSpecs()) {
- if (r.matchSource(branch.branch())) {
- return true;
- }
- }
- for (RefSpec r : multiMatchRefSpecs()) {
- if (r.matchSource(branch.branch())) {
- return true;
- }
- }
- return false;
- }
-
- public Collection<String> matchingRefSpecsAsString() {
- return matchingRefSpecs().stream().map(RefSpec::toString).collect(toImmutableList());
- }
-
- public Collection<String> multiMatchRefSpecsAsString() {
- return multiMatchRefSpecs().stream().map(RefSpec::toString).collect(toImmutableList());
- }
-
- /** Evaluates what the destination branches for the subscription are. */
- public ImmutableSet<BranchNameKey> getDestinationBranches(
- BranchNameKey src, Collection<Ref> allRefsInRefsHeads) {
- Set<BranchNameKey> ret = new HashSet<>();
- logger.atFine().log("Inspecting SubscribeSection %s", this);
- for (RefSpec r : matchingRefSpecs()) {
- logger.atFine().log("Inspecting [matching] ref %s", r);
- if (!r.matchSource(src.branch())) {
- continue;
- }
- if (r.isWildcard()) {
- // refs/heads/*[:refs/somewhere/*]
- ret.add(BranchNameKey.create(project(), r.expandFromSource(src.branch()).getDestination()));
- } else {
- // e.g. refs/heads/master[:refs/heads/stable]
- String dest = r.getDestination();
- if (dest == null) {
- dest = r.getSource();
- }
- ret.add(BranchNameKey.create(project(), dest));
- }
- }
-
- for (RefSpec r : multiMatchRefSpecs()) {
- logger.atFine().log("Inspecting [all] ref %s", r);
- if (!r.matchSource(src.branch())) {
- continue;
- }
- for (Ref ref : allRefsInRefsHeads) {
- if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
- continue;
- }
- BranchNameKey b = BranchNameKey.create(project(), ref.getName());
- if (!ret.contains(b)) {
- ret.add(b);
- }
- }
- }
- logger.atFine().log("Returning possible branches: %s for project %s", ret, project());
- return ImmutableSet.copyOf(ret);
- }
-
- @Override
- public final String toString() {
- StringBuilder ret = new StringBuilder();
- ret.append("[SubscribeSection, project=");
- ret.append(project());
- if (!matchingRefSpecs().isEmpty()) {
- ret.append(", matching=[");
- for (RefSpec r : matchingRefSpecs()) {
- ret.append(r.toString());
- ret.append(", ");
- }
- }
- if (!multiMatchRefSpecs().isEmpty()) {
- ret.append(", all=[");
- for (RefSpec r : multiMatchRefSpecs()) {
- ret.append(r.toString());
- ret.append(", ");
- }
- }
- ret.append("]");
- return ret.toString();
- }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
index 7233682..779d433 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
@@ -14,7 +14,7 @@
package com.google.gerrit.elasticsearch;
-import static com.google.gerrit.elasticsearch.ElasticVersion.V6_7;
+import static com.google.gerrit.elasticsearch.ElasticVersion.V6_8;
public class ElasticQueryAdapter {
static final String V6_TYPE = "_doc";
@@ -41,13 +41,15 @@
this.defaultNumberOfShards = version.isV7OrLater() ? 1 : 5;
this.versionDiscoveryUrl = version.isV6OrLater() ? "/%s*" : "/%s*/_aliases";
this.searchFilteringName = "_source";
- this.indicesExistParams =
- version.isAtLeastMinorVersion(V6_7) ? INDICES + "&" + INCLUDE_TYPE : INDICES;
this.exactFieldType = "keyword";
this.stringFieldType = "text";
this.indexProperty = "true";
this.rawFieldsKey = "_source";
- this.includeTypeNameParam = version.isAtLeastMinorVersion(V6_7) ? "?" + INCLUDE_TYPE : "";
+
+ // Since v6.7 (end-of-life), in fact, for these two parameters:
+ this.indicesExistParams =
+ version.isAtLeastMinorVersion(V6_8) ? INDICES + "&" + INCLUDE_TYPE : INDICES;
+ this.includeTypeNameParam = version.isAtLeastMinorVersion(V6_8) ? "?" + INCLUDE_TYPE : "";
}
public String searchFilteringName() {
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index 62fcfda..b3f1471 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -18,8 +18,6 @@
import java.util.regex.Pattern;
public enum ElasticVersion {
- V6_6("6.6.*"),
- V6_7("6.7.*"),
V6_8("6.8.*"),
V7_0("7.0.*"),
V7_1("7.1.*"),
diff --git a/java/com/google/gerrit/entities/AccessSection.java b/java/com/google/gerrit/entities/AccessSection.java
new file mode 100644
index 0000000..d97bca8
--- /dev/null
+++ b/java/com/google/gerrit/entities/AccessSection.java
@@ -0,0 +1,165 @@
+// Copyright (C) 2010 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.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
+
+/** Portion of a {@link Project} describing access rules. */
+@AutoValue
+public abstract class AccessSection implements Comparable<AccessSection> {
+ /** Special name given to the global capabilities; not a valid reference. */
+ public static final String GLOBAL_CAPABILITIES = "GLOBAL_CAPABILITIES";
+ /** Pattern that matches all references in a project. */
+ public static final String ALL = "refs/*";
+
+ /** Pattern that matches all branches in a project. */
+ public static final String HEADS = "refs/heads/*";
+
+ /** Prefix that triggers a regular expression pattern. */
+ public static final String REGEX_PREFIX = "^";
+
+ /** Name of the access section. It could be a ref pattern or something else. */
+ public abstract String getName();
+
+ public abstract ImmutableList<Permission> getPermissions();
+
+ public static AccessSection create(String name) {
+ return builder(name).build();
+ }
+
+ public static Builder builder(String name) {
+ return new AutoValue_AccessSection.Builder().setName(name).setPermissions(ImmutableList.of());
+ }
+
+ /** @return true if the name is likely to be a valid reference section name. */
+ public static boolean isValidRefSectionName(String name) {
+ return name.startsWith("refs/") || name.startsWith("^refs/");
+ }
+
+ @Nullable
+ public Permission getPermission(String name) {
+ requireNonNull(name);
+ for (Permission p : getPermissions()) {
+ if (p.getName().equalsIgnoreCase(name)) {
+ return p;
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public final int compareTo(AccessSection o) {
+ return comparePattern().compareTo(o.comparePattern());
+ }
+
+ private String comparePattern() {
+ if (getName().startsWith(REGEX_PREFIX)) {
+ return getName().substring(REGEX_PREFIX.length());
+ }
+ return getName();
+ }
+
+ @Override
+ public final String toString() {
+ return "AccessSection[" + getName() + "]";
+ }
+
+ public Builder toBuilder() {
+ Builder b = autoToBuilder();
+ b.getPermissions().stream().map(Permission::toBuilder).forEach(p -> b.addPermission(p));
+ return b;
+ }
+
+ protected abstract Builder autoToBuilder();
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ private final List<Permission.Builder> permissionBuilders;
+
+ protected Builder() {
+ permissionBuilders = new ArrayList<>();
+ }
+
+ public abstract Builder setName(String name);
+
+ public abstract String getName();
+
+ public Builder modifyPermissions(Consumer<List<Permission.Builder>> modification) {
+ modification.accept(permissionBuilders);
+ return this;
+ }
+
+ public Builder addPermission(Permission.Builder permission) {
+ requireNonNull(permission, "permission must be non-null");
+ return modifyPermissions(p -> p.add(permission));
+ }
+
+ public Builder remove(Permission.Builder permission) {
+ requireNonNull(permission, "permission must be non-null");
+ return removePermission(permission.getName());
+ }
+
+ public Builder removePermission(String name) {
+ requireNonNull(name, "name must be non-null");
+ return modifyPermissions(
+ p -> p.removeIf(permissionBuilder -> name.equalsIgnoreCase(permissionBuilder.getName())));
+ }
+
+ public Permission.Builder upsertPermission(String permissionName) {
+ requireNonNull(permissionName, "permissionName must be non-null");
+
+ Optional<Permission.Builder> maybePermission =
+ permissionBuilders.stream()
+ .filter(p -> p.getName().equalsIgnoreCase(permissionName))
+ .findAny();
+ if (maybePermission.isPresent()) {
+ return maybePermission.get();
+ }
+
+ Permission.Builder permission = Permission.builder(permissionName);
+ modifyPermissions(p -> p.add(permission));
+ return permission;
+ }
+
+ public AccessSection build() {
+ setPermissions(
+ permissionBuilders.stream().map(Permission.Builder::build).collect(toImmutableList()));
+ if (getPermissions().size()
+ > getPermissions().stream()
+ .map(Permission::getName)
+ .map(String::toLowerCase)
+ .distinct()
+ .count()) {
+ throw new IllegalArgumentException("duplicate permissions: " + getPermissions());
+ }
+ return autoBuild();
+ }
+
+ protected abstract AccessSection autoBuild();
+
+ protected abstract ImmutableList<Permission> getPermissions();
+
+ abstract Builder setPermissions(ImmutableList<Permission> permissions);
+ }
+}
diff --git a/java/com/google/gerrit/entities/AccountsSection.java b/java/com/google/gerrit/entities/AccountsSection.java
new file mode 100644
index 0000000..93083a2
--- /dev/null
+++ b/java/com/google/gerrit/entities/AccountsSection.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2010 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+
+@AutoValue
+public abstract class AccountsSection {
+ public abstract ImmutableList<PermissionRule> getSameGroupVisibility();
+
+ public static AccountsSection create(List<PermissionRule> sameGroupVisibility) {
+ return new AutoValue_AccountsSection(ImmutableList.copyOf(sameGroupVisibility));
+ }
+}
diff --git a/java/com/google/gerrit/entities/BUILD b/java/com/google/gerrit/entities/BUILD
index 66d1869..c0f5de6 100644
--- a/java/com/google/gerrit/entities/BUILD
+++ b/java/com/google/gerrit/entities/BUILD
@@ -10,11 +10,13 @@
deps = [
"//java/com/google/gerrit/common:annotations",
"//java/com/google/gerrit/extensions:api",
+ "//lib:gson",
"//lib:guava",
"//lib:jgit",
"//lib:protobuf",
"//lib/auto:auto-value",
"//lib/auto:auto-value-annotations",
+ "//lib/auto:auto-value-gson",
"//lib/errorprone:annotations",
"//lib/flogger:api",
"//proto:cache_java_proto",
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
new file mode 100644
index 0000000..0b755b7
--- /dev/null
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -0,0 +1,273 @@
+// 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.entities;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Cached representation of values parsed from {@link
+ * com.google.gerrit.server.project.ProjectConfig}.
+ *
+ * <p>This class is immutable and thread-safe.
+ */
+@AutoValue
+public abstract class CachedProjectConfig {
+ public abstract Project getProject();
+
+ public abstract ImmutableMap<AccountGroup.UUID, GroupReference> getGroups();
+
+ /** Returns a set of all groups used by this configuration. */
+ public ImmutableSet<AccountGroup.UUID> getAllGroupUUIDs() {
+ return getGroups().keySet();
+ }
+
+ /**
+ * Returns the group reference for a {@link AccountGroup.UUID}, if the group is used by at least
+ * one rule.
+ */
+ public Optional<GroupReference> getGroup(AccountGroup.UUID uuid) {
+ return Optional.ofNullable(getGroups().get(uuid));
+ }
+
+ /**
+ * Returns the group reference for matching the given {@code name}, if the group is used by at
+ * least one rule.
+ */
+ public Optional<GroupReference> getGroupByName(@Nullable String name) {
+ if (name == null) {
+ return Optional.empty();
+ }
+ return getGroups().values().stream().filter(g -> name.equals(g.getName())).findAny();
+ }
+
+ /** Returns the account section containing visibility information about accounts. */
+ public abstract AccountsSection getAccountsSection();
+
+ /** Returns a map of {@link AccessSection}s keyed by their name. */
+ public abstract ImmutableMap<String, AccessSection> getAccessSections();
+
+ /** Returns the {@link AccessSection} with to the given name. */
+ public Optional<AccessSection> getAccessSection(String refName) {
+ return Optional.ofNullable(getAccessSections().get(refName));
+ }
+
+ /** Returns all {@link AccessSection} names. */
+ public ImmutableSet<String> getAccessSectionNames() {
+ return ImmutableSet.copyOf(getAccessSections().keySet());
+ }
+
+ /**
+ * Returns the {@link BranchOrderSection} containing the order in which branches should be shown.
+ */
+ public abstract Optional<BranchOrderSection> getBranchOrderSection();
+
+ /** Returns the {@link ContributorAgreement}s keyed by their name. */
+ public abstract ImmutableMap<String, ContributorAgreement> getContributorAgreements();
+
+ /** Returns the {@link NotifyConfig}s keyed by their name. */
+ public abstract ImmutableMap<String, NotifyConfig> getNotifySections();
+
+ /** Returns the {@link LabelType}s keyed by their name. */
+ public abstract ImmutableMap<String, LabelType> getLabelSections();
+
+ /** Returns configured {@link ConfiguredMimeTypes}s. */
+ public abstract ConfiguredMimeTypes getMimeTypes();
+
+ /** Returns {@link SubscribeSection} keyed by the {@link Project.NameKey} they reference. */
+ public abstract ImmutableMap<Project.NameKey, SubscribeSection> getSubscribeSections();
+
+ /** Returns {@link StoredCommentLinkInfo} keyed by their name. */
+ public abstract ImmutableMap<String, StoredCommentLinkInfo> getCommentLinkSections();
+
+ /** Returns the blob ID of the {@code rules.pl} file, if present. */
+ public abstract Optional<ObjectId> getRulesId();
+
+ // TODO(hiesel): This should not have to be an Optional.
+ /** Returns the SHA1 of the {@code refs/meta/config} branch. */
+ public abstract Optional<ObjectId> getRevision();
+
+ /** Returns the maximum allowed object size. */
+ public abstract long getMaxObjectSizeLimit();
+
+ /** Returns {@code true} if received objects should be checked for validity. */
+ public abstract boolean getCheckReceivedObjects();
+
+ /** Returns a list of panel sections keyed by title. */
+ public abstract ImmutableMap<String, ImmutableList<String>> getExtensionPanelSections();
+
+ public ImmutableList<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
+ return filterSubscribeSectionsByBranch(getSubscribeSections().values(), branch);
+ }
+
+ public abstract ImmutableMap<String, String> getPluginConfigs();
+
+ /**
+ * Returns the {@link Config} that got parsed from the specified {@code fileName} on {@code
+ * refs/meta/config}. The returned instance is a defensive copy of the cached value.
+ *
+ * @param fileName the name of the file. Must end in {@code .config}.
+ * @return an {@link Optional} of the {@link Config}. {@link Optional#empty()} if the file was not
+ * found or could not be parsed. {@link com.google.gerrit.server.project.ProjectConfig} will
+ * surface validation errors in case of a parsing issue.
+ */
+ public Optional<Config> getProjectLevelConfig(String fileName) {
+ checkState(fileName.endsWith(".config"), "file name must end in .config");
+ if (getProjectLevelConfigs().containsKey(fileName)) {
+ Config config = new Config();
+ try {
+ config.fromText(getProjectLevelConfigs().get(fileName));
+ } catch (ConfigInvalidException e) {
+ // This is OK to propagate as IllegalStateException because it's a programmer error.
+ // The config was converted to a String using Config#toText. So #fromText must not
+ // throw a ConfigInvalidException
+ throw new IllegalStateException("invalid config for " + fileName, e);
+ }
+ return Optional.of(config);
+ }
+ return Optional.empty();
+ }
+
+ public abstract ImmutableMap<String, String> getProjectLevelConfigs();
+
+ public static Builder builder() {
+ return new AutoValue_CachedProjectConfig.Builder();
+ }
+
+ public abstract Builder toBuilder();
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ public abstract Builder setProject(Project value);
+
+ public abstract Builder setAccountsSection(AccountsSection value);
+
+ public abstract Builder setBranchOrderSection(Optional<BranchOrderSection> value);
+
+ public Builder addGroup(GroupReference groupReference) {
+ groupsBuilder().put(groupReference.getUUID(), groupReference);
+ return this;
+ }
+
+ public Builder addAccessSection(AccessSection accessSection) {
+ accessSectionsBuilder().put(accessSection.getName(), accessSection);
+ return this;
+ }
+
+ public Builder addContributorAgreement(ContributorAgreement contributorAgreement) {
+ contributorAgreementsBuilder().put(contributorAgreement.getName(), contributorAgreement);
+ return this;
+ }
+
+ public Builder addNotifySection(NotifyConfig notifyConfig) {
+ notifySectionsBuilder().put(notifyConfig.getName(), notifyConfig);
+ return this;
+ }
+
+ public Builder addLabelSection(LabelType labelType) {
+ labelSectionsBuilder().put(labelType.getName(), labelType);
+ return this;
+ }
+
+ public abstract Builder setMimeTypes(ConfiguredMimeTypes value);
+
+ public Builder addSubscribeSection(SubscribeSection subscribeSection) {
+ subscribeSectionsBuilder().put(subscribeSection.project(), subscribeSection);
+ return this;
+ }
+
+ public Builder addCommentLinkSection(StoredCommentLinkInfo storedCommentLinkInfo) {
+ commentLinkSectionsBuilder().put(storedCommentLinkInfo.getName(), storedCommentLinkInfo);
+ return this;
+ }
+
+ public abstract Builder setRulesId(Optional<ObjectId> value);
+
+ public abstract Builder setRevision(Optional<ObjectId> value);
+
+ public abstract Builder setMaxObjectSizeLimit(long value);
+
+ public abstract Builder setCheckReceivedObjects(boolean value);
+
+ public abstract ImmutableMap.Builder<String, ImmutableList<String>>
+ extensionPanelSectionsBuilder();
+
+ public Builder setExtensionPanelSections(Map<String, List<String>> value) {
+ value
+ .entrySet()
+ .forEach(
+ e ->
+ extensionPanelSectionsBuilder()
+ .put(e.getKey(), ImmutableList.copyOf(e.getValue())));
+ return this;
+ }
+
+ abstract ImmutableMap.Builder<String, String> pluginConfigsBuilder();
+
+ public Builder addPluginConfig(String pluginName, String pluginConfig) {
+ pluginConfigsBuilder().put(pluginName, pluginConfig);
+ return this;
+ }
+
+ abstract ImmutableMap.Builder<String, String> projectLevelConfigsBuilder();
+
+ public Builder addProjectLevelConfig(String configFileName, String config) {
+ projectLevelConfigsBuilder().put(configFileName, config);
+ return this;
+ }
+
+ public abstract CachedProjectConfig build();
+
+ protected abstract ImmutableMap.Builder<AccountGroup.UUID, GroupReference> groupsBuilder();
+
+ protected abstract ImmutableMap.Builder<String, AccessSection> accessSectionsBuilder();
+
+ protected abstract ImmutableMap.Builder<String, ContributorAgreement>
+ contributorAgreementsBuilder();
+
+ protected abstract ImmutableMap.Builder<String, NotifyConfig> notifySectionsBuilder();
+
+ protected abstract ImmutableMap.Builder<String, LabelType> labelSectionsBuilder();
+
+ protected abstract ImmutableMap.Builder<Project.NameKey, SubscribeSection>
+ subscribeSectionsBuilder();
+
+ protected abstract ImmutableMap.Builder<String, StoredCommentLinkInfo>
+ commentLinkSectionsBuilder();
+ }
+
+ private static ImmutableList<SubscribeSection> filterSubscribeSectionsByBranch(
+ Collection<SubscribeSection> allSubscribeSections, BranchNameKey branch) {
+ ImmutableList.Builder<SubscribeSection> ret = ImmutableList.builder();
+ for (SubscribeSection s : allSubscribeSections) {
+ if (s.appliesTo(branch)) {
+ ret.add(s);
+ }
+ }
+ return ret.build();
+ }
+}
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 845a9bb..aab72ea72 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -21,6 +21,9 @@
import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.annotations.SerializedName;
import java.sql.Timestamp;
import java.util.Arrays;
import java.util.Optional;
@@ -283,6 +286,7 @@
return Change.key(KeyUtil.decode(str));
}
+ @SerializedName("id")
abstract String key();
public String get() {
@@ -307,6 +311,10 @@
public final String toString() {
return get();
}
+
+ public static TypeAdapter<Key> typeAdapter(Gson gson) {
+ return new AutoValue_Change_Key.GsonTypeAdapter(gson);
+ }
}
/** Minimum database status constant for an open change. */
diff --git a/java/com/google/gerrit/entities/Comment.java b/java/com/google/gerrit/entities/Comment.java
index 2c10c87..37b8620 100644
--- a/java/com/google/gerrit/entities/Comment.java
+++ b/java/com/google/gerrit/entities/Comment.java
@@ -231,7 +231,6 @@
private String revId;
public String serverId;
- public boolean unresolved;
public Comment(Comment c) {
this(
@@ -240,14 +239,13 @@
new Timestamp(c.writtenOn.getTime()),
c.side,
c.message,
- c.serverId,
- c.unresolved);
+ c.serverId);
this.lineNbr = c.lineNbr;
this.realAuthor = c.realAuthor;
+ this.parentUuid = c.parentUuid;
this.range = c.range != null ? new Range(c.range) : null;
this.tag = c.tag;
this.revId = c.revId;
- this.unresolved = c.unresolved;
}
public Comment(
@@ -256,8 +254,7 @@
Timestamp writtenOn,
short side,
String message,
- String serverId,
- boolean unresolved) {
+ String serverId) {
this.key = key;
this.author = new Comment.Identity(author);
this.realAuthor = this.author;
@@ -265,7 +262,6 @@
this.side = side;
this.message = message;
this.serverId = serverId;
- this.unresolved = unresolved;
}
public void setLineNbrAndRange(
@@ -333,8 +329,7 @@
&& Objects.equals(range, c.range)
&& Objects.equals(tag, c.tag)
&& Objects.equals(revId, c.revId)
- && Objects.equals(serverId, c.serverId)
- && unresolved == c.unresolved;
+ && Objects.equals(serverId, c.serverId);
}
@Override
@@ -351,8 +346,7 @@
range,
tag,
revId,
- serverId,
- unresolved);
+ serverId);
}
@Override
@@ -372,7 +366,6 @@
.add("parentUuid", Objects.toString(parentUuid, ""))
.add("range", Objects.toString(range, ""))
.add("revId", Objects.toString(revId, ""))
- .add("tag", Objects.toString(tag, ""))
- .add("unresolved", unresolved);
+ .add("tag", Objects.toString(tag, ""));
}
}
diff --git a/java/com/google/gerrit/entities/CommentContext.java b/java/com/google/gerrit/entities/CommentContext.java
new file mode 100644
index 0000000..183f6d0
--- /dev/null
+++ b/java/com/google/gerrit/entities/CommentContext.java
@@ -0,0 +1,29 @@
+// 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+
+/** An entity class representing all context lines of a comment. */
+@AutoValue
+public abstract class CommentContext {
+ public static CommentContext create(ImmutableMap<Integer, String> lines) {
+ return new AutoValue_CommentContext(lines);
+ }
+
+ /** Map of {line number, line text} of the context lines of a comment */
+ public abstract ImmutableMap<Integer, String> lines();
+}
diff --git a/java/com/google/gerrit/entities/ConfiguredMimeTypes.java b/java/com/google/gerrit/entities/ConfiguredMimeTypes.java
index c28a573..6ba89c9 100644
--- a/java/com/google/gerrit/entities/ConfiguredMimeTypes.java
+++ b/java/com/google/gerrit/entities/ConfiguredMimeTypes.java
@@ -33,7 +33,7 @@
private static final String MIMETYPE = "mimetype";
private static final String KEY_PATH = "path";
- protected abstract ImmutableList<TypeMatcher> matchers();
+ public abstract ImmutableList<TypeMatcher> matchers();
public static ConfiguredMimeTypes create(String projectName, Config rc) {
Set<String> types = rc.getSubsections(MIMETYPE);
@@ -58,6 +58,10 @@
return new AutoValue_ConfiguredMimeTypes(matchers.build());
}
+ public static ConfiguredMimeTypes create(ImmutableList<TypeMatcher> matchers) {
+ return new AutoValue_ConfiguredMimeTypes(matchers);
+ }
+
@Nullable
public String getMimeType(String path) {
for (TypeMatcher m : matchers()) {
diff --git a/java/com/google/gerrit/entities/ContributorAgreement.java b/java/com/google/gerrit/entities/ContributorAgreement.java
new file mode 100644
index 0000000..1d933b5
--- /dev/null
+++ b/java/com/google/gerrit/entities/ContributorAgreement.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2012 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.entities;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.util.List;
+
+/** Portion of a {@link Project} describing a single contributor agreement. */
+@AutoValue
+public abstract class ContributorAgreement implements Comparable<ContributorAgreement> {
+ public abstract String getName();
+
+ @Nullable
+ public abstract String getDescription();
+
+ public abstract ImmutableList<PermissionRule> getAccepted();
+
+ @Nullable
+ public abstract GroupReference getAutoVerify();
+
+ @Nullable
+ public abstract String getAgreementUrl();
+
+ public abstract ImmutableList<String> getExcludeProjectsRegexes();
+
+ public abstract ImmutableList<String> getMatchProjectsRegexes();
+
+ public static ContributorAgreement.Builder builder(String name) {
+ return new AutoValue_ContributorAgreement.Builder()
+ .setName(name)
+ .setAccepted(ImmutableList.of())
+ .setExcludeProjectsRegexes(ImmutableList.of())
+ .setMatchProjectsRegexes(ImmutableList.of());
+ }
+
+ @Override
+ public final int compareTo(ContributorAgreement o) {
+ return getName().compareTo(o.getName());
+ }
+
+ @Override
+ public final String toString() {
+ return "ContributorAgreement[" + getName() + "]";
+ }
+
+ public abstract Builder toBuilder();
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ public abstract Builder setName(String name);
+
+ public abstract Builder setDescription(@Nullable String description);
+
+ public abstract Builder setAccepted(ImmutableList<PermissionRule> accepted);
+
+ public abstract Builder setAutoVerify(@Nullable GroupReference autoVerify);
+
+ public abstract Builder setAgreementUrl(@Nullable String agreementUrl);
+
+ public abstract Builder setExcludeProjectsRegexes(List<String> excludeProjectsRegexes);
+
+ public abstract Builder setMatchProjectsRegexes(List<String> matchProjectsRegexes);
+
+ public abstract ContributorAgreement build();
+ }
+}
diff --git a/java/com/google/gerrit/entities/EntitiesAdapterFactory.java b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
new file mode 100644
index 0000000..e6a06fd
--- /dev/null
+++ b/java/com/google/gerrit/entities/EntitiesAdapterFactory.java
@@ -0,0 +1,25 @@
+// 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.entities;
+
+import com.google.gson.TypeAdapterFactory;
+import com.ryanharter.auto.value.gson.GsonTypeAdapterFactory;
+
+@GsonTypeAdapterFactory
+public abstract class EntitiesAdapterFactory implements TypeAdapterFactory {
+ public static TypeAdapterFactory create() {
+ return new AutoValueGson_EntitiesAdapterFactory();
+ }
+}
diff --git a/java/com/google/gerrit/entities/GroupReference.java b/java/com/google/gerrit/entities/GroupReference.java
index 9185d53..208ba0f 100644
--- a/java/com/google/gerrit/entities/GroupReference.java
+++ b/java/com/google/gerrit/entities/GroupReference.java
@@ -19,7 +19,7 @@
import com.google.auto.value.AutoValue;
import com.google.gerrit.common.Nullable;
-/** Describes a group within a projects {@link com.google.gerrit.common.data.AccessSection}s. */
+/** Describes a group within a projects {@link AccessSection}s. */
@AutoValue
public abstract class GroupReference implements Comparable<GroupReference> {
diff --git a/java/com/google/gerrit/entities/HumanComment.java b/java/com/google/gerrit/entities/HumanComment.java
index 8b687cc..50bee8d 100644
--- a/java/com/google/gerrit/entities/HumanComment.java
+++ b/java/com/google/gerrit/entities/HumanComment.java
@@ -15,6 +15,7 @@
package com.google.gerrit.entities;
import java.sql.Timestamp;
+import java.util.Objects;
/**
* This class represents inline human comments in NoteDb. This means it determines the JSON format
@@ -27,6 +28,8 @@
*/
public class HumanComment extends Comment {
+ public boolean unresolved;
+
public HumanComment(
Key key,
Account.Id author,
@@ -35,7 +38,8 @@
String message,
String serverId,
boolean unresolved) {
- super(key, author, writtenOn, side, message, serverId, unresolved);
+ super(key, author, writtenOn, side, message, serverId);
+ this.unresolved = unresolved;
}
public HumanComment(HumanComment comment) {
@@ -49,19 +53,23 @@
@Override
public String toString() {
- return toStringHelper().toString();
+ return toStringHelper().add("unresolved", unresolved).toString();
}
@Override
- public boolean equals(Object o) {
- if (!(o instanceof HumanComment)) {
+ public boolean equals(Object otherObject) {
+ if (!(otherObject instanceof HumanComment)) {
return false;
}
- return super.equals(o);
+ if (!super.equals(otherObject)) {
+ return false;
+ }
+ HumanComment otherComment = (HumanComment) otherObject;
+ return unresolved == otherComment.unresolved;
}
@Override
public int hashCode() {
- return super.hashCode();
+ return Objects.hash(super.hashCode(), unresolved);
}
}
diff --git a/java/com/google/gerrit/entities/LabelFunction.java b/java/com/google/gerrit/entities/LabelFunction.java
new file mode 100644
index 0000000..f361741
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelFunction.java
@@ -0,0 +1,123 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.SubmitRecord.Label;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Functions for determining submittability based on label votes.
+ *
+ * <p>Only describes built-in label functions. Admins can extend the logic arbitrarily using Prolog
+ * rules, in which case the choice of function in the project config is ignored.
+ *
+ * <p>Function semantics are documented in {@code config-labels.txt}, and actual behavior is
+ * implemented both in Prolog in {@code gerrit_common.pl} and in the {@link #check} method.
+ */
+public enum LabelFunction {
+ ANY_WITH_BLOCK("AnyWithBlock", true, false, false),
+ MAX_WITH_BLOCK("MaxWithBlock", true, true, true),
+ MAX_NO_BLOCK("MaxNoBlock", false, true, true),
+ NO_BLOCK("NoBlock"),
+ NO_OP("NoOp"),
+ PATCH_SET_LOCK("PatchSetLock");
+
+ public static final Map<String, LabelFunction> ALL;
+
+ static {
+ Map<String, LabelFunction> all = new LinkedHashMap<>();
+ for (LabelFunction f : values()) {
+ all.put(f.getFunctionName(), f);
+ }
+ ALL = Collections.unmodifiableMap(all);
+ }
+
+ public static Optional<LabelFunction> parse(@Nullable String str) {
+ return Optional.ofNullable(ALL.get(str));
+ }
+
+ private final String name;
+ private final boolean isBlock;
+ private final boolean isRequired;
+ private final boolean requiresMaxValue;
+
+ LabelFunction(String name) {
+ this(name, false, false, false);
+ }
+
+ LabelFunction(String name, boolean isBlock, boolean isRequired, boolean requiresMaxValue) {
+ this.name = name;
+ this.isBlock = isBlock;
+ this.isRequired = isRequired;
+ this.requiresMaxValue = requiresMaxValue;
+ }
+
+ /** The function name as defined in documentation and {@code project.config}. */
+ public String getFunctionName() {
+ return name;
+ }
+
+ /** Whether the label is a "block" label, meaning a minimum vote will prevent submission. */
+ public boolean isBlock() {
+ return isBlock;
+ }
+
+ /** Whether the label is a mandatory label, meaning absence of votes will prevent submission. */
+ public boolean isRequired() {
+ return isRequired;
+ }
+
+ /** Whether the label requires a vote with the maximum value to allow submission. */
+ public boolean isMaxValueRequired() {
+ return requiresMaxValue;
+ }
+
+ public Label check(LabelType labelType, Iterable<PatchSetApproval> approvals) {
+ Label submitRecordLabel = new Label();
+ submitRecordLabel.label = labelType.getName();
+
+ submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
+ if (isRequired) {
+ submitRecordLabel.status = SubmitRecord.Label.Status.NEED;
+ }
+
+ for (PatchSetApproval a : approvals) {
+ if (a.value() == 0) {
+ continue;
+ }
+
+ if (isBlock && labelType.isMaxNegative(a)) {
+ submitRecordLabel.appliedBy = a.accountId();
+ submitRecordLabel.status = SubmitRecord.Label.Status.REJECT;
+ return submitRecordLabel;
+ }
+
+ if (labelType.isMaxPositive(a) || !requiresMaxValue) {
+ submitRecordLabel.appliedBy = a.accountId();
+
+ submitRecordLabel.status = SubmitRecord.Label.Status.MAY;
+ if (isRequired) {
+ submitRecordLabel.status = SubmitRecord.Label.Status.OK;
+ }
+ }
+ }
+
+ return submitRecordLabel;
+ }
+}
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
new file mode 100644
index 0000000..a8d4da5
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -0,0 +1,298 @@
+// Copyright (C) 2008 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.entities;
+
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@AutoValue
+public abstract class LabelType {
+ public static final boolean DEF_ALLOW_POST_SUBMIT = true;
+ public static final boolean DEF_CAN_OVERRIDE = true;
+ public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CHANGE = true;
+ public static final boolean DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = false;
+ public static final boolean DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = false;
+ public static final boolean DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = false;
+ public static final boolean DEF_COPY_ANY_SCORE = false;
+ public static final boolean DEF_COPY_MAX_SCORE = false;
+ public static final boolean DEF_COPY_MIN_SCORE = false;
+ public static final ImmutableList<Short> DEF_COPY_VALUES = ImmutableList.of();
+ public static final boolean DEF_IGNORE_SELF_APPROVAL = false;
+
+ public static LabelType withDefaultValues(String name) {
+ checkName(name);
+ List<LabelValue> values = new ArrayList<>(2);
+ values.add(LabelValue.create((short) 0, "Rejected"));
+ values.add(LabelValue.create((short) 1, "Approved"));
+ return create(name, values);
+ }
+
+ public static String checkName(String name) throws IllegalArgumentException {
+ checkNameInternal(name);
+ if ("SUBM".equals(name)) {
+ throw new IllegalArgumentException("Reserved label name \"" + name + "\"");
+ }
+ return name;
+ }
+
+ public static String checkNameInternal(String name) throws IllegalArgumentException {
+ if (name == null || name.isEmpty()) {
+ throw new IllegalArgumentException("Empty label name");
+ }
+ for (int i = 0; i < name.length(); i++) {
+ char c = name.charAt(i);
+ if ((i == 0 && c == '-')
+ || !((c >= 'a' && c <= 'z')
+ || (c >= 'A' && c <= 'Z')
+ || (c >= '0' && c <= '9')
+ || c == '-')) {
+ throw new IllegalArgumentException("Illegal label name \"" + name + "\"");
+ }
+ }
+ return name;
+ }
+
+ private static ImmutableList<LabelValue> sortValues(List<LabelValue> values) {
+ if (values.isEmpty()) {
+ return ImmutableList.of();
+ }
+ values = values.stream().sorted(comparing(LabelValue::getValue)).collect(toList());
+ short v = values.get(0).getValue();
+ short i = 0;
+ ImmutableList.Builder<LabelValue> result = ImmutableList.builder();
+ // Fill in any missing values with empty text.
+ while (i < values.size()) {
+ while (v < values.get(i).getValue()) {
+ result.add(LabelValue.create(v++, ""));
+ }
+ v++;
+ result.add(values.get(i++));
+ }
+ return result.build();
+ }
+
+ public abstract String getName();
+
+ public abstract LabelFunction getFunction();
+
+ public abstract boolean isCopyAnyScore();
+
+ public abstract boolean isCopyMinScore();
+
+ public abstract boolean isCopyMaxScore();
+
+ public abstract boolean isCopyAllScoresOnMergeFirstParentUpdate();
+
+ public abstract boolean isCopyAllScoresOnTrivialRebase();
+
+ public abstract boolean isCopyAllScoresIfNoCodeChange();
+
+ public abstract boolean isCopyAllScoresIfNoChange();
+
+ public abstract ImmutableList<Short> getCopyValues();
+
+ public abstract boolean isAllowPostSubmit();
+
+ public abstract boolean isIgnoreSelfApproval();
+
+ public abstract short getDefaultValue();
+
+ public abstract ImmutableList<LabelValue> getValues();
+
+ public abstract short getMaxNegative();
+
+ public abstract short getMaxPositive();
+
+ public abstract boolean isCanOverride();
+
+ @Nullable
+ public abstract ImmutableList<String> getRefPatterns();
+
+ public abstract ImmutableMap<Short, LabelValue> getByValue();
+
+ public static LabelType create(String name, List<LabelValue> valueList) {
+ return LabelType.builder(name, valueList).build();
+ }
+
+ public static LabelType.Builder builder(String name, List<LabelValue> valueList) {
+ return (new AutoValue_LabelType.Builder())
+ .setName(name)
+ .setValues(valueList)
+ .setDefaultValue((short) 0)
+ .setFunction(LabelFunction.MAX_WITH_BLOCK)
+ .setMaxNegative(Short.MIN_VALUE)
+ .setMaxPositive(Short.MAX_VALUE)
+ .setCanOverride(DEF_CAN_OVERRIDE)
+ .setCopyAllScoresIfNoChange(DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
+ .setCopyAllScoresIfNoCodeChange(DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
+ .setCopyAllScoresOnTrivialRebase(DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
+ .setCopyAllScoresOnMergeFirstParentUpdate(DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
+ .setCopyAnyScore(DEF_COPY_ANY_SCORE)
+ .setCopyMaxScore(DEF_COPY_MAX_SCORE)
+ .setCopyMinScore(DEF_COPY_MIN_SCORE)
+ .setCopyValues(DEF_COPY_VALUES)
+ .setAllowPostSubmit(DEF_ALLOW_POST_SUBMIT)
+ .setIgnoreSelfApproval(DEF_IGNORE_SELF_APPROVAL);
+ }
+
+ public boolean matches(PatchSetApproval psa) {
+ return psa.labelId().get().equalsIgnoreCase(getName());
+ }
+
+ public LabelValue getMin() {
+ if (getValues().isEmpty()) {
+ return null;
+ }
+ return getValues().get(0);
+ }
+
+ public LabelValue getMax() {
+ if (getValues().isEmpty()) {
+ return null;
+ }
+ return getValues().get(getValues().size() - 1);
+ }
+
+ public boolean isMaxNegative(PatchSetApproval ca) {
+ return getMaxNegative() == ca.value();
+ }
+
+ public boolean isMaxPositive(PatchSetApproval ca) {
+ return getMaxPositive() == ca.value();
+ }
+
+ public LabelValue getValue(short value) {
+ return getByValue().get(value);
+ }
+
+ public LabelValue getValue(PatchSetApproval ca) {
+ return getByValue().get(ca.value());
+ }
+
+ public LabelId getLabelId() {
+ return LabelId.create(getName());
+ }
+
+ @Override
+ public final String toString() {
+ StringBuilder sb = new StringBuilder(getName()).append('[');
+ LabelValue min = getMin();
+ LabelValue max = getMax();
+ if (min != null && max != null) {
+ sb.append(
+ new PermissionRange(Permission.forLabel(getName()), min.getValue(), max.getValue())
+ .toString()
+ .trim());
+ } else if (min != null) {
+ sb.append(min.formatValue().trim());
+ } else if (max != null) {
+ sb.append(max.formatValue().trim());
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ public abstract Builder toBuilder();
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ public abstract Builder setName(String name);
+
+ public abstract Builder setFunction(LabelFunction function);
+
+ public abstract Builder setCanOverride(boolean canOverride);
+
+ public abstract Builder setAllowPostSubmit(boolean allowPostSubmit);
+
+ public abstract Builder setIgnoreSelfApproval(boolean ignoreSelfApproval);
+
+ public abstract Builder setRefPatterns(@Nullable List<String> refPatterns);
+
+ public abstract Builder setValues(List<LabelValue> values);
+
+ public abstract Builder setDefaultValue(short defaultValue);
+
+ public abstract Builder setCopyAnyScore(boolean copyAnyScore);
+
+ public abstract Builder setCopyMinScore(boolean copyMinScore);
+
+ public abstract Builder setCopyMaxScore(boolean copyMaxScore);
+
+ public abstract Builder setCopyAllScoresOnMergeFirstParentUpdate(
+ boolean copyAllScoresOnMergeFirstParentUpdate);
+
+ public abstract Builder setCopyAllScoresOnTrivialRebase(boolean copyAllScoresOnTrivialRebase);
+
+ public abstract Builder setCopyAllScoresIfNoCodeChange(boolean copyAllScoresIfNoCodeChange);
+
+ public abstract Builder setCopyAllScoresIfNoChange(boolean copyAllScoresIfNoChange);
+
+ public abstract Builder setCopyValues(Collection<Short> copyValues);
+
+ public abstract Builder setMaxNegative(short maxNegative);
+
+ public abstract Builder setMaxPositive(short maxPositive);
+
+ public abstract ImmutableList<LabelValue> getValues();
+
+ protected abstract String getName();
+
+ protected abstract ImmutableList<Short> getCopyValues();
+
+ protected abstract Builder setByValue(ImmutableMap<Short, LabelValue> byValue);
+
+ @Nullable
+ protected abstract ImmutableList<String> getRefPatterns();
+
+ protected abstract LabelType autoBuild();
+
+ public LabelType build() throws IllegalArgumentException {
+ setName(checkName(getName()));
+ if (getRefPatterns() == null || getRefPatterns().isEmpty()) {
+ // Empty to null
+ setRefPatterns(null);
+ }
+
+ List<LabelValue> valueList = sortValues(getValues());
+ setValues(valueList);
+ if (!valueList.isEmpty()) {
+ if (valueList.get(0).getValue() < 0) {
+ setMaxNegative(valueList.get(0).getValue());
+ }
+ if (valueList.get(valueList.size() - 1).getValue() > 0) {
+ setMaxPositive(valueList.get(valueList.size() - 1).getValue());
+ }
+ }
+
+ ImmutableMap.Builder<Short, LabelValue> byValue = ImmutableMap.builder();
+ for (LabelValue v : valueList) {
+ byValue.put(v.getValue(), v);
+ }
+ setByValue(byValue.build());
+
+ setCopyValues(ImmutableList.sortedCopyOf(getCopyValues()));
+
+ return autoBuild();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
new file mode 100644
index 0000000..1c38c59
--- /dev/null
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -0,0 +1,107 @@
+// Copyright (C) 2009 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.entities;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class LabelTypes {
+ protected List<LabelType> labelTypes;
+ private transient volatile Map<String, LabelType> byLabel;
+ private transient volatile Map<String, Integer> positions;
+
+ protected LabelTypes() {}
+
+ public LabelTypes(List<? extends LabelType> approvals) {
+ labelTypes = Collections.unmodifiableList(new ArrayList<>(approvals));
+ }
+
+ public List<LabelType> getLabelTypes() {
+ return labelTypes;
+ }
+
+ public LabelType byLabel(LabelId labelId) {
+ return byLabel().get(labelId.get().toLowerCase());
+ }
+
+ public LabelType byLabel(String labelName) {
+ return byLabel().get(labelName.toLowerCase());
+ }
+
+ private Map<String, LabelType> byLabel() {
+ if (byLabel == null) {
+ synchronized (this) {
+ if (byLabel == null) {
+ Map<String, LabelType> l = new HashMap<>();
+ if (labelTypes != null) {
+ for (LabelType t : labelTypes) {
+ l.put(t.getName().toLowerCase(), t);
+ }
+ }
+ byLabel = l;
+ }
+ }
+ }
+ return byLabel;
+ }
+
+ @Override
+ public String toString() {
+ return labelTypes.toString();
+ }
+
+ public Comparator<String> nameComparator() {
+ final Map<String, Integer> positions = positions();
+ return new Comparator<String>() {
+ @Override
+ public int compare(String left, String right) {
+ int lp = position(left);
+ int rp = position(right);
+ int cmp = lp - rp;
+ if (cmp == 0) {
+ cmp = left.compareTo(right);
+ }
+ return cmp;
+ }
+
+ private int position(String name) {
+ Integer p = positions.get(name);
+ return p != null ? p : positions.size();
+ }
+ };
+ }
+
+ private Map<String, Integer> positions() {
+ if (positions == null) {
+ synchronized (this) {
+ if (positions == null) {
+ Map<String, Integer> p = new HashMap<>();
+ if (labelTypes != null) {
+ int i = 0;
+ for (LabelType t : labelTypes) {
+ p.put(t.getName(), i++);
+ }
+ }
+ positions = p;
+ }
+ }
+ }
+ return positions;
+ }
+}
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 4a33bd7..5c8f7eb 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -21,7 +21,6 @@
import com.google.auto.value.AutoValue;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Streams;
import com.google.common.primitives.Ints;
import java.sql.Timestamp;
import java.util.List;
@@ -55,7 +54,7 @@
}
public static ImmutableList<String> splitGroups(String joinedGroups) {
- return Streams.stream(Splitter.on(',').split(joinedGroups)).collect(toImmutableList());
+ return Splitter.on(',').splitToStream(joinedGroups).collect(toImmutableList());
}
public static Id id(Change.Id changeId, int id) {
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
new file mode 100644
index 0000000..3f04fa5
--- /dev/null
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -0,0 +1,293 @@
+// Copyright (C) 2010 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.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.function.Consumer;
+
+/** A single permission within an {@link AccessSection} of a project. */
+@AutoValue
+public abstract class Permission implements Comparable<Permission> {
+ public static final String ABANDON = "abandon";
+ public static final String ADD_PATCH_SET = "addPatchSet";
+ public static final String CREATE = "create";
+ public static final String CREATE_SIGNED_TAG = "createSignedTag";
+ public static final String CREATE_TAG = "createTag";
+ public static final String DELETE = "delete";
+ public static final String DELETE_CHANGES = "deleteChanges";
+ public static final String DELETE_OWN_CHANGES = "deleteOwnChanges";
+ public static final String EDIT_ASSIGNEE = "editAssignee";
+ public static final String EDIT_HASHTAGS = "editHashtags";
+ public static final String EDIT_TOPIC_NAME = "editTopicName";
+ public static final String FORGE_AUTHOR = "forgeAuthor";
+ public static final String FORGE_COMMITTER = "forgeCommitter";
+ public static final String FORGE_SERVER = "forgeServerAsCommitter";
+ public static final String LABEL = "label-";
+ public static final String LABEL_AS = "labelAs-";
+ public static final String OWNER = "owner";
+ public static final String PUSH = "push";
+ public static final String PUSH_MERGE = "pushMerge";
+ public static final String READ = "read";
+ public static final String REBASE = "rebase";
+ public static final String REMOVE_REVIEWER = "removeReviewer";
+ public static final String REVERT = "revert";
+ public static final String SUBMIT = "submit";
+ public static final String SUBMIT_AS = "submitAs";
+ public static final String TOGGLE_WORK_IN_PROGRESS_STATE = "toggleWipState";
+ public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
+
+ public static final boolean DEF_EXCLUSIVE_GROUP = false;
+
+ private static final List<String> NAMES_LC;
+ private static final int LABEL_INDEX;
+ private static final int LABEL_AS_INDEX;
+
+ static {
+ NAMES_LC = new ArrayList<>();
+ NAMES_LC.add(ABANDON.toLowerCase());
+ NAMES_LC.add(ADD_PATCH_SET.toLowerCase());
+ NAMES_LC.add(CREATE.toLowerCase());
+ NAMES_LC.add(CREATE_SIGNED_TAG.toLowerCase());
+ NAMES_LC.add(CREATE_TAG.toLowerCase());
+ NAMES_LC.add(DELETE.toLowerCase());
+ NAMES_LC.add(DELETE_CHANGES.toLowerCase());
+ NAMES_LC.add(DELETE_OWN_CHANGES.toLowerCase());
+ NAMES_LC.add(EDIT_ASSIGNEE.toLowerCase());
+ NAMES_LC.add(EDIT_HASHTAGS.toLowerCase());
+ NAMES_LC.add(EDIT_TOPIC_NAME.toLowerCase());
+ NAMES_LC.add(FORGE_AUTHOR.toLowerCase());
+ NAMES_LC.add(FORGE_COMMITTER.toLowerCase());
+ NAMES_LC.add(FORGE_SERVER.toLowerCase());
+ NAMES_LC.add(LABEL.toLowerCase());
+ NAMES_LC.add(LABEL_AS.toLowerCase());
+ NAMES_LC.add(OWNER.toLowerCase());
+ NAMES_LC.add(PUSH.toLowerCase());
+ NAMES_LC.add(PUSH_MERGE.toLowerCase());
+ NAMES_LC.add(READ.toLowerCase());
+ NAMES_LC.add(REBASE.toLowerCase());
+ NAMES_LC.add(REMOVE_REVIEWER.toLowerCase());
+ NAMES_LC.add(REVERT.toLowerCase());
+ NAMES_LC.add(SUBMIT.toLowerCase());
+ NAMES_LC.add(SUBMIT_AS.toLowerCase());
+ NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase());
+ NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase());
+
+ LABEL_INDEX = NAMES_LC.indexOf(Permission.LABEL);
+ LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
+ }
+
+ /** @return true if the name is recognized as a permission name. */
+ public static boolean isPermission(String varName) {
+ return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
+ }
+
+ public static boolean hasRange(String varName) {
+ return isLabel(varName) || isLabelAs(varName);
+ }
+
+ /** @return true if the permission name is actually for a review label. */
+ public static boolean isLabel(String varName) {
+ return varName.startsWith(LABEL) && LABEL.length() < varName.length();
+ }
+
+ /** @return true if the permission is for impersonated review labels. */
+ public static boolean isLabelAs(String var) {
+ return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
+ }
+
+ /** @return permission name for the given review label. */
+ public static String forLabel(String labelName) {
+ return LABEL + labelName;
+ }
+
+ /** @return permission name to apply a label for another user. */
+ public static String forLabelAs(String labelName) {
+ return LABEL_AS + labelName;
+ }
+
+ public static String extractLabel(String varName) {
+ if (isLabel(varName)) {
+ return varName.substring(LABEL.length());
+ } else if (isLabelAs(varName)) {
+ return varName.substring(LABEL_AS.length());
+ }
+ return null;
+ }
+
+ public static boolean canBeOnAllProjects(String ref, String permissionName) {
+ if (AccessSection.ALL.equals(ref)) {
+ return !OWNER.equals(permissionName);
+ }
+ return true;
+ }
+
+ public abstract String getName();
+
+ protected abstract boolean isExclusiveGroup();
+
+ public abstract ImmutableList<PermissionRule> getRules();
+
+ public static Builder builder(String name) {
+ return new AutoValue_Permission.Builder()
+ .setName(name)
+ .setExclusiveGroup(DEF_EXCLUSIVE_GROUP)
+ .setRules(ImmutableList.of());
+ }
+
+ public static Permission create(String name) {
+ return builder(name).build();
+ }
+
+ public String getLabel() {
+ return extractLabel(getName());
+ }
+
+ public boolean getExclusiveGroup() {
+ // Only permit exclusive group behavior on non OWNER permissions,
+ // otherwise an owner might lose access to a delegated subspace.
+ //
+ return isExclusiveGroup() && !OWNER.equals(getName());
+ }
+
+ @Nullable
+ public PermissionRule getRule(GroupReference group) {
+ for (PermissionRule r : getRules()) {
+ if (sameGroup(r, group)) {
+ return r;
+ }
+ }
+
+ return null;
+ }
+
+ private static boolean sameGroup(PermissionRule rule, GroupReference group) {
+ if (group.getUUID() != null && rule.getGroup().getUUID() != null) {
+ return group.getUUID().equals(rule.getGroup().getUUID());
+ } else if (group.getName() != null && rule.getGroup().getName() != null) {
+ return group.getName().equals(rule.getGroup().getName());
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public final int compareTo(Permission b) {
+ int cmp = index(this) - index(b);
+ if (cmp == 0) {
+ cmp = getName().compareTo(b.getName());
+ }
+ return cmp;
+ }
+
+ private static int index(Permission a) {
+ if (isLabel(a.getName())) {
+ return LABEL_INDEX;
+ } else if (isLabelAs(a.getName())) {
+ return LABEL_AS_INDEX;
+ }
+
+ int index = NAMES_LC.indexOf(a.getName().toLowerCase());
+ return 0 <= index ? index : NAMES_LC.size();
+ }
+
+ @Override
+ public final String toString() {
+ StringBuilder bldr = new StringBuilder();
+ bldr.append(getName()).append(" ");
+ if (isExclusiveGroup()) {
+ bldr.append("[exclusive] ");
+ }
+ bldr.append("[");
+ Iterator<PermissionRule> it = getRules().iterator();
+ while (it.hasNext()) {
+ bldr.append(it.next());
+ if (it.hasNext()) {
+ bldr.append(", ");
+ }
+ }
+ bldr.append("]");
+ return bldr.toString();
+ }
+
+ protected abstract Builder autoToBuilder();
+
+ public Builder toBuilder() {
+ Builder b = autoToBuilder();
+ getRules().stream().map(PermissionRule::toBuilder).forEach(r -> b.add(r));
+ return b;
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ private final List<PermissionRule.Builder> rulesBuilders;
+
+ Builder() {
+ rulesBuilders = new ArrayList<>();
+ }
+
+ public abstract Builder setName(String value);
+
+ public abstract String getName();
+
+ public abstract Builder setExclusiveGroup(boolean value);
+
+ public Builder modifyRules(Consumer<List<PermissionRule.Builder>> modification) {
+ modification.accept(rulesBuilders);
+ return this;
+ }
+
+ public Builder add(PermissionRule.Builder rule) {
+ return modifyRules(r -> r.add(rule));
+ }
+
+ public Builder remove(PermissionRule rule) {
+ if (rule != null) {
+ return removeRule(rule.getGroup());
+ }
+ return this;
+ }
+
+ public Builder removeRule(GroupReference group) {
+ return modifyRules(rules -> rules.removeIf(rule -> sameGroup(rule.build(), group)));
+ }
+
+ public Builder clearRules() {
+ return modifyRules(r -> r.clear());
+ }
+
+ public Permission build() {
+ setRules(
+ rulesBuilders.stream().map(PermissionRule.Builder::build).collect(toImmutableList()));
+ return autoBuild();
+ }
+
+ public List<PermissionRule.Builder> getRulesBuilders() {
+ return rulesBuilders;
+ }
+
+ protected abstract ImmutableList<PermissionRule> getRules();
+
+ protected abstract Builder setRules(ImmutableList<PermissionRule> rules);
+
+ protected abstract Permission autoBuild();
+ }
+}
diff --git a/java/com/google/gerrit/entities/PermissionRange.java b/java/com/google/gerrit/entities/PermissionRange.java
new file mode 100644
index 0000000..fa9f4c2
--- /dev/null
+++ b/java/com/google/gerrit/entities/PermissionRange.java
@@ -0,0 +1,144 @@
+// Copyright (C) 2010 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.entities;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a closed interval [min, max] with a name. The special value [0, 0] is understood to be
+ * the empty range.
+ */
+public class PermissionRange implements Comparable<PermissionRange> {
+ public static class WithDefaults extends PermissionRange {
+ protected int defaultMin;
+ protected int defaultMax;
+
+ protected WithDefaults() {}
+
+ public WithDefaults(String name, int min, int max, int defMin, int defMax) {
+ super(name, min, max);
+ setDefaultRange(defMin, defMax);
+ }
+
+ public int getDefaultMin() {
+ return defaultMin;
+ }
+
+ public int getDefaultMax() {
+ return defaultMax;
+ }
+
+ public void setDefaultRange(int min, int max) {
+ defaultMin = min;
+ defaultMax = max;
+ }
+
+ /** @return all values between {@link #getMin()} and {@link #getMax()} */
+ public List<Integer> getValuesAsList() {
+ ArrayList<Integer> r = new ArrayList<>(getRangeSize());
+ for (int i = min; i <= max; i++) {
+ r.add(i);
+ }
+ return r;
+ }
+
+ /** @return number of values between {@link #getMin()} and {@link #getMax()} */
+ public int getRangeSize() {
+ return max - min;
+ }
+ }
+
+ protected String name;
+ protected int min;
+ protected int max;
+
+ protected PermissionRange() {}
+
+ public PermissionRange(String name, int min, int max) {
+ this.name = name;
+
+ if (min <= max) {
+ this.min = min;
+ this.max = max;
+ } else {
+ this.min = 0;
+ this.max = 0;
+ }
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public boolean isLabel() {
+ return Permission.isLabel(getName());
+ }
+
+ public String getLabel() {
+ return Permission.extractLabel(getName());
+ }
+
+ public int getMin() {
+ return min;
+ }
+
+ public int getMax() {
+ return max;
+ }
+
+ /** True if the value is within the range. */
+ public boolean contains(int value) {
+ return getMin() <= value && value <= getMax();
+ }
+
+ /** Normalize the value to fit within the bounds of the range. */
+ public int squash(int value) {
+ return Math.min(Math.max(getMin(), value), getMax());
+ }
+
+ /** True both {@link #getMin()} and {@link #getMax()} are 0. */
+ public boolean isEmpty() {
+ return getMin() == 0 && getMax() == 0;
+ }
+
+ @Override
+ public int compareTo(PermissionRange o) {
+ return getName().compareTo(o.getName());
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder r = new StringBuilder();
+ if (getMin() < 0 && getMax() == 0) {
+ r.append(getMin());
+ r.append(' ');
+ } else {
+ if (getMin() != getMax()) {
+ if (0 <= getMin()) {
+ r.append('+');
+ }
+ r.append(getMin());
+ r.append("..");
+ }
+ if (0 <= getMax()) {
+ r.append('+');
+ }
+ r.append(getMax());
+ r.append(' ');
+ }
+ return r.toString();
+ }
+}
diff --git a/java/com/google/gerrit/entities/PermissionRule.java b/java/com/google/gerrit/entities/PermissionRule.java
new file mode 100644
index 0000000..9a2d31e
--- /dev/null
+++ b/java/com/google/gerrit/entities/PermissionRule.java
@@ -0,0 +1,272 @@
+// Copyright (C) 2010 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.entities;
+
+import com.google.auto.value.AutoValue;
+
+@AutoValue
+public abstract class PermissionRule implements Comparable<PermissionRule> {
+ public static final boolean DEF_FORCE = false;
+
+ public enum Action {
+ ALLOW,
+ DENY,
+ BLOCK,
+
+ INTERACTIVE,
+ BATCH
+ }
+
+ public abstract Action getAction();
+
+ public abstract boolean getForce();
+
+ public abstract int getMin();
+
+ public abstract int getMax();
+
+ public abstract GroupReference getGroup();
+
+ public static PermissionRule.Builder builder(GroupReference group) {
+ return builder().setGroup(group);
+ }
+
+ public static PermissionRule create(GroupReference group) {
+ return builder().setGroup(group).build();
+ }
+
+ protected static Builder builder() {
+ return new AutoValue_PermissionRule.Builder()
+ .setMin(0)
+ .setMax(0)
+ .setAction(Action.ALLOW)
+ .setForce(DEF_FORCE);
+ }
+
+ static PermissionRule merge(PermissionRule src, PermissionRule dest) {
+ PermissionRule.Builder result = dest.toBuilder();
+ if (dest.getAction() != src.getAction()) {
+ if (dest.getAction() == Action.BLOCK || src.getAction() == Action.BLOCK) {
+ result.setAction(Action.BLOCK);
+
+ } else if (dest.getAction() == Action.DENY || src.getAction() == Action.DENY) {
+ result.setAction(Action.DENY);
+
+ } else if (dest.getAction() == Action.BATCH || src.getAction() == Action.BATCH) {
+ result.setAction(Action.BATCH);
+ }
+ }
+
+ result.setForce(dest.getForce() || src.getForce());
+ result.setRange(Math.min(dest.getMin(), src.getMin()), Math.max(dest.getMax(), src.getMax()));
+ return result.build();
+ }
+
+ public boolean isDeny() {
+ return getAction() == Action.DENY;
+ }
+
+ public boolean isBlock() {
+ return getAction() == Action.BLOCK;
+ }
+
+ @Override
+ public int compareTo(PermissionRule o) {
+ int cmp = action(this) - action(o);
+ if (cmp == 0) {
+ cmp = range(o) - range(this);
+ }
+ if (cmp == 0) {
+ cmp = group(this).compareTo(group(o));
+ }
+ return cmp;
+ }
+
+ private static int action(PermissionRule a) {
+ switch (a.getAction()) {
+ case DENY:
+ return 0;
+ case ALLOW:
+ case BATCH:
+ case BLOCK:
+ case INTERACTIVE:
+ default:
+ return 1 + a.getAction().ordinal();
+ }
+ }
+
+ private static int range(PermissionRule a) {
+ return Math.abs(a.getMin()) + Math.abs(a.getMax());
+ }
+
+ private static String group(PermissionRule a) {
+ return a.getGroup().getName() != null ? a.getGroup().getName() : "";
+ }
+
+ @Override
+ public final String toString() {
+ return asString(true);
+ }
+
+ public String asString(boolean canUseRange) {
+ StringBuilder r = new StringBuilder();
+
+ switch (getAction()) {
+ case ALLOW:
+ break;
+
+ case DENY:
+ r.append("deny ");
+ break;
+
+ case BLOCK:
+ r.append("block ");
+ break;
+
+ case INTERACTIVE:
+ r.append("interactive ");
+ break;
+
+ case BATCH:
+ r.append("batch ");
+ break;
+ }
+
+ if (getForce()) {
+ r.append("+force ");
+ }
+
+ if (canUseRange && (getMin() != 0 || getMax() != 0)) {
+ if (0 <= getMin()) {
+ r.append('+');
+ }
+ r.append(getMin());
+ r.append("..");
+ if (0 <= getMax()) {
+ r.append('+');
+ }
+ r.append(getMax());
+ r.append(' ');
+ }
+
+ r.append(getGroup().toConfigValue());
+
+ return r.toString();
+ }
+
+ public static PermissionRule fromString(String src, boolean mightUseRange) {
+ final String orig = src;
+ final PermissionRule.Builder rule = PermissionRule.builder();
+
+ src = src.trim();
+
+ if (src.startsWith("deny ")) {
+ rule.setAction(Action.DENY);
+ src = src.substring("deny ".length()).trim();
+
+ } else if (src.startsWith("block ")) {
+ rule.setAction(Action.BLOCK);
+ src = src.substring("block ".length()).trim();
+
+ } else if (src.startsWith("interactive ")) {
+ rule.setAction(Action.INTERACTIVE);
+ src = src.substring("interactive ".length()).trim();
+
+ } else if (src.startsWith("batch ")) {
+ rule.setAction(Action.BATCH);
+ src = src.substring("batch ".length()).trim();
+ }
+
+ if (src.startsWith("+force ")) {
+ rule.setForce(true);
+ src = src.substring("+force ".length()).trim();
+ }
+
+ if (mightUseRange && !GroupReference.isGroupReference(src)) {
+ int sp = src.indexOf(' ');
+ String range = src.substring(0, sp);
+
+ if (range.matches("^([+-]?\\d+)\\.\\.([+-]?\\d+)$")) {
+ int dotdot = range.indexOf("..");
+ int min = parseInt(range.substring(0, dotdot));
+ int max = parseInt(range.substring(dotdot + 2));
+ rule.setRange(min, max);
+ } else {
+ throw new IllegalArgumentException("Invalid range in rule: " + orig);
+ }
+
+ src = src.substring(sp + 1).trim();
+ }
+
+ String groupName = GroupReference.extractGroupName(src);
+ if (groupName != null) {
+ GroupReference group = GroupReference.create(groupName);
+ rule.setGroup(group);
+ } else {
+ throw new IllegalArgumentException("Rule must include group: " + orig);
+ }
+
+ return rule.build();
+ }
+
+ public boolean hasRange() {
+ return getMin() != 0 || getMax() != 0;
+ }
+
+ public static int parseInt(String value) {
+ if (value.startsWith("+")) {
+ value = value.substring(1);
+ }
+ return Integer.parseInt(value);
+ }
+
+ public abstract Builder toBuilder();
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ public Builder setDeny() {
+ return setAction(Action.DENY);
+ }
+
+ public Builder setBlock() {
+ return setAction(Action.BLOCK);
+ }
+
+ public Builder setRange(int newMin, int newMax) {
+ if (newMax < newMin) {
+ setMin(newMax);
+ setMax(newMin);
+ } else {
+ setMin(newMin);
+ setMax(newMax);
+ }
+ return this;
+ }
+
+ public abstract Builder setAction(Action action);
+
+ public abstract Builder setGroup(GroupReference groupReference);
+
+ public abstract Builder setForce(boolean newForce);
+
+ public abstract Builder setMin(int min);
+
+ public abstract Builder setMax(int max);
+
+ public abstract GroupReference getGroup();
+
+ public abstract PermissionRule build();
+ }
+}
diff --git a/java/com/google/gerrit/entities/RobotComment.java b/java/com/google/gerrit/entities/RobotComment.java
index 03ddad5..e2e4114 100644
--- a/java/com/google/gerrit/entities/RobotComment.java
+++ b/java/com/google/gerrit/entities/RobotComment.java
@@ -35,7 +35,7 @@
String serverId,
String robotId,
String robotRunId) {
- super(key, author, writtenOn, side, message, serverId, false);
+ super(key, author, writtenOn, side, message, serverId);
this.robotId = robotId;
this.robotRunId = robotRunId;
}
diff --git a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
index e70bf1e..f298782 100644
--- a/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
+++ b/java/com/google/gerrit/entities/StoredCommentLinkInfo.java
@@ -42,11 +42,11 @@
@Nullable
public abstract Boolean getEnabled();
- /** If set, {@link StoredCommentLinkInfo} has to be overriden to take any effect. */
+ /** If set, {@link StoredCommentLinkInfo} has to be overridden to take any effect. */
public abstract boolean getOverrideOnly();
/**
- * Creates an enabled {@link StoredCommentLinkInfo} that can be overriden but doesn't do anything
+ * Creates an enabled {@link StoredCommentLinkInfo} that can be overridden but doesn't do anything
* on its own.
*/
public static StoredCommentLinkInfo enabled(String name) {
@@ -54,7 +54,7 @@
}
/**
- * Creates a disabled {@link StoredCommentLinkInfo} that can be overriden but doesn't do anything
+ * Creates a disabled {@link StoredCommentLinkInfo} that can be overridden but doesn't do anything
* on it's own.
*/
public static StoredCommentLinkInfo disabled(String name) {
@@ -68,7 +68,7 @@
}
/** Creates and returns a new {@link StoredCommentLinkInfo} instance with the same values. */
- public static StoredCommentLinkInfo fromInfo(CommentLinkInfo src, boolean enabled) {
+ public static StoredCommentLinkInfo fromInfo(CommentLinkInfo src, Boolean enabled) {
return builder(src.name)
.setMatch(src.match)
.setLink(src.link)
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
new file mode 100644
index 0000000..67c6007
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -0,0 +1,184 @@
+// Copyright (C) 2011 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.entities;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+/** Describes the state and edits required to submit a change. */
+public class SubmitRecord {
+ public static boolean allRecordsOK(Collection<SubmitRecord> in) {
+ if (in == null || in.isEmpty()) {
+ // If the list is null or empty, it means that this Gerrit installation does not
+ // have any form of validation rules.
+ // Hence, the permission system should be used to determine if the change can be merged
+ // or not.
+ return true;
+ }
+
+ // The change can be submitted, unless at least one plugin prevents it.
+ return in.stream().map(SubmitRecord::status).allMatch(SubmitRecord.Status::allowsSubmission);
+ }
+
+ public enum Status {
+ // NOTE: These values are persisted in the index, so deleting or changing
+ // the name of any values requires a schema upgrade.
+
+ /** The change is ready for submission. */
+ OK,
+
+ /** Something is preventing this change from being submitted. */
+ NOT_READY,
+
+ /** The change has been closed. */
+ CLOSED,
+
+ /** The change was submitted bypassing submit rules. */
+ FORCED,
+
+ /**
+ * An internal server error occurred preventing computation.
+ *
+ * <p>Additional detail may be available in {@link SubmitRecord#errorMessage}.
+ */
+ RULE_ERROR;
+
+ private boolean allowsSubmission() {
+ return this == OK || this == FORCED;
+ }
+ }
+
+ public Status status;
+ public List<Label> labels;
+ public List<SubmitRequirement> requirements;
+ public String errorMessage;
+
+ public static class Label {
+ public enum Status {
+ // NOTE: These values are persisted in the index, so deleting or changing
+ // the name of any values requires a schema upgrade.
+
+ /**
+ * This label provides what is necessary for submission.
+ *
+ * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
+ * to the change.
+ */
+ OK,
+
+ /**
+ * This label prevents the change from being submitted.
+ *
+ * <p>If provided, {@link Label#appliedBy} describes the user account that applied this label
+ * to the change.
+ */
+ REJECT,
+
+ /** The label is required for submission, but has not been satisfied. */
+ NEED,
+
+ /**
+ * The label may be set, but it's neither necessary for submission nor does it block
+ * submission if set.
+ */
+ MAY,
+
+ /**
+ * The label is required for submission, but is impossible to complete. The likely cause is
+ * access has not been granted correctly by the project owner or site administrator.
+ */
+ IMPOSSIBLE
+ }
+
+ public String label;
+ public Status status;
+ public Account.Id appliedBy;
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(label).append(": ").append(status);
+ if (appliedBy != null) {
+ sb.append(" by ").append(appliedBy);
+ }
+ return sb.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o instanceof Label) {
+ Label l = (Label) o;
+ return Objects.equals(label, l.label)
+ && Objects.equals(status, l.status)
+ && Objects.equals(appliedBy, l.appliedBy);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(label, status, appliedBy);
+ }
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(status);
+ if (status == Status.RULE_ERROR && errorMessage != null) {
+ sb.append('(').append(errorMessage).append(')');
+ }
+ sb.append('[');
+ if (labels != null) {
+ String delimiter = "";
+ for (Label label : labels) {
+ sb.append(delimiter).append(label);
+ delimiter = ", ";
+ }
+ }
+ sb.append("],[");
+ if (requirements != null) {
+ String delimiter = "";
+ for (SubmitRequirement requirement : requirements) {
+ sb.append(delimiter).append(requirement);
+ delimiter = ", ";
+ }
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o instanceof SubmitRecord) {
+ SubmitRecord r = (SubmitRecord) o;
+ return Objects.equals(status, r.status)
+ && Objects.equals(labels, r.labels)
+ && Objects.equals(errorMessage, r.errorMessage)
+ && Objects.equals(requirements, r.requirements);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(status, labels, errorMessage, requirements);
+ }
+
+ private Status status() {
+ return status;
+ }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
new file mode 100644
index 0000000..f9301a4
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirement.java
@@ -0,0 +1,60 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+
+/** Describes a requirement to submit a change. */
+@AutoValue
+@AutoValue.CopyAnnotations
+public abstract class SubmitRequirement {
+ private static final CharMatcher TYPE_MATCHER =
+ CharMatcher.inRange('a', 'z')
+ .or(CharMatcher.inRange('A', 'Z'))
+ .or(CharMatcher.inRange('0', '9'))
+ .or(CharMatcher.anyOf("-_"));
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ public abstract Builder setType(String value);
+
+ public abstract Builder setFallbackText(String value);
+
+ public SubmitRequirement build() {
+ SubmitRequirement requirement = autoBuild();
+ checkState(
+ validateType(requirement.type()),
+ "SubmitRequirement's type contains non alphanumerical symbols.");
+ return requirement;
+ }
+
+ abstract SubmitRequirement autoBuild();
+ }
+
+ public abstract String fallbackText();
+
+ public abstract String type();
+
+ public static Builder builder() {
+ return new AutoValue_SubmitRequirement.Builder();
+ }
+
+ private static boolean validateType(String type) {
+ return TYPE_MATCHER.matchesAllOf(type);
+ }
+}
diff --git a/java/com/google/gerrit/entities/SubmitTypeRecord.java b/java/com/google/gerrit/entities/SubmitTypeRecord.java
new file mode 100644
index 0000000..492d637
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitTypeRecord.java
@@ -0,0 +1,77 @@
+// Copyright (C) 2012 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.entities;
+
+import com.google.gerrit.extensions.client.SubmitType;
+
+/** Describes the submit type for a change. */
+public class SubmitTypeRecord {
+ public enum Status {
+ /** The type was computed successfully */
+ OK,
+
+ /**
+ * An internal server error occurred preventing computation.
+ *
+ * <p>Additional detail may be available in {@link SubmitTypeRecord#errorMessage}
+ */
+ RULE_ERROR
+ }
+
+ public static SubmitTypeRecord OK(SubmitType type) {
+ return new SubmitTypeRecord(Status.OK, type, null);
+ }
+
+ public static SubmitTypeRecord error(String err) {
+ return new SubmitTypeRecord(SubmitTypeRecord.Status.RULE_ERROR, null, err);
+ }
+
+ /** Status enum value of the record. */
+ public final Status status;
+
+ /** Submit type of the record; never null if {@link #status} is {@code OK}. */
+ public final SubmitType type;
+
+ /** Submit type of the record; always null if {@link #status} is {@code OK}. */
+ public final String errorMessage;
+
+ private SubmitTypeRecord(Status status, SubmitType type, String errorMessage) {
+ if (type == SubmitType.INHERIT) {
+ throw new IllegalArgumentException("Cannot output submit type " + type);
+ }
+ this.status = status;
+ this.type = type;
+ this.errorMessage = errorMessage;
+ }
+
+ public boolean isOk() {
+ return status == Status.OK;
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append(status);
+ if (status == Status.RULE_ERROR && errorMessage != null) {
+ sb.append(" (").append(errorMessage).append(")");
+ }
+ if (type != null) {
+ sb.append('[');
+ sb.append(type.name());
+ sb.append(']');
+ }
+ return sb.toString();
+ }
+}
diff --git a/java/com/google/gerrit/entities/SubscribeSection.java b/java/com/google/gerrit/entities/SubscribeSection.java
new file mode 100644
index 0000000..b95517c
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubscribeSection.java
@@ -0,0 +1,162 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.RefSpec;
+
+/** Portion of a {@link Project} describing superproject subscription rules. */
+@AutoValue
+public abstract class SubscribeSection {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ public abstract Project.NameKey project();
+
+ protected abstract ImmutableList<RefSpec> matchingRefSpecs();
+
+ protected abstract ImmutableList<RefSpec> multiMatchRefSpecs();
+
+ public static Builder builder(Project.NameKey project) {
+ return new AutoValue_SubscribeSection.Builder().project(project);
+ }
+
+ public abstract Builder toBuilder();
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ public abstract Builder project(Project.NameKey project);
+
+ abstract ImmutableList.Builder<RefSpec> matchingRefSpecsBuilder();
+
+ abstract ImmutableList.Builder<RefSpec> multiMatchRefSpecsBuilder();
+
+ public Builder addMatchingRefSpec(String matchingRefSpec) {
+ matchingRefSpecsBuilder()
+ .add(new RefSpec(matchingRefSpec, RefSpec.WildcardMode.REQUIRE_MATCH));
+ return this;
+ }
+
+ public Builder addMultiMatchRefSpec(String multiMatchRefSpec) {
+ multiMatchRefSpecsBuilder()
+ .add(new RefSpec(multiMatchRefSpec, RefSpec.WildcardMode.ALLOW_MISMATCH));
+ return this;
+ }
+
+ public abstract SubscribeSection build();
+ }
+
+ /**
+ * Determines if the <code>branch</code> could trigger a superproject update as allowed via this
+ * subscribe section.
+ *
+ * @param branch the branch to check
+ * @return if the branch could trigger a superproject update
+ */
+ public boolean appliesTo(BranchNameKey branch) {
+ for (RefSpec r : matchingRefSpecs()) {
+ if (r.matchSource(branch.branch())) {
+ return true;
+ }
+ }
+ for (RefSpec r : multiMatchRefSpecs()) {
+ if (r.matchSource(branch.branch())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public Collection<String> matchingRefSpecsAsString() {
+ return matchingRefSpecs().stream().map(RefSpec::toString).collect(toImmutableList());
+ }
+
+ public Collection<String> multiMatchRefSpecsAsString() {
+ return multiMatchRefSpecs().stream().map(RefSpec::toString).collect(toImmutableList());
+ }
+
+ /** Evaluates what the destination branches for the subscription are. */
+ public ImmutableSet<BranchNameKey> getDestinationBranches(
+ BranchNameKey src, Collection<Ref> allRefsInRefsHeads) {
+ Set<BranchNameKey> ret = new HashSet<>();
+ logger.atFine().log("Inspecting SubscribeSection %s", this);
+ for (RefSpec r : matchingRefSpecs()) {
+ logger.atFine().log("Inspecting [matching] ref %s", r);
+ if (!r.matchSource(src.branch())) {
+ continue;
+ }
+ if (r.isWildcard()) {
+ // refs/heads/*[:refs/somewhere/*]
+ ret.add(BranchNameKey.create(project(), r.expandFromSource(src.branch()).getDestination()));
+ } else {
+ // e.g. refs/heads/master[:refs/heads/stable]
+ String dest = r.getDestination();
+ if (dest == null) {
+ dest = r.getSource();
+ }
+ ret.add(BranchNameKey.create(project(), dest));
+ }
+ }
+
+ for (RefSpec r : multiMatchRefSpecs()) {
+ logger.atFine().log("Inspecting [all] ref %s", r);
+ if (!r.matchSource(src.branch())) {
+ continue;
+ }
+ for (Ref ref : allRefsInRefsHeads) {
+ if (r.getDestination() != null && !r.matchDestination(ref.getName())) {
+ continue;
+ }
+ BranchNameKey b = BranchNameKey.create(project(), ref.getName());
+ if (!ret.contains(b)) {
+ ret.add(b);
+ }
+ }
+ }
+ logger.atFine().log("Returning possible branches: %s for project %s", ret, project());
+ return ImmutableSet.copyOf(ret);
+ }
+
+ @Override
+ public final String toString() {
+ StringBuilder ret = new StringBuilder();
+ ret.append("[SubscribeSection, project=");
+ ret.append(project());
+ if (!matchingRefSpecs().isEmpty()) {
+ ret.append(", matching=[");
+ for (RefSpec r : matchingRefSpecs()) {
+ ret.append(r.toString());
+ ret.append(", ");
+ }
+ }
+ if (!multiMatchRefSpecs().isEmpty()) {
+ ret.append(", all=[");
+ for (RefSpec r : multiMatchRefSpecs()) {
+ ret.append(r.toString());
+ ret.append(", ");
+ }
+ }
+ ret.append("]");
+ return ret.toString();
+ }
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java b/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java
index f0d42c5..4665b11 100644
--- a/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/AttentionSetInput.java
@@ -16,6 +16,7 @@
import com.google.gerrit.extensions.common.AttentionSetInfo;
import com.google.gerrit.extensions.restapi.DefaultInput;
+import java.util.Map;
/**
* Input at API level to add a user to the attention set.
@@ -25,6 +26,8 @@
public class AttentionSetInput {
public String user;
@DefaultInput public String reason;
+ public NotifyHandling notify;
+ public Map<RecipientType, NotifyInfo> notifyDetails;
public AttentionSetInput(String user, String reason) {
this.user = user;
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index e8b58f9..3364fc1 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -326,8 +326,12 @@
* @return comments in a map keyed by path; comments have the {@code revision} field set to
* indicate their patch set.
* @throws RestApiException
+ * @deprecated Callers should use {@link #commentsRequest()} instead
*/
- Map<String, List<CommentInfo>> comments() throws RestApiException;
+ @Deprecated
+ default Map<String, List<CommentInfo>> comments() throws RestApiException {
+ return commentsRequest().get();
+ }
/**
* Get all published comments on a change as a list.
@@ -335,8 +339,20 @@
* @return comments as a list; comments have the {@code revision} field set to indicate their
* patch set.
* @throws RestApiException
+ * @deprecated Callers should use {@link #commentsRequest()} instead
*/
- List<CommentInfo> commentsAsList() throws RestApiException;
+ @Deprecated
+ default List<CommentInfo> commentsAsList() throws RestApiException {
+ return commentsRequest().getAsList();
+ }
+
+ /**
+ * Get a {@link CommentsRequest} entity that can be used to retrieve published comments.
+ *
+ * @return A {@link CommentsRequest} entity that can be used to retrieve the comments using the
+ * {@link CommentsRequest#get()} or {@link CommentsRequest#getAsList()}.
+ */
+ CommentsRequest commentsRequest() throws RestApiException;
/**
* Get all robot comments on a change.
@@ -395,6 +411,41 @@
*/
ChangeMessageApi message(String id) throws RestApiException;
+ abstract class CommentsRequest {
+ private boolean enableContext;
+
+ /**
+ * Get all published comments on a change.
+ *
+ * @return comments in a map keyed by path; comments have the {@code revision} field set to
+ * indicate their patch set.
+ * @throws RestApiException
+ */
+ public abstract Map<String, List<CommentInfo>> get() throws RestApiException;
+
+ /**
+ * Get all published comments on a change as a list.
+ *
+ * @return comments as a list; comments have the {@code revision} field set to indicate their
+ * patch set.
+ */
+ public abstract List<CommentInfo> getAsList() throws RestApiException;
+
+ public CommentsRequest withContext(boolean enableContext) {
+ this.enableContext = enableContext;
+ return this;
+ }
+
+ public CommentsRequest withContext() {
+ this.enableContext = true;
+ return this;
+ }
+
+ public boolean getContext() {
+ return enableContext;
+ }
+ }
+
abstract class SuggestedReviewersRequest {
private String query;
private int limit;
@@ -603,16 +654,23 @@
}
@Override
+ @Deprecated
public Map<String, List<CommentInfo>> comments() throws RestApiException {
throw new NotImplementedException();
}
@Override
+ @Deprecated
public List<CommentInfo> commentsAsList() throws RestApiException {
throw new NotImplementedException();
}
@Override
+ public CommentsRequest commentsRequest() throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
throw new NotImplementedException();
}
diff --git a/java/com/google/gerrit/extensions/api/changes/CommentInput.java b/java/com/google/gerrit/extensions/api/changes/CommentInput.java
new file mode 100644
index 0000000..5970541
--- /dev/null
+++ b/java/com/google/gerrit/extensions/api/changes/CommentInput.java
@@ -0,0 +1,20 @@
+// 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.extensions.api.changes;
+
+/** Input to the {@link ChangeApi#comments}. */
+public class CommentInput {
+ public boolean enableContext;
+}
diff --git a/java/com/google/gerrit/extensions/api/changes/DraftInput.java b/java/com/google/gerrit/extensions/api/changes/DraftInput.java
index b3c2786..74f626f 100644
--- a/java/com/google/gerrit/extensions/api/changes/DraftInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/DraftInput.java
@@ -19,18 +19,19 @@
public class DraftInput extends Comment {
public String tag;
+ public Boolean unresolved;
@Override
public boolean equals(Object o) {
if (super.equals(o)) {
DraftInput di = (DraftInput) o;
- return Objects.equals(tag, di.tag);
+ return Objects.equals(tag, di.tag) && Objects.equals(unresolved, di.unresolved);
}
return false;
}
@Override
public int hashCode() {
- return Objects.hash(super.hashCode(), tag);
+ return Objects.hash(super.hashCode(), tag, unresolved);
}
}
diff --git a/java/com/google/gerrit/extensions/api/changes/FileApi.java b/java/com/google/gerrit/extensions/api/changes/FileApi.java
index 8d9b2d5..26f9452 100644
--- a/java/com/google/gerrit/extensions/api/changes/FileApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileApi.java
@@ -52,7 +52,6 @@
abstract class DiffRequest {
private String base;
- private Integer context;
private Boolean intraline;
private Whitespace whitespace;
private OptionalInt parent = OptionalInt.empty();
@@ -64,11 +63,6 @@
return this;
}
- public DiffRequest withContext(int context) {
- this.context = context;
- return this;
- }
-
public DiffRequest withIntraline(boolean intraline) {
this.intraline = intraline;
return this;
@@ -88,10 +82,6 @@
return base;
}
- public Integer getContext() {
- return context;
- }
-
public Boolean getIntraline() {
return intraline;
}
diff --git a/java/com/google/gerrit/extensions/api/changes/RevertInput.java b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
index c4272e4..148d24a 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevertInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevertInput.java
@@ -17,6 +17,10 @@
import com.google.gerrit.extensions.restapi.DefaultInput;
import java.util.Map;
+/**
+ * Input passed to {@code POST /changes/[change-id]/revert} and {@code POST
+ * /changes/[change-id]/revert_submission}
+ */
public class RevertInput {
@DefaultInput public String message;
@@ -26,4 +30,10 @@
public Map<RecipientType, NotifyInfo> notifyDetails;
public String topic;
+
+ /**
+ * Mark the change as work-in-progress. This will also override the {@link #notify} value to
+ * {@link NotifyHandling#OWNER}
+ */
+ public boolean workInProgress;
}
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index 1f41c07..7ecc0a6 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -87,7 +87,7 @@
* occur. E.g, adding/removing reviewers, marking a change ready for review or work in progress,
* and replying on changes.
*/
- public boolean ignoreDefaultAttentionSetRules;
+ public boolean ignoreAutomaticAttentionSetRules;
public enum DraftHandling {
/** Leave pending drafts alone. */
@@ -100,9 +100,11 @@
PUBLISH_ALL_REVISIONS
}
- public static class CommentInput extends Comment {}
+ public static class CommentInput extends Comment {
+ public Boolean unresolved;
+ }
- public static class RobotCommentInput extends CommentInput {
+ public static class RobotCommentInput extends Comment {
public String robotId;
public String robotRunId;
public String url;
@@ -175,8 +177,8 @@
return this;
}
- public ReviewInput blockDefaultAttentionSetRules() {
- ignoreDefaultAttentionSetRules = true;
+ public ReviewInput blockAutomaticAttentionSetRules() {
+ ignoreAutomaticAttentionSetRules = true;
return this;
}
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index ff9fb3c..b419c2f 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -106,6 +106,10 @@
List<RobotCommentInfo> robotCommentsAsList() throws RestApiException;
+ Map<String, List<CommentInfo>> portedComments() throws RestApiException;
+
+ Map<String, List<CommentInfo>> portedDrafts() throws RestApiException;
+
/**
* Applies the indicated fix by creating a new change edit or integrating the fix with the
* existing change edit. If no change edit exists before this call, the fix must refer to the
@@ -294,6 +298,16 @@
}
@Override
+ public Map<String, List<CommentInfo>> portedComments() throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
+ public Map<String, List<CommentInfo>> portedDrafts() throws RestApiException {
+ throw new NotImplementedException();
+ }
+
+ @Override
public EditInfo applyFix(String fixId) throws RestApiException {
throw new NotImplementedException();
}
diff --git a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
index fab2ec4..423ac49 100644
--- a/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/AccessCheckInfo.java
@@ -14,10 +14,15 @@
package com.google.gerrit.extensions.api.config;
+import java.util.List;
+
public class AccessCheckInfo {
public String message;
// HTTP status code
public int status;
+ /** Debug logs that may help to understand why a permission is denied or allowed. */
+ public List<String> debugLogs;
+
// for future extension, we may add inputs / results for bulk checks.
}
diff --git a/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
index 2c166d0..e582f1b 100644
--- a/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
@@ -48,6 +48,7 @@
public static class ConsistencyProblemInfo {
public enum Status {
+ FATAL,
ERROR,
WARNING,
}
diff --git a/java/com/google/gerrit/extensions/client/Comment.java b/java/com/google/gerrit/extensions/client/Comment.java
index faa9f69..634992e 100644
--- a/java/com/google/gerrit/extensions/client/Comment.java
+++ b/java/com/google/gerrit/extensions/client/Comment.java
@@ -37,7 +37,6 @@
public String inReplyTo;
public Timestamp updated;
public String message;
- public Boolean unresolved;
/**
* Hex commit SHA1 (as 40 characters hex string) of the commit of the patchset to which this
@@ -128,7 +127,6 @@
&& Objects.equals(inReplyTo, c.inReplyTo)
&& Objects.equals(updated, c.updated)
&& Objects.equals(message, c.message)
- && Objects.equals(unresolved, c.unresolved)
&& Objects.equals(commitId, c.commitId);
}
return false;
diff --git a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
index ed01a4d..6d52a93 100644
--- a/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
@@ -28,12 +28,6 @@
/** Default line length. */
public static final int DEFAULT_LINE_LENGTH = 100;
- /** Context setting to display the entire file. */
- public static final short WHOLE_FILE_CONTEXT = -1;
-
- /** Typical valid choices for the default context setting. */
- public static final short[] CONTEXT_CHOICES = {3, 10, 25, 50, 75, 100, WHOLE_FILE_CONTEXT};
-
public enum Whitespace {
IGNORE_NONE,
IGNORE_TRAILING,
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index f2b40b6..681d0bd 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -77,6 +77,7 @@
public enum EmailStrategy {
ENABLED,
CC_ON_OWN_COMMENTS,
+ ATTENTION_SET_ONLY,
DISABLED
}
diff --git a/java/com/google/gerrit/extensions/common/AccountInfo.java b/java/com/google/gerrit/extensions/common/AccountInfo.java
index 2a3d260..60ba18d 100644
--- a/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -27,6 +27,12 @@
* are defined in {@link AccountDetailInfo}.
*/
public class AccountInfo {
+ /** Tags are additional properties of an account. */
+ public enum Tag {
+ /** Tag indicating that this account is a service user. */
+ SERVICE_USER
+ }
+
/** The numeric ID of the account. */
public Integer _accountId;
@@ -67,6 +73,9 @@
/** Whether the account is inactive. */
public Boolean inactive;
+ /** Tags, such as whether this account is a service user. */
+ public List<Tag> tags;
+
public AccountInfo(Integer id) {
this._accountId = id;
}
@@ -89,7 +98,8 @@
&& Objects.equals(username, accountInfo.username)
&& Objects.equals(avatars, accountInfo.avatars)
&& Objects.equals(_moreAccounts, accountInfo._moreAccounts)
- && Objects.equals(status, accountInfo.status);
+ && Objects.equals(status, accountInfo.status)
+ && Objects.equals(tags, accountInfo.tags);
}
return false;
}
@@ -102,6 +112,7 @@
.add("displayname", displayName)
.add("email", email)
.add("username", username)
+ .add("tags", tags)
.toString();
}
@@ -116,7 +127,8 @@
username,
avatars,
_moreAccounts,
- status);
+ status,
+ tags);
}
protected AccountInfo() {}
diff --git a/java/com/google/gerrit/extensions/common/CommentInfo.java b/java/com/google/gerrit/extensions/common/CommentInfo.java
index 19e002a..fcce2b3 100644
--- a/java/com/google/gerrit/extensions/common/CommentInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommentInfo.java
@@ -15,24 +15,34 @@
package com.google.gerrit.extensions.common;
import com.google.gerrit.extensions.client.Comment;
+import java.util.List;
import java.util.Objects;
public class CommentInfo extends Comment {
public AccountInfo author;
public String tag;
public String changeMessageId;
+ public Boolean unresolved;
+
+ /**
+ * A list of {@link ContextLineInfo}, that is, a list of pairs of {line_num, line_text} of the
+ * actual source file content surrounding and including the lines where the comment was written.
+ */
+ public List<ContextLineInfo> contextLines;
@Override
public boolean equals(Object o) {
if (super.equals(o)) {
CommentInfo ci = (CommentInfo) o;
- return Objects.equals(author, ci.author) && Objects.equals(tag, ci.tag);
+ return Objects.equals(author, ci.author)
+ && Objects.equals(tag, ci.tag)
+ && Objects.equals(unresolved, ci.unresolved);
}
return false;
}
@Override
public int hashCode() {
- return Objects.hash(super.hashCode(), author, tag);
+ return Objects.hash(super.hashCode(), author, tag, unresolved);
}
}
diff --git a/java/com/google/gerrit/extensions/common/ContextLineInfo.java b/java/com/google/gerrit/extensions/common/ContextLineInfo.java
new file mode 100644
index 0000000..3062e85
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/ContextLineInfo.java
@@ -0,0 +1,47 @@
+// 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.extensions.common;
+
+import java.util.Objects;
+
+/**
+ * An entity class representing 1 line of context {line number, line text} of the source file where
+ * a comment was written.
+ */
+public class ContextLineInfo {
+ public int lineNumber;
+ public String contextLine;
+
+ public ContextLineInfo() {}
+
+ public ContextLineInfo(int lineNumber, String contextLine) {
+ this.lineNumber = lineNumber;
+ this.contextLine = contextLine;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o instanceof ContextLineInfo) {
+ ContextLineInfo l = (ContextLineInfo) o;
+ return lineNumber == l.lineNumber && contextLine.equals(l.contextLine);
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(lineNumber, contextLine);
+ }
+}
diff --git a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
index 53f5e07..734d7e9 100644
--- a/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
+++ b/java/com/google/gerrit/extensions/common/MergePatchSetInput.java
@@ -14,9 +14,12 @@
package com.google.gerrit.extensions.common;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+
public class MergePatchSetInput {
public String subject;
public boolean inheritParent;
public String baseChange;
public MergeInput merge;
+ public AccountInput author;
}
diff --git a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
index e6fef0f..69bfa2c 100644
--- a/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
+++ b/java/com/google/gerrit/extensions/common/PluginDefinedInfo.java
@@ -16,4 +16,5 @@
public class PluginDefinedInfo {
public String name;
+ public String message;
}
diff --git a/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java b/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
index 4170797..deb03b0 100644
--- a/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
+++ b/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
@@ -19,7 +19,7 @@
import java.util.Objects;
public class TestSubmitRuleInfo {
- /** @see com.google.gerrit.common.data.SubmitRecord.Status */
+ /** @see com.google.gerrit.entities.SubmitRecord.Status */
public String status;
public String errorMessage;
diff --git a/java/com/google/gerrit/extensions/common/testing/AccountInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/AccountInfoSubject.java
new file mode 100644
index 0000000..8fa6617
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/AccountInfoSubject.java
@@ -0,0 +1,50 @@
+// 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.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.extensions.common.AccountInfo;
+
+/** A Truth subject for {@link AccountInfo} instances. */
+public class AccountInfoSubject extends Subject {
+
+ private final AccountInfo accountInfo;
+
+ public static AccountInfoSubject assertThat(AccountInfo accountInfo) {
+ return assertAbout(accounts()).that(accountInfo);
+ }
+
+ public static Factory<AccountInfoSubject, AccountInfo> accounts() {
+ return AccountInfoSubject::new;
+ }
+
+ private AccountInfoSubject(FailureMetadata metadata, AccountInfo accountInfo) {
+ super(metadata, accountInfo);
+ this.accountInfo = accountInfo;
+ }
+
+ public IntegerSubject id() {
+ return check("id").that(accountInfo()._accountId);
+ }
+
+ private AccountInfo accountInfo() {
+ isNotNull();
+ return accountInfo;
+ }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/CommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommentInfoSubject.java
new file mode 100644
index 0000000..c34e439
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/testing/CommentInfoSubject.java
@@ -0,0 +1,119 @@
+// 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.extensions.common.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+import static com.google.gerrit.extensions.common.testing.AccountInfoSubject.accounts;
+import static com.google.gerrit.extensions.common.testing.RangeSubject.ranges;
+
+import com.google.common.truth.BooleanSubject;
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.truth.ListSubject;
+import java.sql.Timestamp;
+import java.util.List;
+
+public class CommentInfoSubject extends Subject {
+
+ public static ListSubject<CommentInfoSubject, CommentInfo> assertThatList(
+ List<CommentInfo> commentInfos) {
+ return ListSubject.assertThat(commentInfos, comments());
+ }
+
+ public static CommentInfoSubject assertThat(CommentInfo commentInfo) {
+ return assertAbout(comments()).that(commentInfo);
+ }
+
+ private static Factory<CommentInfoSubject, CommentInfo> comments() {
+ return CommentInfoSubject::new;
+ }
+
+ private final CommentInfo commentInfo;
+
+ private CommentInfoSubject(FailureMetadata failureMetadata, CommentInfo commentInfo) {
+ super(failureMetadata, commentInfo);
+ this.commentInfo = commentInfo;
+ }
+
+ public StringSubject uuid() {
+ return check("id").that(commentInfo().id);
+ }
+
+ public IntegerSubject patchSet() {
+ return check("patchSet").that(commentInfo().patchSet);
+ }
+
+ public StringSubject path() {
+ return check("path").that(commentInfo().path);
+ }
+
+ public IntegerSubject line() {
+ return check("line").that(commentInfo().line);
+ }
+
+ public RangeSubject range() {
+ return check("range").about(ranges()).that(commentInfo().range);
+ }
+
+ public StringSubject message() {
+ return check("message").that(commentInfo().message);
+ }
+
+ public ComparableSubject<Side> side() {
+ return check("side").that(commentInfo().side);
+ }
+
+ public IntegerSubject parent() {
+ return check("parent").that(commentInfo().parent);
+ }
+
+ public BooleanSubject unresolved() {
+ return check("unresolved").that(commentInfo().unresolved);
+ }
+
+ public StringSubject inReplyTo() {
+ return check("inReplyTo").that(commentInfo().inReplyTo);
+ }
+
+ public StringSubject commitId() {
+ return check("commitId").that(commentInfo().commitId);
+ }
+
+ public AccountInfoSubject author() {
+ return check("author").about(accounts()).that(commentInfo().author);
+ }
+
+ public StringSubject tag() {
+ return check("tag").that(commentInfo().tag);
+ }
+
+ public ComparableSubject<Timestamp> updated() {
+ return check("updated").that(commentInfo().updated);
+ }
+
+ public StringSubject changeMessageId() {
+ return check("changeMessageId").that(commentInfo().changeMessageId);
+ }
+
+ private CommentInfo commentInfo() {
+ isNotNull();
+ return commentInfo;
+ }
+}
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index d6fcb37..d344e18 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -18,11 +18,13 @@
import static com.google.gerrit.extensions.common.testing.GitPersonSubject.gitPersons;
import static com.google.gerrit.truth.ListSubject.elements;
+import com.google.common.truth.Correspondence;
import com.google.common.truth.FailureMetadata;
import com.google.common.truth.StringSubject;
import com.google.common.truth.Subject;
import com.google.gerrit.extensions.common.CommitInfo;
import com.google.gerrit.truth.ListSubject;
+import com.google.gerrit.truth.NullAwareCorrespondence;
public class CommitInfoSubject extends Subject {
@@ -65,4 +67,8 @@
isNotNull();
return check("message").that(commitInfo.message);
}
+
+ public static Correspondence<CommitInfo, String> hasCommit() {
+ return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasCommit");
+ }
}
diff --git a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
index dd226ed..5176145 100644
--- a/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/RobotCommentInfoSubject.java
@@ -18,6 +18,7 @@
import static com.google.gerrit.truth.ListSubject.elements;
import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.MapSubject;
import com.google.common.truth.StringSubject;
import com.google.common.truth.Subject;
import com.google.gerrit.extensions.common.FixSuggestionInfo;
@@ -59,6 +60,26 @@
return check("path").that(robotCommentInfo.path);
}
+ public StringSubject robotId() {
+ isNotNull();
+ return check("robotId").that(robotCommentInfo.robotId);
+ }
+
+ public StringSubject robotRunId() {
+ isNotNull();
+ return check("robotRunId").that(robotCommentInfo.robotRunId);
+ }
+
+ public StringSubject url() {
+ isNotNull();
+ return check("url").that(robotCommentInfo.url);
+ }
+
+ public MapSubject properties() {
+ isNotNull();
+ return check("property").that(robotCommentInfo.properties);
+ }
+
public FixSuggestionInfoSubject onlyFixSuggestion() {
return fixSuggestions().onlyElement();
}
diff --git a/java/com/google/gerrit/extensions/restapi/testing/AttentionSetUpdateSubject.java b/java/com/google/gerrit/extensions/restapi/testing/AttentionSetUpdateSubject.java
new file mode 100644
index 0000000..4033c3e
--- /dev/null
+++ b/java/com/google/gerrit/extensions/restapi/testing/AttentionSetUpdateSubject.java
@@ -0,0 +1,83 @@
+// 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.extensions.restapi.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
+
+/** {@link Subject} for doing assertions on {@link AttentionSetUpdate}s. */
+public class AttentionSetUpdateSubject extends Subject {
+
+ /**
+ * Starts fluent chain to do assertions on a {@link AttentionSetUpdate}.
+ *
+ * @param attentionSetUpdate the {@link AttentionSetUpdate} on which assertions should be done
+ * @return the created {@link AttentionSetUpdateSubject}
+ */
+ public static AttentionSetUpdateSubject assertThat(AttentionSetUpdate attentionSetUpdate) {
+ return assertAbout(attentionSetUpdates()).that(attentionSetUpdate);
+ }
+
+ private static Factory<AttentionSetUpdateSubject, AttentionSetUpdate> attentionSetUpdates() {
+ return AttentionSetUpdateSubject::new;
+ }
+
+ private final AttentionSetUpdate attentionSetUpdate;
+
+ private AttentionSetUpdateSubject(
+ FailureMetadata metadata, AttentionSetUpdate attentionSetUpdate) {
+ super(metadata, attentionSetUpdate);
+ this.attentionSetUpdate = attentionSetUpdate;
+ }
+
+ /**
+ * Returns a {@link ComparableSubject} for the account ID of attention set update.
+ *
+ * @return {@link ComparableSubject} for the account ID of attention set update
+ */
+ public ComparableSubject<Account.Id> hasAccountIdThat() {
+ return check("account()").that(attentionSetUpdate().account());
+ }
+
+ /**
+ * Returns a {@link StringSubject} for the reason of attention set update.
+ *
+ * @return {@link StringSubject} for the reason of attention set update
+ */
+ public StringSubject hasReasonThat() {
+ return check("reason()").that(attentionSetUpdate().reason());
+ }
+
+ /**
+ * Returns a {@link ComparableSubject} for the {@link
+ * com.google.gerrit.entities.AttentionSetUpdate.Operation} of attention set update.
+ *
+ * @return {@link ComparableSubject} for the {@link
+ * com.google.gerrit.entities.AttentionSetUpdate.Operation} of attention set update.
+ */
+ public ComparableSubject<AttentionSetUpdate.Operation> hasOperationThat() {
+ return check("operation()").that(attentionSetUpdate().operation());
+ }
+
+ private AttentionSetUpdate attentionSetUpdate() {
+ isNotNull();
+ return attentionSetUpdate;
+ }
+}
diff --git a/java/com/google/gerrit/extensions/restapi/testing/BUILD b/java/com/google/gerrit/extensions/restapi/testing/BUILD
index 4c44d2a..da11ce8 100644
--- a/java/com/google/gerrit/extensions/restapi/testing/BUILD
+++ b/java/com/google/gerrit/extensions/restapi/testing/BUILD
@@ -6,6 +6,7 @@
srcs = glob(["**/*.java"]),
visibility = ["//visibility:public"],
deps = [
+ "//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/truth",
"//lib/truth",
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 5c4830c..a3a67e5 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -28,6 +28,7 @@
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PropertyMap;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AuthResult;
import com.google.gerrit.server.account.externalids.ExternalId;
@@ -161,15 +162,11 @@
}
@Override
- public ExternalId.Key getLastLoginExternalId() {
- return val != null ? val.getExternalId() : null;
- }
-
- @Override
public CurrentUser getUser() {
if (user == null) {
if (isSignedIn()) {
- user = identified.create(val.getAccountId());
+
+ user = identified.create(val.getAccountId(), getUserProperties(val));
} else {
user = anonymousProvider.get();
}
@@ -177,6 +174,15 @@
return user;
}
+ private static PropertyMap getUserProperties(@Nullable WebSessionManager.Val val) {
+ if (val == null || val.getExternalId() == null) {
+ return PropertyMap.EMPTY;
+ }
+ return PropertyMap.builder()
+ .put(CurrentUser.LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY, val.getExternalId())
+ .build();
+ }
+
@Override
public void login(AuthResult res, boolean rememberMe) {
Account.Id id = res.getAccountId();
@@ -194,7 +200,7 @@
key = manager.createKey(id);
val = manager.createVal(key, id, rememberMe, identity, null, null);
saveCookie();
- user = identified.create(val.getAccountId());
+ user = identified.create(val.getAccountId(), getUserProperties(val));
}
/** Set the user account for this current request only. */
@@ -202,7 +208,7 @@
public void setUserAccountId(Account.Id id) {
key = new Key("id:" + id);
val = new Val(id, 0, false, null, 0, null, null);
- user = identified.runAs(id, user);
+ user = identified.runAs(id, user, PropertyMap.EMPTY);
}
@Override
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index e8b54fe..daf30ff 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -19,7 +19,6 @@
import com.google.gerrit.server.AccessPath;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.externalids.ExternalId;
public interface WebSession {
boolean isSignedIn();
@@ -29,8 +28,6 @@
boolean isValidXGerritAuth(String keyIn);
- ExternalId.Key getLastLoginExternalId();
-
CurrentUser getUser();
void login(AuthResult res, boolean rememberMe);
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index 509a9f1..e20c9b9 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -35,6 +35,7 @@
import java.io.IOException;
import java.io.OutputStream;
import java.util.Locale;
+import java.util.Optional;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
@@ -124,8 +125,8 @@
}
private static boolean correctUser(String user, WebSession session) {
- ExternalId.Key id = session.getLastLoginExternalId();
- return id != null && id.equals(ExternalId.Key.create(SCHEME_GERRIT, user));
+ Optional<ExternalId.Key> id = session.getUser().getLastLoginExternalIdKey();
+ return id.map(i -> i.equals(ExternalId.Key.create(SCHEME_GERRIT, user))).orElse(false);
}
String getRemoteUser(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index c5f97a3..193c4f1 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -97,6 +97,7 @@
import com.google.gerrit.server.ssh.SshAddressesModule;
import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
import com.google.gerrit.server.submit.SubscriptionGraph;
+import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener;
import com.google.gerrit.sshd.SshHostKeyModule;
import com.google.gerrit.sshd.SshKeyCacheImpl;
import com.google.gerrit.sshd.SshModule;
@@ -324,6 +325,7 @@
modules.add(new RestApiModule());
modules.add(new SubscriptionGraph.Module());
+ modules.add(new SuperprojectUpdateSubmissionListener.Module());
modules.add(new WorkQueue.Module());
modules.add(new GerritInstanceNameModule());
modules.add(
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index cddaea4..77d02c1 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -14,25 +14,21 @@
package com.google.gerrit.httpd.raw;
+import static com.google.gerrit.httpd.raw.IndexPreloadingUtil.RequestedPage;
import static com.google.template.soy.data.ordainers.GsonOrdainer.serializeObject;
import static java.util.stream.Collectors.toSet;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.common.UsedAt.Project;
import com.google.gerrit.extensions.api.GerritApi;
import com.google.gerrit.extensions.api.accounts.AccountApi;
import com.google.gerrit.extensions.api.config.Server;
-import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.ListOption;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.Url;
import com.google.gerrit.json.OutputFormat;
import com.google.gson.Gson;
import com.google.template.soy.data.SanitizedContent;
@@ -44,61 +40,13 @@
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
/** Helper for generating parts of {@code index.html}. */
@UsedAt(Project.GOOGLE)
public class IndexHtmlUtil {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- public static final String CHANGE_CANONICAL_URL = ".*/c/(?<project>.+)/\\+/(?<changeNum>\\d+)";
- public static final String BASE_PATCH_NUM_URL_PART = "(/(-?\\d+|edit)(\\.\\.(\\d+|edit))?)";
- public static final Pattern CHANGE_URL_PATTERN =
- Pattern.compile(CHANGE_CANONICAL_URL + BASE_PATCH_NUM_URL_PART + "?" + "/?$");
- public static final Pattern DIFF_URL_PATTERN =
- Pattern.compile(CHANGE_CANONICAL_URL + BASE_PATCH_NUM_URL_PART + "(/(.+))" + "/?$");
-
private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
-
- public static String getDefaultChangeDetailHex() {
- Set<ListChangesOption> options =
- ImmutableSet.of(
- ListChangesOption.ALL_COMMITS,
- ListChangesOption.ALL_REVISIONS,
- ListChangesOption.CHANGE_ACTIONS,
- ListChangesOption.DETAILED_LABELS,
- ListChangesOption.DOWNLOAD_COMMANDS,
- ListChangesOption.MESSAGES,
- ListChangesOption.SUBMITTABLE,
- ListChangesOption.WEB_LINKS,
- ListChangesOption.SKIP_DIFFSTAT);
-
- return ListOption.toHex(options);
- }
-
- public static String getDefaultDiffDetailHex() {
- Set<ListChangesOption> options =
- ImmutableSet.of(
- ListChangesOption.ALL_COMMITS,
- ListChangesOption.ALL_REVISIONS,
- ListChangesOption.SKIP_DIFFSTAT);
-
- return ListOption.toHex(options);
- }
-
- public static String computeChangeRequestsPath(String requestedURL, Pattern pattern) {
- Matcher matcher = pattern.matcher(requestedURL);
- if (matcher.matches()) {
- Integer changeId = Ints.tryParse(matcher.group("changeNum"));
- if (changeId != null) {
- return "changes/" + Url.encode(matcher.group("project")) + "~" + changeId;
- }
- }
-
- return null;
- }
-
/**
* Returns both static and dynamic parameters of {@code index.html}. The result is to be used when
* rendering the soy template.
@@ -115,24 +63,19 @@
ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
data.putAll(
staticTemplateData(
- canonicalURL,
- cdnPath,
- faviconPath,
- urlParameterMap,
- urlInScriptTagOrdainer,
- requestedURL))
- .putAll(dynamicTemplateData(gerritApi));
+ canonicalURL, cdnPath, faviconPath, urlParameterMap, urlInScriptTagOrdainer))
+ .putAll(dynamicTemplateData(gerritApi, requestedURL));
Set<String> enabledExperiments = experimentData(urlParameterMap);
if (!enabledExperiments.isEmpty()) {
- data.put("enabledExperiments", serializeObject(GSON, enabledExperiments));
+ data.put("enabledExperiments", serializeObject(GSON, enabledExperiments).toString());
}
return data.build();
}
/** Returns dynamic parameters of {@code index.html}. */
- public static ImmutableMap<String, Object> dynamicTemplateData(GerritApi gerritApi)
- throws RestApiException {
+ public static ImmutableMap<String, Object> dynamicTemplateData(
+ GerritApi gerritApi, String requestedURL) throws RestApiException, URISyntaxException {
ImmutableMap.Builder<String, Object> data = ImmutableMap.builder();
Map<String, SanitizedContent> initialData = new HashMap<>();
Server serverApi = gerritApi.config().server();
@@ -140,6 +83,28 @@
initialData.put("\"/config/server/version\"", serializeObject(GSON, serverApi.getVersion()));
initialData.put("\"/config/server/top-menus\"", serializeObject(GSON, serverApi.topMenus()));
+ String requestedPath = IndexPreloadingUtil.getPath(requestedURL);
+ IndexPreloadingUtil.RequestedPage page = IndexPreloadingUtil.parseRequestedPage(requestedPath);
+ switch (page) {
+ case CHANGE:
+ data.put(
+ "defaultChangeDetailHex", IndexPreloadingUtil.getDefaultChangeDetailOptionsAsHex());
+ data.put(
+ "changeRequestsPath",
+ IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
+ break;
+ case DIFF:
+ data.put("defaultDiffDetailHex", IndexPreloadingUtil.getDefaultDiffDetailOptionsAsHex());
+ data.put(
+ "changeRequestsPath",
+ IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
+ break;
+ case DASHBOARD:
+ // Dashboard is preloaded queries are added later when we check user is authenticated.
+ case PAGE_WITHOUT_PRELOADING:
+ break;
+ }
+
try {
AccountApi accountApi = gerritApi.accounts().self();
initialData.put("\"/accounts/self/detail\"", serializeObject(GSON, accountApi.get()));
@@ -152,6 +117,10 @@
"\"/accounts/self/preferences.edit\"",
serializeObject(GSON, accountApi.getEditPreferences()));
data.put("userIsAuthenticated", true);
+ if (page == RequestedPage.DASHBOARD) {
+ data.put("defaultDashboardHex", IndexPreloadingUtil.getDefaultDashboardHex(serverApi));
+ data.put("dashboardQuery", IndexPreloadingUtil.computeDashboardQueryList(serverApi));
+ }
} catch (AuthException e) {
logger.atFine().log("Can't inline account-related data because user is unauthenticated");
// Don't render data
@@ -179,8 +148,7 @@
String cdnPath,
String faviconPath,
Map<String, String[]> urlParameterMap,
- Function<String, SanitizedContent> urlInScriptTagOrdainer,
- String requestedURL)
+ Function<String, SanitizedContent> urlInScriptTagOrdainer)
throws URISyntaxException {
String canonicalPath = computeCanonicalPath(canonicalURL);
@@ -203,22 +171,6 @@
if (faviconPath != null) {
data.put("faviconPath", faviconPath);
}
- if (requestedURL != null) {
- data.put("defaultChangeDetailHex", getDefaultChangeDetailHex());
- data.put("defaultDiffDetailHex", getDefaultDiffDetailHex());
-
- String changeRequestsPath = computeChangeRequestsPath(requestedURL, CHANGE_URL_PATTERN);
- if (changeRequestsPath != null) {
- data.put("preloadChangePage", "true");
- } else {
- changeRequestsPath = computeChangeRequestsPath(requestedURL, DIFF_URL_PATTERN);
- data.put("preloadDiffPage", "true");
- }
-
- if (changeRequestsPath != null) {
- data.put("changeRequestsPath", changeRequestsPath);
- }
- }
if (urlParameterMap.containsKey("ce")) {
data.put("polyfillCE", "true");
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
new file mode 100644
index 0000000..c17cd97
--- /dev/null
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -0,0 +1,225 @@
+// 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.httpd.raw;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.client.ListOption;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.Url;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+/** Helper for generating preloading parts of {@code index.html}. */
+@UsedAt(Project.GOOGLE)
+public class IndexPreloadingUtil {
+ enum RequestedPage {
+ CHANGE,
+ DIFF,
+ DASHBOARD,
+ PAGE_WITHOUT_PRELOADING,
+ }
+
+ public static final String CHANGE_CANONICAL_PATH = "/c/(?<project>.+)/\\+/(?<changeNum>\\d+)";
+ public static final String BASE_PATCH_NUM_PATH_PART = "(/(-?\\d+|edit)(\\.\\.(\\d+|edit))?)";
+ public static final Pattern CHANGE_URL_PATTERN =
+ Pattern.compile(CHANGE_CANONICAL_PATH + BASE_PATCH_NUM_PATH_PART + "?" + "/?$");
+ public static final Pattern DIFF_URL_PATTERN =
+ Pattern.compile(CHANGE_CANONICAL_PATH + BASE_PATCH_NUM_PATH_PART + "(/(.+))" + "/?$");
+ public static final Pattern DASHBOARD_PATTERN = Pattern.compile("/dashboard/self$");
+ public static final String ROOT_PATH = "/";
+
+ // These queries should be kept in sync with PolyGerrit:
+ // polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+ public static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft limit:10";
+ public static final String YOUR_TURN = "attention:${user} limit:25";
+ public static final String DASHBOARD_ASSIGNED_QUERY =
+ "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open -is:ignored limit:25";
+ public static final String DASHBOARD_WORK_IN_PROGRESS_QUERY =
+ "is:open owner:${user} is:wip limit:25";
+ public static final String DASHBOARD_OUTGOING_QUERY =
+ "is:open owner:${user} -is:wip -is:ignored limit:25";
+ public static final String DASHBOARD_INCOMING_QUERY =
+ "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user}) limit:25";
+ public static final String CC_QUERY = "is:open -is:ignored cc:${user} limit:10";
+ public static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
+ "is:closed -is:ignored (-is:wip OR owner:self) "
+ + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
+ + "OR cc:${user}) -age:4w limit:10";
+ public static final String NEW_USER = "owner:${user} limit:1";
+
+ public static final String SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY =
+ DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY.replaceAll("\\$\\{user}", "self");
+ public static final String SELF_YOUR_TURN = YOUR_TURN.replaceAll("\\$\\{user}", "self");
+ public static final String SELF_DASHBOARD_ASSIGNED_QUERY =
+ DASHBOARD_ASSIGNED_QUERY.replaceAll("\\$\\{user}", "self");
+ public static final ImmutableList<String> SELF_DASHBOARD_QUERIES =
+ Stream.of(
+ DASHBOARD_WORK_IN_PROGRESS_QUERY,
+ DASHBOARD_OUTGOING_QUERY,
+ DASHBOARD_INCOMING_QUERY,
+ CC_QUERY,
+ DASHBOARD_RECENTLY_CLOSED_QUERY,
+ NEW_USER)
+ .map(query -> query.replaceAll("\\$\\{user}", "self"))
+ .collect(toImmutableList());
+
+ public static String getDefaultChangeDetailOptionsAsHex() {
+ Set<ListChangesOption> options =
+ ImmutableSet.of(
+ ListChangesOption.ALL_COMMITS,
+ ListChangesOption.ALL_REVISIONS,
+ ListChangesOption.CHANGE_ACTIONS,
+ ListChangesOption.DETAILED_LABELS,
+ ListChangesOption.DOWNLOAD_COMMANDS,
+ ListChangesOption.MESSAGES,
+ ListChangesOption.SUBMITTABLE,
+ ListChangesOption.WEB_LINKS,
+ ListChangesOption.SKIP_DIFFSTAT);
+
+ return ListOption.toHex(options);
+ }
+
+ public static String getDefaultDiffDetailOptionsAsHex() {
+ Set<ListChangesOption> options =
+ ImmutableSet.of(
+ ListChangesOption.ALL_COMMITS,
+ ListChangesOption.ALL_REVISIONS,
+ ListChangesOption.SKIP_DIFFSTAT);
+
+ return ListOption.toHex(options);
+ }
+
+ public static String getDefaultDashboardHex(Server serverApi) throws RestApiException {
+ Set<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
+ options.add(ListChangesOption.LABELS);
+ options.add(ListChangesOption.DETAILED_ACCOUNTS);
+
+ if (isEnabledAttentionSet(serverApi)) {
+ options.add(ListChangesOption.DETAILED_LABELS);
+ } else {
+ options.add(ListChangesOption.REVIEWED);
+ }
+ return ListOption.toHex(options);
+ }
+
+ public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
+ if (requestedURL == null) {
+ return null;
+ }
+ URI uri = new URI(requestedURL);
+ return uri.getPath();
+ }
+
+ public static RequestedPage parseRequestedPage(@Nullable String requestedPath) {
+ if (requestedPath == null) {
+ return RequestedPage.PAGE_WITHOUT_PRELOADING;
+ }
+
+ Optional<String> changeRequestsPath =
+ computeChangeRequestsPath(requestedPath, RequestedPage.CHANGE);
+ if (changeRequestsPath.isPresent()) {
+ return RequestedPage.CHANGE;
+ }
+
+ changeRequestsPath = computeChangeRequestsPath(requestedPath, RequestedPage.DIFF);
+ if (changeRequestsPath.isPresent()) {
+ return RequestedPage.DIFF;
+ }
+
+ Matcher dashboardMatcher = IndexPreloadingUtil.DASHBOARD_PATTERN.matcher(requestedPath);
+ if (dashboardMatcher.matches()) {
+ return RequestedPage.DASHBOARD;
+ }
+
+ if (ROOT_PATH.equals(requestedPath)) {
+ return RequestedPage.DASHBOARD;
+ }
+
+ return RequestedPage.PAGE_WITHOUT_PRELOADING;
+ }
+
+ public static Optional<String> computeChangeRequestsPath(
+ String requestedURL, RequestedPage page) {
+ Matcher matcher;
+ switch (page) {
+ case CHANGE:
+ matcher = CHANGE_URL_PATTERN.matcher(requestedURL);
+ break;
+ case DIFF:
+ matcher = DIFF_URL_PATTERN.matcher(requestedURL);
+ break;
+ case DASHBOARD:
+ case PAGE_WITHOUT_PRELOADING:
+ default:
+ return Optional.empty();
+ }
+
+ if (matcher.matches()) {
+ Integer changeId = Ints.tryParse(matcher.group("changeNum"));
+ if (changeId != null) {
+ return Optional.of("changes/" + Url.encode(matcher.group("project")) + "~" + changeId);
+ }
+ }
+ return Optional.empty();
+ }
+
+ public static List<String> computeDashboardQueryList(Server serverApi) throws RestApiException {
+ List<String> queryList = new ArrayList<>();
+ queryList.add(SELF_DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY);
+ if (isEnabledAttentionSet(serverApi)) {
+ queryList.add(SELF_YOUR_TURN);
+ }
+ if (isEnabledAssignee(serverApi)) {
+ queryList.add(SELF_DASHBOARD_ASSIGNED_QUERY);
+ }
+
+ queryList.addAll(SELF_DASHBOARD_QUERIES);
+
+ return queryList;
+ }
+
+ private static boolean isEnabledAttentionSet(Server serverApi) throws RestApiException {
+ return serverApi.getInfo() != null
+ && serverApi.getInfo().change != null
+ && serverApi.getInfo().change.enableAttentionSet != null
+ && serverApi.getInfo().change.enableAttentionSet;
+ }
+
+ private static boolean isEnabledAssignee(Server serverApi) throws RestApiException {
+ return serverApi.getInfo() != null
+ && serverApi.getInfo().change != null
+ && serverApi.getInfo().change.enableAssignee != null
+ && serverApi.getInfo().change.enableAssignee;
+ }
+
+ private IndexPreloadingUtil() {}
+}
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index 414a120..4b2c8a9 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -69,6 +69,7 @@
ImmutableList.of(
"/",
"/c/*",
+ "/id/*",
"/p/*",
"/q/*",
"/x/*",
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 172321d..95d99f0 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -32,7 +32,6 @@
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.Url;
@@ -44,7 +43,6 @@
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import com.google.inject.Inject;
-import com.google.inject.Injector;
import java.io.IOException;
import java.io.StringWriter;
import java.util.HashSet;
@@ -149,24 +147,20 @@
}
private final CmdLineParser.Factory parserFactory;
- private final Injector injector;
- private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
@Inject
- ParameterParser(
- CmdLineParser.Factory pf,
- Injector injector,
- DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+ ParameterParser(CmdLineParser.Factory pf) {
this.parserFactory = pf;
- this.injector = injector;
- this.dynamicBeans = dynamicBeans;
}
<T> boolean parse(
- T param, ListMultimap<String, String> in, HttpServletRequest req, HttpServletResponse res)
+ T param,
+ DynamicOptions pluginOptions,
+ ListMultimap<String, String> in,
+ HttpServletRequest req,
+ HttpServletResponse res)
throws IOException {
CmdLineParser clp = parserFactory.create(param);
- DynamicOptions pluginOptions = new DynamicOptions(param, injector, dynamicBeans);
pluginOptions.parseDynamicBeans(clp);
pluginOptions.setDynamicBeans();
pluginOptions.onBeanParseStart();
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index f743578..4d55b36 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -103,6 +103,7 @@
import com.google.gerrit.server.AccessPath;
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.ExceptionHook;
import com.google.gerrit.server.OptionUtil;
import com.google.gerrit.server.RequestInfo;
@@ -146,6 +147,7 @@
import com.google.gson.stream.JsonWriter;
import com.google.gson.stream.MalformedJsonException;
import com.google.inject.Inject;
+import com.google.inject.Injector;
import com.google.inject.Provider;
import com.google.inject.TypeLiteral;
import com.google.inject.util.Providers;
@@ -250,6 +252,8 @@
final ChangeFinder changeFinder;
final RetryHelper retryHelper;
final PluginSetContext<ExceptionHook> exceptionHooks;
+ final Injector injector;
+ final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
@Inject
Globals(
@@ -265,7 +269,9 @@
DynamicSet<PerformanceLogger> performanceLoggers,
ChangeFinder changeFinder,
RetryHelper retryHelper,
- PluginSetContext<ExceptionHook> exceptionHooks) {
+ PluginSetContext<ExceptionHook> exceptionHooks,
+ Injector injector,
+ DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
this.currentUser = currentUser;
this.webSession = webSession;
this.paramParser = paramParser;
@@ -280,6 +286,8 @@
this.retryHelper = retryHelper;
this.exceptionHooks = exceptionHooks;
allowOrigin = makeAllowOrigin(config);
+ this.injector = injector;
+ this.dynamicBeans = dynamicBeans;
}
private static Pattern makeAllowOrigin(Config cfg) {
@@ -498,105 +506,116 @@
return;
}
- if (!globals.paramParser.get().parse(viewData.view, qp.params(), req, res)) {
- return;
+ try (DynamicOptions pluginOptions =
+ new DynamicOptions(viewData.view, globals.injector, globals.dynamicBeans)) {
+ if (!globals
+ .paramParser
+ .get()
+ .parse(viewData.view, pluginOptions, qp.params(), req, res)) {
+ return;
+ }
+
+ if (viewData.view instanceof RestReadView<?> && isRead(req)) {
+ response =
+ invokeRestReadViewWithRetry(
+ req,
+ traceContext,
+ viewData,
+ (RestReadView<RestResource>) viewData.view,
+ rsrc);
+ } else if (viewData.view instanceof RestModifyView<?, ?>) {
+ @SuppressWarnings("unchecked")
+ RestModifyView<RestResource, Object> m =
+ (RestModifyView<RestResource, Object>) viewData.view;
+
+ Type type = inputType(m);
+ inputRequestBody = parseRequest(req, type);
+ response =
+ invokeRestModifyViewWithRetry(
+ req, traceContext, viewData, m, rsrc, inputRequestBody);
+
+ if (inputRequestBody instanceof RawInput) {
+ try (InputStream is = req.getInputStream()) {
+ ServletUtils.consumeRequestBody(is);
+ }
+ }
+ } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
+ @SuppressWarnings("unchecked")
+ RestCollectionCreateView<RestResource, RestResource, Object> m =
+ (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
+
+ Type type = inputType(m);
+ inputRequestBody = parseRequest(req, type);
+ response =
+ invokeRestCollectionCreateViewWithRetry(
+ req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
+ if (inputRequestBody instanceof RawInput) {
+ try (InputStream is = req.getInputStream()) {
+ ServletUtils.consumeRequestBody(is);
+ }
+ }
+ } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
+ @SuppressWarnings("unchecked")
+ RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
+ (RestCollectionDeleteMissingView<RestResource, RestResource, Object>)
+ viewData.view;
+
+ Type type = inputType(m);
+ inputRequestBody = parseRequest(req, type);
+ response =
+ invokeRestCollectionDeleteMissingViewWithRetry(
+ req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
+ if (inputRequestBody instanceof RawInput) {
+ try (InputStream is = req.getInputStream()) {
+ ServletUtils.consumeRequestBody(is);
+ }
+ }
+ } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
+ @SuppressWarnings("unchecked")
+ RestCollectionModifyView<RestResource, RestResource, Object> m =
+ (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
+
+ Type type = inputType(m);
+ inputRequestBody = parseRequest(req, type);
+ response =
+ invokeRestCollectionModifyViewWithRetry(
+ req, traceContext, viewData, m, rsrc, inputRequestBody);
+ if (inputRequestBody instanceof RawInput) {
+ try (InputStream is = req.getInputStream()) {
+ ServletUtils.consumeRequestBody(is);
+ }
+ }
+ } else {
+ throw new ResourceNotFoundException();
+ }
+
+ if (response instanceof Response.Redirect) {
+ CacheHeaders.setNotCacheable(res);
+ String location = ((Response.Redirect) response).location();
+ res.sendRedirect(location);
+ logger.atFinest().log("REST call redirected to: %s", location);
+ return;
+ } else if (response instanceof Response.Accepted) {
+ CacheHeaders.setNotCacheable(res);
+ res.setStatus(response.statusCode());
+ res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
+ logger.atFinest().log("REST call succeeded: %d", response.statusCode());
+ return;
+ }
+
+ statusCode = response.statusCode();
+ configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
+ res.setStatus(statusCode);
+ logger.atFinest().log("REST call succeeded: %d", statusCode);
}
- if (viewData.view instanceof RestReadView<?> && isRead(req)) {
- response =
- invokeRestReadViewWithRetry(
- req, traceContext, viewData, (RestReadView<RestResource>) viewData.view, rsrc);
- } else if (viewData.view instanceof RestModifyView<?, ?>) {
- @SuppressWarnings("unchecked")
- RestModifyView<RestResource, Object> m =
- (RestModifyView<RestResource, Object>) viewData.view;
-
- Type type = inputType(m);
- inputRequestBody = parseRequest(req, type);
- response =
- invokeRestModifyViewWithRetry(
- req, traceContext, viewData, m, rsrc, inputRequestBody);
-
- if (inputRequestBody instanceof RawInput) {
- try (InputStream is = req.getInputStream()) {
- ServletUtils.consumeRequestBody(is);
- }
+ if (response != Response.none()) {
+ Object value = Response.unwrap(response);
+ if (value instanceof BinaryResult) {
+ responseBytes = replyBinaryResult(req, res, (BinaryResult) value);
+ } else {
+ responseBytes = replyJson(req, res, false, qp.config(), value);
}
- } else if (viewData.view instanceof RestCollectionCreateView<?, ?, ?>) {
- @SuppressWarnings("unchecked")
- RestCollectionCreateView<RestResource, RestResource, Object> m =
- (RestCollectionCreateView<RestResource, RestResource, Object>) viewData.view;
-
- Type type = inputType(m);
- inputRequestBody = parseRequest(req, type);
- response =
- invokeRestCollectionCreateViewWithRetry(
- req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
- if (inputRequestBody instanceof RawInput) {
- try (InputStream is = req.getInputStream()) {
- ServletUtils.consumeRequestBody(is);
- }
- }
- } else if (viewData.view instanceof RestCollectionDeleteMissingView<?, ?, ?>) {
- @SuppressWarnings("unchecked")
- RestCollectionDeleteMissingView<RestResource, RestResource, Object> m =
- (RestCollectionDeleteMissingView<RestResource, RestResource, Object>) viewData.view;
-
- Type type = inputType(m);
- inputRequestBody = parseRequest(req, type);
- response =
- invokeRestCollectionDeleteMissingViewWithRetry(
- req, traceContext, viewData, m, rsrc, path.get(0), inputRequestBody);
- if (inputRequestBody instanceof RawInput) {
- try (InputStream is = req.getInputStream()) {
- ServletUtils.consumeRequestBody(is);
- }
- }
- } else if (viewData.view instanceof RestCollectionModifyView<?, ?, ?>) {
- @SuppressWarnings("unchecked")
- RestCollectionModifyView<RestResource, RestResource, Object> m =
- (RestCollectionModifyView<RestResource, RestResource, Object>) viewData.view;
-
- Type type = inputType(m);
- inputRequestBody = parseRequest(req, type);
- response =
- invokeRestCollectionModifyViewWithRetry(
- req, traceContext, viewData, m, rsrc, inputRequestBody);
- if (inputRequestBody instanceof RawInput) {
- try (InputStream is = req.getInputStream()) {
- ServletUtils.consumeRequestBody(is);
- }
- }
- } else {
- throw new ResourceNotFoundException();
- }
-
- if (response instanceof Response.Redirect) {
- CacheHeaders.setNotCacheable(res);
- String location = ((Response.Redirect) response).location();
- res.sendRedirect(location);
- logger.atFinest().log("REST call redirected to: %s", location);
- return;
- } else if (response instanceof Response.Accepted) {
- CacheHeaders.setNotCacheable(res);
- res.setStatus(response.statusCode());
- res.setHeader(HttpHeaders.LOCATION, ((Response.Accepted) response).location());
- logger.atFinest().log("REST call succeeded: %d", response.statusCode());
- return;
- }
-
- statusCode = response.statusCode();
- configureCaching(req, res, traceContext, rsrc, viewData, response.caching());
- res.setStatus(statusCode);
- logger.atFinest().log("REST call succeeded: %d", statusCode);
- }
-
- if (response != Response.none()) {
- Object value = Response.unwrap(response);
- if (value instanceof BinaryResult) {
- responseBytes = replyBinaryResult(req, res, (BinaryResult) value);
- } else {
- responseBytes = replyJson(req, res, false, qp.config(), value);
}
}
} catch (MalformedJsonException | JsonParseException e) {
@@ -1640,9 +1659,6 @@
"Invalid authentication method. In order to authenticate, "
+ "prefix the REST endpoint URL with /a/ (e.g. http://example.com/a/projects/).");
}
- if (user.isIdentifiedUser()) {
- user.setLastLoginExternalIdKey(globals.webSession.get().getLastLoginExternalId());
- }
}
private List<String> getParameterNames(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/json/BUILD b/java/com/google/gerrit/json/BUILD
index 439f23f..d9cec45 100644
--- a/java/com/google/gerrit/json/BUILD
+++ b/java/com/google/gerrit/json/BUILD
@@ -6,6 +6,5 @@
visibility = ["//visibility:public"],
deps = [
"//lib:gson",
- "//lib/flogger:api",
],
)
diff --git a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
index 21c4891..9c32aa8 100644
--- a/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
+++ b/java/com/google/gerrit/json/EnumTypeAdapterFactory.java
@@ -14,8 +14,8 @@
package com.google.gerrit.json;
-import com.google.common.flogger.FluentLogger;
import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.internal.bind.TypeAdapters;
@@ -32,7 +32,6 @@
* special behavior: log when input which doesn't match any existing enum value is encountered.
*/
public class EnumTypeAdapterFactory implements TypeAdapterFactory {
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@SuppressWarnings({"rawtypes", "unchecked"})
@Override
@@ -65,7 +64,8 @@
}
T enumValue = defaultEnumAdapter.read(in);
if (enumValue == null) {
- logger.atWarning().log("Expected an existing value for enum %s.", typeToken);
+ throw new JsonSyntaxException(
+ String.format("Expected an existing value for enum %s.", typeToken));
}
return enumValue;
}
diff --git a/java/com/google/gerrit/mail/HtmlParser.java b/java/com/google/gerrit/mail/HtmlParser.java
index 2fc659d..ba73bdd 100644
--- a/java/com/google/gerrit/mail/HtmlParser.java
+++ b/java/com/google/gerrit/mail/HtmlParser.java
@@ -35,7 +35,7 @@
"gmail_quote" // Used for quoting original content
);
- private static final ImmutableSet<String> WHITELISTED_HTML_TAGS =
+ private static final ImmutableSet<String> ALLOWED_HTML_TAGS =
ImmutableSet.of(
"div", // Most user-typed comments are contained in a <div> tag
"a", // We allow links to be contained in a comment
@@ -120,8 +120,8 @@
// There is no user-input in quoted text
continue;
}
- if (!WHITELISTED_HTML_TAGS.contains(elementName)) {
- // We only accept a set of whitelisted tags that can contain user input
+ if (!ALLOWED_HTML_TAGS.contains(elementName)) {
+ // We only accept a set of allowed tags that can contain user input
continue;
}
if (elementName.equals("a") && e.attr("href").startsWith("mailto:")) {
diff --git a/java/com/google/gerrit/mail/MailHeader.java b/java/com/google/gerrit/mail/MailHeader.java
index 2f31a9c..2700f81 100644
--- a/java/com/google/gerrit/mail/MailHeader.java
+++ b/java/com/google/gerrit/mail/MailHeader.java
@@ -18,6 +18,7 @@
public enum MailHeader {
// Gerrit metadata holders
ASSIGNEE("Gerrit-Assignee"),
+ ATTENTION("Gerrit-Attention"),
BRANCH("Gerrit-Branch"),
CC("Gerrit-CC"),
COMMENT_IN_REPLY_TO("Comment-In-Reply-To"),
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 63278c1..2eb19aa 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -108,6 +108,7 @@
import com.google.gerrit.server.ssh.SshAddressesModule;
import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
import com.google.gerrit.server.submit.SubscriptionGraph;
+import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener;
import com.google.gerrit.sshd.SshHostKeyModule;
import com.google.gerrit.sshd.SshKeyCacheImpl;
import com.google.gerrit.sshd.SshModule;
@@ -205,6 +206,7 @@
private AbstractModule luceneModule;
private Module emailModule;
private List<Module> testSysModules = new ArrayList<>();
+ private List<Module> testSshModules = new ArrayList<>();
private Module auditEventModule;
private Runnable serverStarted;
@@ -336,6 +338,11 @@
}
@VisibleForTesting
+ public void addAdditionalSshModuleForTesting(@Nullable Module... modules) {
+ testSshModules.addAll(Arrays.asList(modules));
+ }
+
+ @VisibleForTesting
public void start() throws IOException {
if (dbInjector == null) {
dbInjector = createDbInjector(true /* enableMetrics */);
@@ -347,9 +354,7 @@
sysInjector.getInstance(PluginGuiceEnvironment.class).setDbCfgInjector(dbInjector, cfgInjector);
manager.add(dbInjector, cfgInjector, sysInjector);
- if (!consoleLog) {
- manager.add(ErrorLogFile.start(getSitePath(), config));
- }
+ manager.add(ErrorLogFile.start(getSitePath(), config, consoleLog));
sshd &= !sshdOff();
if (sshd) {
@@ -413,6 +418,7 @@
modules.add(createIndexModule());
modules.add(new SubscriptionGraph.Module());
+ modules.add(new SuperprojectUpdateSubmissionListener.Module());
modules.add(new WorkQueue.Module());
modules.add(new StreamEventsApiListener.Module());
modules.add(new EventBroker.Module());
@@ -493,12 +499,13 @@
modules.add(new AccountDeactivator.Module());
modules.add(new ChangeCleanupRunner.Module());
}
- modules.addAll(testSysModules);
modules.add(new LocalMergeSuperSetComputation.Module());
modules.add(new DefaultProjectNameLockManager.Module());
- return cfgInjector.createChildInjector(
- ModuleOverloader.override(
- modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE)));
+
+ List<Module> libModules = LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE);
+ libModules.addAll(testSysModules);
+
+ return cfgInjector.createChildInjector(ModuleOverloader.override(modules, libModules));
}
private Module createIndexModule() {
@@ -531,6 +538,8 @@
replica,
sysInjector.getInstance(DownloadConfig.class),
sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
+
+ modules.addAll(testSshModules);
if (!replica) {
modules.add(new IndexCommandsModule(sysInjector));
modules.add(new SequenceCommandsModule());
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 966801f..c8d69f1c 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -29,6 +29,10 @@
import com.google.gerrit.lucene.LuceneIndexModule;
import com.google.gerrit.pgm.util.BatchProgramModule;
import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.LibModuleType;
+import com.google.gerrit.server.ModuleOverloader;
+import com.google.gerrit.server.cache.h2.H2CacheModule;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.index.IndexModule;
@@ -159,6 +163,7 @@
}
modules.add(indexModule);
modules.add(new BatchProgramModule());
+ modules.add(new H2CacheModule());
modules.add(
new FactoryModule() {
@Override
@@ -167,7 +172,9 @@
}
});
- return dbInjector.createChildInjector(modules);
+ return dbInjector.createChildInjector(
+ ModuleOverloader.override(
+ modules, LibModuleLoader.loadModules(dbInjector, LibModuleType.SYS_MODULE)));
}
private void overrideConfig() {
diff --git a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
index 8ae0d4f..95a5b07 100644
--- a/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
+++ b/java/com/google/gerrit/pgm/http/jetty/HttpLogJsonLayout.java
@@ -16,6 +16,7 @@
import static com.google.gerrit.pgm.http.jetty.HttpLog.P_CONTENT_LENGTH;
import static com.google.gerrit.pgm.http.jetty.HttpLog.P_HOST;
+import static com.google.gerrit.pgm.http.jetty.HttpLog.P_LATENCY;
import static com.google.gerrit.pgm.http.jetty.HttpLog.P_METHOD;
import static com.google.gerrit.pgm.http.jetty.HttpLog.P_PROTOCOL;
import static com.google.gerrit.pgm.http.jetty.HttpLog.P_REFERER;
@@ -46,6 +47,7 @@
public String protocol;
public String status;
public String contentLength;
+ public String latency;
public String referer;
public String userAgent;
@@ -59,6 +61,7 @@
this.protocol = getMdcString(event, P_PROTOCOL);
this.status = getMdcString(event, P_STATUS);
this.contentLength = getMdcString(event, P_CONTENT_LENGTH);
+ this.latency = getMdcString(event, P_LATENCY);
this.referer = getMdcString(event, P_REFERER);
this.userAgent = getMdcString(event, P_USER_AGENT);
}
diff --git a/java/com/google/gerrit/pgm/init/InitLabels.java b/java/com/google/gerrit/pgm/init/InitLabels.java
index 0797cf9..3edc732 100644
--- a/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -14,7 +14,7 @@
package com.google.gerrit.pgm.init;
-import static com.google.gerrit.common.data.LabelFunction.MAX_WITH_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.MAX_WITH_BLOCK;
import com.google.gerrit.pgm.init.api.AllProjectsConfig;
import com.google.gerrit.pgm.init.api.ConsoleUI;
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index fff33e5..ddc4f79 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -108,6 +108,8 @@
extractMailExample("AbandonedHtml.soy");
extractMailExample("AddKey.soy");
extractMailExample("AddKeyHtml.soy");
+ extractMailExample("AddToAttentionSet.soy");
+ extractMailExample("AddToAttentionSetHtml.soy");
extractMailExample("ChangeFooter.soy");
extractMailExample("ChangeFooterHtml.soy");
extractMailExample("ChangeSubject.soy");
@@ -123,7 +125,8 @@
extractMailExample("DeleteVoteHtml.soy");
extractMailExample("Footer.soy");
extractMailExample("FooterHtml.soy");
- extractMailExample("HeaderHtml.soy");
+ extractMailExample("ChangeHeader.soy");
+ extractMailExample("ChangeHeaderHtml.soy");
extractMailExample("HttpPasswordUpdate.soy");
extractMailExample("HttpPasswordUpdateHtml.soy");
extractMailExample("InboundEmailRejection.soy");
@@ -134,6 +137,8 @@
extractMailExample("NewChangeHtml.soy");
extractMailExample("RegisterNewEmail.soy");
extractMailExample("RegisterNewEmailHtml.soy");
+ extractMailExample("RemoveFromAttentionSet.soy");
+ extractMailExample("RemoveFromAttentionSetHtml.soy");
extractMailExample("ReplacePatchSet.soy");
extractMailExample("ReplacePatchSetHtml.soy");
extractMailExample("Restored.soy");
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 21ce2d1..e9c0136 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -35,9 +35,9 @@
import com.google.gerrit.server.account.GroupCacheImpl;
import com.google.gerrit.server.account.GroupIncludeCacheImpl;
import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.ServiceUserClassifierImpl;
import com.google.gerrit.server.account.externalids.ExternalIdModule;
import com.google.gerrit.server.cache.CacheRemovalListener;
-import com.google.gerrit.server.cache.h2.H2CacheModule;
import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
import com.google.gerrit.server.change.ChangeAttributeFactory;
import com.google.gerrit.server.change.ChangeJson;
@@ -152,7 +152,7 @@
install(new BatchGitModule());
install(new DefaultPermissionBackendModule());
install(new DefaultMemoryCacheModule());
- install(new H2CacheModule());
+
install(new ExternalIdModule());
install(new GroupModule());
install(new NoteDbModule());
@@ -164,6 +164,7 @@
install(SectionSortCache.module());
install(ChangeKindCacheImpl.module());
install(MergeabilityCacheImpl.module());
+ install(ServiceUserClassifierImpl.module());
install(TagCache.module());
install(PureRevertCache.module());
factory(CapabilityCollection.Factory.class);
diff --git a/java/com/google/gerrit/pgm/util/ErrorLogFile.java b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
index 8eb6c34..634e56b 100644
--- a/java/com/google/gerrit/pgm/util/ErrorLogFile.java
+++ b/java/com/google/gerrit/pgm/util/ErrorLogFile.java
@@ -49,11 +49,12 @@
root.addAppender(dst);
}
- public static LifecycleListener start(Path sitePath, Config config) throws IOException {
+ public static LifecycleListener start(Path sitePath, Config config, boolean consoleLog)
+ throws IOException {
Path logdir =
FileUtil.mkdirsOrDie(new SitePaths(sitePath).logs_dir, "Cannot create log directory");
if (SystemLog.shouldConfigure()) {
- initLogSystem(logdir, config);
+ initLogSystem(logdir, config, consoleLog);
}
return new LifecycleListener() {
@@ -67,22 +68,30 @@
};
}
- private static void initLogSystem(Path logdir, Config config) {
+ private static void initLogSystem(Path logdir, Config config, boolean consoleLog) {
Logger root = LogManager.getRootLogger();
root.removeAllAppenders();
+ PatternLayout errorLogLayout =
+ new PatternLayout(
+ "[%d{" + LogTimestampFormatter.TIMESTAMP_FORMAT + "}] [%t] %-5p %c %x: %m%n");
+
+ if (consoleLog) {
+ ConsoleAppender dst = new ConsoleAppender();
+ dst.setLayout(errorLogLayout);
+ dst.setTarget("System.err");
+ dst.setThreshold(Level.INFO);
+ dst.activateOptions();
+
+ root.addAppender(dst);
+ }
+
boolean json = config.getBoolean("log", "jsonLogging", false);
- boolean text = config.getBoolean("log", "textLogging", true) || !json;
+ boolean text = config.getBoolean("log", "textLogging", true) || !(json || consoleLog);
boolean rotate = config.getBoolean("log", "rotate", true);
if (text) {
- root.addAppender(
- SystemLog.createAppender(
- logdir,
- LOG_NAME,
- new PatternLayout(
- "[%d{" + LogTimestampFormatter.TIMESTAMP_FORMAT + "}] [%t] %-5p %c %x: %m%n"),
- rotate));
+ root.addAppender(SystemLog.createAppender(logdir, LOG_NAME, errorLogLayout, rotate));
}
if (json) {
diff --git a/java/com/google/gerrit/prettify/common/EditHunk.java b/java/com/google/gerrit/prettify/common/EditHunk.java
new file mode 100644
index 0000000..68cb796
--- /dev/null
+++ b/java/com/google/gerrit/prettify/common/EditHunk.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2009 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.prettify.common;
+
+import java.util.List;
+import org.eclipse.jgit.diff.Edit;
+
+/**
+ * This is a legacy class. It was only simplified but not improved regarding readability or code
+ * health. Feel free to completely rewrite it or replace it with some other, better code.
+ */
+public class EditHunk {
+ private final List<Edit> edits;
+
+ private int curIdx;
+ private Edit curEdit;
+
+ private int aCur;
+ private int bCur;
+ private final int aEnd;
+ private final int bEnd;
+
+ public EditHunk(List<Edit> edits, int aSize, int bSize) {
+ this.edits = edits;
+
+ curIdx = 0;
+ curEdit = edits.get(curIdx);
+
+ aCur = 0;
+ bCur = 0;
+ aEnd = aSize;
+ bEnd = bSize;
+ }
+
+ public int getCurA() {
+ return aCur;
+ }
+
+ public int getCurB() {
+ return bCur;
+ }
+
+ public void incA() {
+ aCur++;
+ }
+
+ public void incB() {
+ bCur++;
+ }
+
+ public void incBoth() {
+ incA();
+ incB();
+ }
+
+ public boolean isUnmodifiedLine() {
+ return !isDeletedA() && !isInsertedB();
+ }
+
+ public boolean isDeletedA() {
+ return curEdit.getBeginA() <= aCur && aCur < curEdit.getEndA();
+ }
+
+ public boolean isInsertedB() {
+ return curEdit.getBeginB() <= bCur && bCur < curEdit.getEndB();
+ }
+
+ public boolean next() {
+ if (!in(curEdit)) {
+ if (curIdx < edits.size() - 1) {
+ curEdit = edits.get(++curIdx);
+ }
+ }
+ return aCur < aEnd || bCur < bEnd;
+ }
+
+ private boolean in(Edit edit) {
+ return aCur < edit.getEndA() || bCur < edit.getEndB();
+ }
+}
diff --git a/java/com/google/gerrit/prettify/common/EditList.java b/java/com/google/gerrit/prettify/common/EditList.java
deleted file mode 100644
index 172a346..0000000
--- a/java/com/google/gerrit/prettify/common/EditList.java
+++ /dev/null
@@ -1,174 +0,0 @@
-// Copyright (C) 2009 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.prettify.common;
-
-import java.util.Iterator;
-import java.util.List;
-import org.eclipse.jgit.diff.Edit;
-
-public class EditList {
- private final List<Edit> edits;
- private final int context;
- private final int aSize;
- private final int bSize;
-
- public EditList(final List<Edit> edits, int contextLines, int aSize, int bSize) {
- this.edits = edits;
- this.context = contextLines;
- this.aSize = aSize;
- this.bSize = bSize;
- }
-
- public List<Edit> getEdits() {
- return edits;
- }
-
- public Iterable<Hunk> getHunks() {
- return () ->
- new Iterator<Hunk>() {
- private int curIdx;
-
- @Override
- public boolean hasNext() {
- return curIdx < edits.size();
- }
-
- @Override
- public Hunk next() {
- final int c = curIdx;
- final int e = findCombinedEnd(c);
- curIdx = e + 1;
- return new Hunk(c, e);
- }
-
- @Override
- public void remove() {
- throw new UnsupportedOperationException();
- }
- };
- }
-
- private int findCombinedEnd(int i) {
- int end = i + 1;
- while (end < edits.size() && (combineA(end) || combineB(end))) {
- end++;
- }
- return end - 1;
- }
-
- private boolean combineA(int i) {
- final Edit s = edits.get(i);
- final Edit e = edits.get(i - 1);
- // + 1 to prevent '... skipping 1 common line ...' messages.
- return s.getBeginA() - e.getEndA() <= 2 * context + 1;
- }
-
- private boolean combineB(int i) {
- final int s = edits.get(i).getBeginB();
- final int e = edits.get(i - 1).getEndB();
- // + 1 to prevent '... skipping 1 common line ...' messages.
- return s - e <= 2 * context + 1;
- }
-
- public class Hunk {
- private int curIdx;
- private Edit curEdit;
- private final int endIdx;
- private final Edit endEdit;
-
- private int aCur;
- private int bCur;
- private final int aEnd;
- private final int bEnd;
-
- private Hunk(int ci, int ei) {
- curIdx = ci;
- endIdx = ei;
- curEdit = edits.get(curIdx);
- endEdit = edits.get(endIdx);
-
- aCur = Math.max(0, curEdit.getBeginA() - context);
- bCur = Math.max(0, curEdit.getBeginB() - context);
- aEnd = Math.min(aSize, endEdit.getEndA() + context);
- bEnd = Math.min(bSize, endEdit.getEndB() + context);
- }
-
- public int getCurA() {
- return aCur;
- }
-
- public int getCurB() {
- return bCur;
- }
-
- public Edit getCurEdit() {
- return curEdit;
- }
-
- public int getEndA() {
- return aEnd;
- }
-
- public int getEndB() {
- return bEnd;
- }
-
- public void incA() {
- aCur++;
- }
-
- public void incB() {
- bCur++;
- }
-
- public void incBoth() {
- incA();
- incB();
- }
-
- public boolean isStartOfFile() {
- return aCur == 0 && bCur == 0;
- }
-
- public boolean isContextLine() {
- return !isModifiedLine();
- }
-
- public boolean isDeletedA() {
- return curEdit.getBeginA() <= aCur && aCur < curEdit.getEndA();
- }
-
- public boolean isInsertedB() {
- return curEdit.getBeginB() <= bCur && bCur < curEdit.getEndB();
- }
-
- public boolean isModifiedLine() {
- return isDeletedA() || isInsertedB();
- }
-
- public boolean next() {
- if (!in(curEdit)) {
- if (curIdx < endIdx) {
- curEdit = edits.get(++curIdx);
- }
- }
- return aCur < aEnd || bCur < bEnd;
- }
-
- private boolean in(Edit edit) {
- return aCur < edit.getEndA() || bCur < edit.getEndB();
- }
- }
-}
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/ApprovalInference.java
index 417a4ef..aa3ef89 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/ApprovalInference.java
@@ -23,8 +23,8 @@
import com.google.common.collect.Table;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.extensions.client.ChangeKind;
diff --git a/java/com/google/gerrit/server/ApprovalsUtil.java b/java/com/google/gerrit/server/ApprovalsUtil.java
index 0280aee..411768d 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -27,11 +27,11 @@
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.PatchSetInfo;
@@ -117,16 +117,6 @@
}
/**
- * Get all reviewers and CCed accounts for a change.
- *
- * @param allApprovals all approvals to consider; must all belong to the same change.
- * @return reviewers for the change.
- */
- public ReviewerSet getReviewers(ChangeNotes notes, Iterable<PatchSetApproval> allApprovals) {
- return notes.load().getReviewers();
- }
-
- /**
* Get updates to reviewer set.
*
* @param notes change notes.
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 291ba6d..b7a00dd 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -54,6 +54,7 @@
"//java/com/google/gerrit/prettify:server",
"//java/com/google/gerrit/proto",
"//java/com/google/gerrit/server/cache/serialize",
+ "//java/com/google/gerrit/server/cache/serialize/entities",
"//java/com/google/gerrit/server/data",
"//java/com/google/gerrit/server/git/receive:ref_cache",
"//java/com/google/gerrit/server/ioutil",
@@ -112,6 +113,7 @@
"//lib/commons:compress",
"//lib/commons:dbcp",
"//lib/commons:lang",
+ "//lib/commons:lang3",
"//lib/commons:net",
"//lib/commons:validator",
"//lib/errorprone:annotations",
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index a166d97..eea1052 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -19,17 +19,24 @@
import com.google.common.collect.Ordering;
import com.google.common.io.BaseEncoding;
+import com.google.gerrit.common.FooterConstants;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.config.UrlFormatter;
import com.google.inject.Singleton;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
import java.util.Random;
import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
@Singleton
public class ChangeUtil {
@@ -106,5 +113,29 @@
return c != null ? c.getStatus().name().toLowerCase() : "deleted";
}
+ private static final Pattern LINK_CHANGE_ID_PATTERN = Pattern.compile("I[0-9a-f]{40}");
+
+ public static List<String> getChangeIdsFromFooter(RevCommit c, UrlFormatter urlFormatter) {
+ List<String> changeIds = c.getFooterLines(FooterConstants.CHANGE_ID);
+ Optional<String> webUrl = urlFormatter.getWebUrl();
+ if (!webUrl.isPresent()) {
+ return changeIds;
+ }
+
+ String prefix = webUrl.get() + "id/";
+ for (String link : c.getFooterLines(FooterConstants.LINK)) {
+ if (!link.startsWith(prefix)) {
+ continue;
+ }
+ String changeId = link.substring(prefix.length());
+ Matcher m = LINK_CHANGE_ID_PATTERN.matcher(changeId);
+ if (m.matches()) {
+ changeIds.add(changeId);
+ }
+ }
+
+ return changeIds;
+ }
+
private ChangeUtil() {}
}
diff --git a/java/com/google/gerrit/server/CommentContextLoader.java b/java/com/google/gerrit/server/CommentContextLoader.java
new file mode 100644
index 0000000..68a80c3
--- /dev/null
+++ b/java/com/google/gerrit/server/CommentContextLoader.java
@@ -0,0 +1,138 @@
+// 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;
+
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.ContextLineInfo;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.Text;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * Computes the list of {@link ContextLineInfo} for a given comment, that is, the lines of the
+ * source file surrounding and including the area where the comment was written.
+ */
+public class CommentContextLoader {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final GitRepositoryManager repoManager;
+ private final Project.NameKey project;
+
+ public interface Factory {
+ CommentContextLoader create(Project.NameKey project);
+ }
+
+ @Inject
+ CommentContextLoader(GitRepositoryManager repoManager, @Assisted Project.NameKey project) {
+ this.repoManager = repoManager;
+ this.project = project;
+ }
+
+ /**
+ * Load the comment context for multiple comments at once. This method will open the repository
+ * and read the source files for all necessary comments' file paths.
+ *
+ * @param comments a list of comments.
+ * @return a Map where all entries consist of the input comments and the values are their
+ * corresponding {@link CommentContext}.
+ */
+ public Map<Comment, CommentContext> getContext(Iterable<Comment> comments) {
+ ImmutableMap.Builder<Comment, CommentContext> result =
+ ImmutableMap.builderWithExpectedSize(Iterables.size(comments));
+
+ // Group comments by commit ID so that each commit is parsed only once
+ Map<ObjectId, List<Comment>> commentsByCommitId =
+ Streams.stream(comments).collect(groupingBy(Comment::getCommitId));
+
+ try (Repository repo = repoManager.openRepository(project);
+ RevWalk rw = new RevWalk(repo)) {
+ for (ObjectId commitId : commentsByCommitId.keySet()) {
+ RevCommit commit = rw.parseCommit(commitId);
+ for (Comment comment : commentsByCommitId.get(commitId)) {
+ Optional<Range> range = getStartAndEndLines(comment);
+ if (!range.isPresent()) {
+ continue;
+ }
+ // TODO(ghareeb): We can further group the comments by file paths to avoid opening
+ // the same file multiple times.
+ try (TreeWalk tw =
+ TreeWalk.forPath(rw.getObjectReader(), comment.key.filename, commit.getTree())) {
+ if (tw == null) {
+ logger.atWarning().log(
+ "Failed to find path %s in the git tree of ID %s.",
+ comment.key.filename, commit.getTree().getId());
+ continue;
+ }
+ ObjectId id = tw.getObjectId(0);
+ Text src = new Text(repo.open(id, Constants.OBJ_BLOB));
+ Range r = range.get();
+ ImmutableMap.Builder<Integer, String> context =
+ ImmutableMap.builderWithExpectedSize(r.end() - r.start());
+ for (int i = r.start(); i < r.end(); i++) {
+ context.put(i, src.getString(i - 1));
+ }
+ result.put(comment, CommentContext.create(context.build()));
+ }
+ }
+ }
+ return result.build();
+ } catch (IOException e) {
+ throw new StorageException("Failed to load the comment context", e);
+ }
+ }
+
+ private static Optional<Range> getStartAndEndLines(Comment comment) {
+ if (comment.range != null) {
+ return Optional.of(Range.create(comment.range.startLine, comment.range.endLine + 1));
+ } else if (comment.lineNbr > 0) {
+ return Optional.of(Range.create(comment.lineNbr, comment.lineNbr + 1));
+ }
+ return Optional.empty();
+ }
+
+ @AutoValue
+ abstract static class Range {
+ static Range create(int start, int end) {
+ return new AutoValue_CommentContextLoader_Range(start, end);
+ }
+
+ /** Start line of the comment (inclusive). */
+ abstract int start();
+
+ /** End line of the comment (exclusive). */
+ abstract int end();
+ }
+}
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index 0b3d1cb..b752791 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -31,12 +31,12 @@
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.RobotComment;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.common.CommentInfo;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.GerritServerId;
import com.google.gerrit.server.git.GitRepositoryManager;
@@ -48,12 +48,15 @@
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
+import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
/** Utility functions to manipulate Comments. */
@Singleton
@@ -108,24 +111,30 @@
private final GitRepositoryManager repoManager;
private final AllUsersName allUsers;
private final String serverId;
+ private final PatchListCache patchListCache;
@Inject
CommentsUtil(
- GitRepositoryManager repoManager, AllUsersName allUsers, @GerritServerId String serverId) {
+ GitRepositoryManager repoManager,
+ AllUsersName allUsers,
+ @GerritServerId String serverId,
+ PatchListCache patchListCache) {
this.repoManager = repoManager;
this.allUsers = allUsers;
this.serverId = serverId;
+ this.patchListCache = patchListCache;
}
public HumanComment newHumanComment(
- ChangeContext ctx,
+ ChangeNotes changeNotes,
+ CurrentUser currentUser,
+ Timestamp when,
String path,
PatchSet.Id psId,
short side,
String message,
@Nullable Boolean unresolved,
- @Nullable String parentUuid)
- throws UnprocessableEntityException {
+ @Nullable String parentUuid) {
if (unresolved == null) {
if (parentUuid == null) {
// Default to false if comment is not descended from another.
@@ -133,24 +142,24 @@
} else {
// Inherit unresolved value from inReplyTo comment if not specified.
Comment.Key key = new Comment.Key(parentUuid, path, psId.get());
- Optional<HumanComment> parent = getPublishedHumanComment(ctx.getNotes(), key);
- if (!parent.isPresent()) {
- throw new UnprocessableEntityException("Invalid parentUuid supplied for comment");
- }
- unresolved = parent.get().unresolved;
+ Optional<HumanComment> parent = getPublishedHumanComment(changeNotes, key);
+
+ // If the comment was not found, it is descended from a robot comment, or the UUID is
+ // invalid. Either way, we use the default.
+ unresolved = parent.map(p -> p.unresolved).orElse(false);
}
}
HumanComment c =
new HumanComment(
new Comment.Key(ChangeUtil.messageUuid(), path, psId.get()),
- ctx.getUser().getAccountId(),
- ctx.getWhen(),
+ currentUser.getAccountId(),
+ when,
side,
message,
serverId,
unresolved);
c.parentUuid = parentUuid;
- ctx.getUser().updateRealAccountId(c::setRealAuthor);
+ currentUser.updateRealAccountId(c::setRealAuthor);
return c;
}
@@ -182,6 +191,12 @@
.findFirst();
}
+ public Optional<HumanComment> getPublishedHumanComment(ChangeNotes notes, String uuid) {
+ return publishedHumanCommentsByChange(notes).stream()
+ .filter(c -> c.key.uuid.equals(uuid))
+ .findFirst();
+ }
+
public Optional<HumanComment> getDraft(ChangeNotes notes, IdentifiedUser user, Comment.Key key) {
return draftByChangeAuthor(notes, user.getAccountId()).stream()
.filter(c -> key.equals(c.key))
@@ -198,6 +213,10 @@
return sort(Lists.newArrayList(notes.getRobotComments().values()));
}
+ public Optional<RobotComment> getRobotComment(ChangeNotes notes, String uuid) {
+ return robotCommentsByChange(notes).stream().filter(c -> c.key.uuid.equals(uuid)).findFirst();
+ }
+
public List<HumanComment> draftByChange(ChangeNotes notes) {
List<HumanComment> comments = new ArrayList<>();
for (Ref ref : getDraftRefs(notes.getChangeId())) {
@@ -360,23 +379,63 @@
return sort(result);
}
- public static void setCommentCommitId(Comment c, PatchListCache cache, Change change, PatchSet ps)
- throws PatchListNotAvailableException {
+ public void setCommentCommitId(Comment c, Change change, PatchSet ps) {
checkArgument(
c.key.patchSetId == ps.id().get(),
"cannot set commit ID for patch set %s on comment %s",
ps.id(),
c);
if (c.getCommitId() == null) {
- if (Side.fromShort(c.side) == Side.PARENT) {
- if (c.side < 0) {
- c.setCommitId(cache.getOldId(change, ps, -c.side));
- } else {
- c.setCommitId(cache.getOldId(change, ps, null));
- }
- } else {
- c.setCommitId(ps.commitId());
+ // This code is very much down into our stack and shouldn't be used for validation. Hence,
+ // don't throw an exception here if we can't find a commit for the indicated side but
+ // simply use the all-null ObjectId.
+ c.setCommitId(determineCommitId(change, ps, c.side).orElseGet(ObjectId::zeroId));
+ }
+ }
+
+ /**
+ * Determines the SHA-1 of the commit referenced by the (change, patchset, side) triple.
+ *
+ * @param change the change to which the commit belongs
+ * @param patchset the patchset to which the commit belongs
+ * @param side the side indicating which commit of the patchset to take. 1 is the patchset commit,
+ * 0 the parent commit (or auto-merge for changes representing merge commits); -x the xth
+ * parent commit of a merge commit
+ * @return the commit SHA-1 or an empty {@link Optional} if the side isn't available for the given
+ * change/patchset
+ * @throws StorageException if the SHA-1 is unavailable for an unknown reason
+ */
+ public Optional<ObjectId> determineCommitId(Change change, PatchSet patchset, short side) {
+ if (Side.fromShort(side) == Side.PARENT) {
+ if (side < 0) {
+ int parentNumber = Math.abs(side);
+ return resolveParentCommit(change.getProject(), patchset, parentNumber);
}
+ return Optional.of(resolveAutoMergeCommit(change, patchset));
+ }
+ return Optional.of(patchset.commitId());
+ }
+
+ private Optional<ObjectId> resolveParentCommit(
+ Project.NameKey project, PatchSet patchset, int parentNumber) {
+ try (Repository repository = repoManager.openRepository(project)) {
+ RevCommit commit = repository.parseCommit(patchset.commitId());
+ if (commit.getParentCount() < parentNumber) {
+ return Optional.empty();
+ }
+ return Optional.of(commit.getParent(parentNumber - 1));
+ } catch (IOException e) {
+ throw new StorageException(e);
+ }
+ }
+
+ private ObjectId resolveAutoMergeCommit(Change change, PatchSet patchset) {
+ try {
+ // TODO(ghareeb): Adjust after the auto-merge code was moved out of the diff caches. Also
+ // unignore the test in PortedCommentsIT.
+ return patchListCache.getOldId(change, patchset, null);
+ } catch (PatchListNotAvailableException e) {
+ throw new StorageException(e);
}
}
diff --git a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
index 0e593b7..6c76de7 100644
--- a/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
+++ b/java/com/google/gerrit/server/CreateGroupPermissionSyncer.java
@@ -16,9 +16,9 @@
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.events.ChangeMergedListener;
import com.google.gerrit.server.config.AllProjectsName;
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 75afc04..825b34f 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -14,7 +14,6 @@
package com.google.gerrit.server;
-import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.server.account.GroupMembership;
import com.google.gerrit.server.account.externalids.ExternalId;
@@ -31,17 +30,19 @@
* @see IdentifiedUser
*/
public abstract class CurrentUser {
- /** Unique key for plugin/extension specific data on a CurrentUser. */
- public static final class PropertyKey<T> {
- public static <T> PropertyKey<T> create() {
- return new PropertyKey<>();
- }
+ public static final PropertyMap.Key<ExternalId.Key> LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY =
+ PropertyMap.key();
- private PropertyKey() {}
+ private final PropertyMap properties;
+ private AccessPath accessPath = AccessPath.UNKNOWN;
+
+ protected CurrentUser() {
+ this.properties = PropertyMap.EMPTY;
}
- private AccessPath accessPath = AccessPath.UNKNOWN;
- private PropertyKey<ExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
+ protected CurrentUser(PropertyMap properties) {
+ this.properties = properties;
+ }
/** How this user is accessing the Gerrit Code Review application. */
public final AccessPath getAccessPath() {
@@ -133,29 +134,18 @@
}
/**
- * Lookup a previously stored property.
+ * Lookup a stored property.
*
- * @param key unique property key.
- * @return previously stored value, or {@code Optional#empty()}.
+ * @param key unique property key. This key has to be the same instance that was used to store the
+ * value when constructing the {@link PropertyMap}
+ * @return stored value, or {@code Optional#empty()}.
*/
- public <T> Optional<T> get(PropertyKey<T> key) {
- return Optional.empty();
- }
-
- /**
- * Store a property for later retrieval.
- *
- * @param key unique property key.
- * @param value value to store; or {@code null} to clear the value.
- */
- public <T> void put(PropertyKey<T> key, @Nullable T value) {}
-
- public void setLastLoginExternalIdKey(ExternalId.Key externalIdKey) {
- put(lastLoginExternalIdPropertyKey, externalIdKey);
+ public <T> Optional<T> get(PropertyMap.Key<T> key) {
+ return properties.get(key);
}
public Optional<ExternalId.Key> getLastLoginExternalIdKey() {
- return get(lastLoginExternalIdPropertyKey);
+ return get(LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY);
}
/**
diff --git a/java/com/google/gerrit/server/DynamicOptions.java b/java/com/google/gerrit/server/DynamicOptions.java
index 41dc082..1d36ff0 100644
--- a/java/com/google/gerrit/server/DynamicOptions.java
+++ b/java/com/google/gerrit/server/DynamicOptions.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server;
import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.lifecycle.LifecycleManager;
import com.google.gerrit.server.plugins.DelegatingClassLoader;
import com.google.gerrit.util.cli.CmdLineParser;
import com.google.inject.Injector;
@@ -29,7 +30,7 @@
import java.util.WeakHashMap;
/** Helper class to define and parse options from plugins on ssh and RestAPI commands. */
-public class DynamicOptions {
+public class DynamicOptions implements AutoCloseable {
/**
* To provide additional options, bind a DynamicBean. For example:
*
@@ -98,7 +99,9 @@
*
* <p>Do this by binding to the name of the command you are going to bind to and providing an
* Iterable of Module names to instantiate and add to the Injector used to instantiate the
- * DynamicBean in the other classLoader. For example:
+ * DynamicBean in the other classLoader. This interface supports running LifecycleListeners which
+ * are defined by the Modules being provided. The duration of the lifecycle starts when a ssh or
+ * http request starts and ends when the request completes. For example:
*
* <pre>
* bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
@@ -106,7 +109,7 @@
* "com.google.gerrit.plugins.otherplugin.command"))
* .to(MyOptionsModulesClassNamesProvider.class);
*
- * static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ClassNameProvider {
+ * static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ModulesClassNamesProvider {
* {@literal @}Override
* public String getClassName() {
* return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
@@ -190,6 +193,7 @@
protected Object bean;
protected Map<String, DynamicBean> beansByPlugin;
protected Injector injector;
+ protected LifecycleManager lifecycleManager;
/**
* Internal: For Gerrit to include options from DynamicBeans, setup a DynamicMap and instantiate
@@ -209,6 +213,7 @@
public DynamicOptions(Object bean, Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
this.bean = bean;
this.injector = injector;
+ lifecycleManager = new LifecycleManager();
beansByPlugin = new HashMap<>();
Class<?> beanClass =
(bean instanceof BeanReceiver)
@@ -221,6 +226,7 @@
beansByPlugin.put(plugin, getDynamicBean(bean, provider.get()));
}
}
+ startLifecycleListeners();
}
@SuppressWarnings("unchecked")
@@ -255,9 +261,10 @@
modules.add(modulesInjector.getInstance(mClass));
}
}
- return modulesInjector
- .createChildInjector(modules)
- .getInstance((Class<DynamicOptions.DynamicBean>) loader.loadClass(className));
+ Injector childModulesInjector = modulesInjector.createChildInjector(modules);
+ lifecycleManager.add(childModulesInjector);
+ return childModulesInjector.getInstance(
+ (Class<DynamicOptions.DynamicBean>) loader.loadClass(className));
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
@@ -300,6 +307,14 @@
}
}
+ public void startLifecycleListeners() {
+ lifecycleManager.start();
+ }
+
+ public void stopLifecycleListeners() {
+ lifecycleManager.stop();
+ }
+
public void onBeanParseStart() {
for (Map.Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
DynamicBean instance = e.getValue();
@@ -319,4 +334,9 @@
}
}
}
+
+ @Override
+ public void close() {
+ stopLifecycleListeners();
+ }
}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 7cafdc0..75c7cda 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -17,11 +17,13 @@
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.flogger.LazyArgs.lazy;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Account;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountState;
@@ -46,8 +48,6 @@
import java.net.SocketAddress;
import java.net.URL;
import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TimeZone;
@@ -105,12 +105,26 @@
return create(null, id);
}
+ @VisibleForTesting
+ @UsedAt(UsedAt.Project.GOOGLE)
+ public IdentifiedUser forTest(Account.Id id, PropertyMap properties) {
+ return runAs(null, id, null, properties);
+ }
+
public IdentifiedUser create(SocketAddress remotePeer, Account.Id id) {
return runAs(remotePeer, id, null);
}
public IdentifiedUser runAs(
SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
+ return runAs(remotePeer, id, caller, PropertyMap.EMPTY);
+ }
+
+ private IdentifiedUser runAs(
+ SocketAddress remotePeer,
+ Account.Id id,
+ @Nullable CurrentUser caller,
+ PropertyMap properties) {
return new IdentifiedUser(
authConfig,
realm,
@@ -121,7 +135,8 @@
enableReverseDnsLookup,
Providers.of(remotePeer),
id,
- caller);
+ caller,
+ properties);
}
}
@@ -163,20 +178,10 @@
}
public IdentifiedUser create(Account.Id id) {
- return new IdentifiedUser(
- authConfig,
- realm,
- anonymousCowardName,
- canonicalUrl,
- accountCache,
- groupBackend,
- enableReverseDnsLookup,
- remotePeerProvider,
- id,
- null);
+ return create(id, PropertyMap.EMPTY);
}
- public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
+ public <T> IdentifiedUser create(Account.Id id, PropertyMap properties) {
return new IdentifiedUser(
authConfig,
realm,
@@ -187,7 +192,23 @@
enableReverseDnsLookup,
remotePeerProvider,
id,
- caller);
+ null,
+ properties);
+ }
+
+ public IdentifiedUser runAs(Account.Id id, CurrentUser caller, PropertyMap properties) {
+ return new IdentifiedUser(
+ authConfig,
+ realm,
+ anonymousCowardName,
+ canonicalUrl,
+ accountCache,
+ groupBackend,
+ enableReverseDnsLookup,
+ remotePeerProvider,
+ id,
+ caller,
+ properties);
}
}
@@ -212,7 +233,6 @@
private boolean loadedAllEmails;
private Set<String> invalidEmails;
private GroupMembership effectiveGroups;
- private Map<PropertyKey<Object>, Object> properties;
private IdentifiedUser(
AuthConfig authConfig,
@@ -235,7 +255,8 @@
enableReverseDnsLookup,
remotePeerProvider,
state.account().id(),
- realUser);
+ realUser,
+ PropertyMap.EMPTY);
this.state = state;
}
@@ -249,7 +270,9 @@
Boolean enableReverseDnsLookup,
@Nullable Provider<SocketAddress> remotePeerProvider,
Account.Id id,
- @Nullable CurrentUser realUser) {
+ @Nullable CurrentUser realUser,
+ PropertyMap properties) {
+ super(properties);
this.canonicalUrl = canonicalUrl;
this.accountCache = accountCache;
this.groupBackend = groupBackend;
@@ -463,40 +486,6 @@
return true;
}
- @Override
- public synchronized <T> Optional<T> get(PropertyKey<T> key) {
- if (properties != null) {
- @SuppressWarnings("unchecked")
- T value = (T) properties.get(key);
- return Optional.ofNullable(value);
- }
- return Optional.empty();
- }
-
- /**
- * Store a property for later retrieval.
- *
- * @param key unique property key.
- * @param value value to store; or {@code null} to clear the value.
- */
- @Override
- public synchronized <T> void put(PropertyKey<T> key, @Nullable T value) {
- if (properties == null) {
- if (value == null) {
- return;
- }
- properties = new HashMap<>();
- }
-
- @SuppressWarnings("unchecked")
- PropertyKey<Object> k = (PropertyKey<Object>) key;
- if (value != null) {
- properties.put(k, value);
- } else {
- properties.remove(k);
- }
- }
-
/**
* Returns a materialized copy of the user with all dependencies.
*
diff --git a/java/com/google/gerrit/server/ModuleOverloader.java b/java/com/google/gerrit/server/ModuleOverloader.java
index 9a8fe84..6b7b082 100644
--- a/java/com/google/gerrit/server/ModuleOverloader.java
+++ b/java/com/google/gerrit/server/ModuleOverloader.java
@@ -42,7 +42,7 @@
return modules;
}
- // swipe cache implementation with alternative provided in lib
+ // swap module implementations with the matching alternative ones provided in lib
return modules.stream()
.map(
m -> {
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index aeef2b6..005ae3b 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -22,9 +22,9 @@
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
diff --git a/java/com/google/gerrit/server/PropertyMap.java b/java/com/google/gerrit/server/PropertyMap.java
new file mode 100644
index 0000000..da3a2495
--- /dev/null
+++ b/java/com/google/gerrit/server/PropertyMap.java
@@ -0,0 +1,84 @@
+// 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;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Optional;
+
+/**
+ * Immutable map that holds a collection of random objects allowing for a type-safe retrieval.
+ *
+ * <p>Intended to be used in {@link CurrentUser} when the object is constructed during login and
+ * holds per-request state. This functionality allows plugins/extensions to contribute specific data
+ * to {@link CurrentUser} that is unknown to Gerrit core.
+ */
+public class PropertyMap {
+ /** Empty instance to be referenced once per JVM. */
+ public static final PropertyMap EMPTY = builder().build();
+
+ /**
+ * Typed key for {@link PropertyMap}. This class intentionally does not implement {@link
+ * Object#equals(Object)} and {@link Object#hashCode()} so that the same instance has to be used
+ * to retrieve a stored value.
+ *
+ * <p>We require the exact same key instance because {@link PropertyMap} is implemented in a
+ * type-safe fashion by using Java generics to guarantee the return type. The generic type can't
+ * be recovered at runtime, so there is no way to just use the type's full name as key - we'd have
+ * to pass additional arguments. At the same time, this is in-line with how we'd want callers to
+ * use {@link PropertyMap}: Instantiate a static, per-JVM key that is reused when setting and
+ * getting values.
+ */
+ public static class Key<T> {}
+
+ public static <T> Key<T> key() {
+ return new Key<>();
+ }
+
+ public static class Builder {
+ private ImmutableMap.Builder<Object, Object> mutableMap;
+
+ private Builder() {
+ this.mutableMap = ImmutableMap.builder();
+ }
+
+ /** Adds the provided {@code value} to the {@link PropertyMap} that is being built. */
+ public <T> Builder put(Key<T> key, T value) {
+ mutableMap.put(key, value);
+ return this;
+ }
+
+ /** Builds and returns an immutable {@link PropertyMap}. */
+ public PropertyMap build() {
+ return new PropertyMap(mutableMap.build());
+ }
+ }
+
+ private final ImmutableMap<Object, Object> map;
+
+ private PropertyMap(ImmutableMap<Object, Object> map) {
+ this.map = map;
+ }
+
+ /** Returns a new {@link Builder} instance. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /** Returns the requested value wrapped as {@link Optional}. */
+ @SuppressWarnings("unchecked")
+ public <T> Optional<T> get(Key<T> key) {
+ return Optional.ofNullable((T) map.get(key));
+ }
+}
diff --git a/java/com/google/gerrit/server/PublishCommentUtil.java b/java/com/google/gerrit/server/PublishCommentUtil.java
index 658af15..4d19dd0 100644
--- a/java/com/google/gerrit/server/PublishCommentUtil.java
+++ b/java/com/google/gerrit/server/PublishCommentUtil.java
@@ -22,15 +22,12 @@
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.validators.CommentForValidation;
import com.google.gerrit.extensions.validators.CommentValidationContext;
import com.google.gerrit.extensions.validators.CommentValidationFailure;
import com.google.gerrit.extensions.validators.CommentValidator;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.plugincontext.PluginSetContext;
import com.google.gerrit.server.update.ChangeContext;
import com.google.inject.Inject;
@@ -44,16 +41,13 @@
public class PublishCommentUtil {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private final PatchListCache patchListCache;
private final PatchSetUtil psUtil;
private final CommentsUtil commentsUtil;
@Inject
- PublishCommentUtil(
- CommentsUtil commentsUtil, PatchListCache patchListCache, PatchSetUtil psUtil) {
+ PublishCommentUtil(CommentsUtil commentsUtil, PatchSetUtil psUtil) {
this.commentsUtil = commentsUtil;
this.psUtil = psUtil;
- this.patchListCache = patchListCache;
}
public void publish(
@@ -101,11 +95,7 @@
// Draft may have been created by a different real user; copy the current real user. (Only
// applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
ctx.getUser().updateRealAccountId(draftComment::setRealAuthor);
- try {
- CommentsUtil.setCommentCommitId(draftComment, patchListCache, notes.getChange(), ps);
- } catch (PatchListNotAvailableException e) {
- throw new StorageException(e);
- }
+ commentsUtil.setCommentCommitId(draftComment, notes.getChange(), ps);
commentsToPublish.add(draftComment);
}
commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, commentsToPublish);
diff --git a/java/com/google/gerrit/server/account/AccountControl.java b/java/com/google/gerrit/server/account/AccountControl.java
index a6143f4..3f7f3f2 100644
--- a/java/com/google/gerrit/server/account/AccountControl.java
+++ b/java/com/google/gerrit/server/account/AccountControl.java
@@ -17,9 +17,11 @@
import static java.util.stream.Collectors.toSet;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.exceptions.NoSuchGroupException;
import com.google.gerrit.extensions.common.AccountVisibility;
import com.google.gerrit.extensions.restapi.AuthException;
@@ -29,7 +31,6 @@
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.project.AccountsSection;
import com.google.gerrit.server.project.ProjectCache;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -63,6 +64,10 @@
this.accountVisibility = accountVisibility;
}
+ /**
+ * Creates a {@link AccountControl} instance that checks whether the current user can see other
+ * accounts.
+ */
public AccountControl get() {
return new AccountControl(
permissionBackend,
@@ -72,6 +77,21 @@
userFactory,
accountVisibility);
}
+
+ /**
+ * Creates a {@link AccountControl} instance that checks whether the given user can see other
+ * accounts.
+ */
+ @UsedAt(UsedAt.Project.PLUGIN_CODE_OWNERS)
+ public AccountControl get(IdentifiedUser identifiedUser) {
+ return new AccountControl(
+ permissionBackend,
+ projectCache,
+ groupControlFactory,
+ identifiedUser,
+ userFactory,
+ accountVisibility);
+ }
}
private final AccountsSection accountsSection;
diff --git a/java/com/google/gerrit/server/account/AccountDirectory.java b/java/com/google/gerrit/server/account/AccountDirectory.java
index 63fa551..98b2ca9 100644
--- a/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -51,7 +51,10 @@
STATE,
/** Human friendly display name presented in the web interface chosen by the user. */
- DISPLAY_NAME
+ DISPLAY_NAME,
+
+ /** Tags such as weather the account is a service user. */
+ TAGS
}
public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index 4d1d1b8..1845f5b 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -15,8 +15,8 @@
package com.google.gerrit.server.account;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.index.query.QueryProcessor;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.git.QueueProvider;
diff --git a/java/com/google/gerrit/server/account/AccountLoader.java b/java/com/google/gerrit/server/account/AccountLoader.java
index 9acf078..c260401 100644
--- a/java/com/google/gerrit/server/account/AccountLoader.java
+++ b/java/com/google/gerrit/server/account/AccountLoader.java
@@ -44,7 +44,8 @@
FillOptions.DISPLAY_NAME,
FillOptions.STATUS,
FillOptions.STATE,
- FillOptions.AVATARS));
+ FillOptions.AVATARS,
+ FillOptions.TAGS));
public interface Factory {
AccountLoader create(boolean detailed);
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index cb9412c..47c6efb 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -24,11 +24,11 @@
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.exceptions.NoSuchGroupException;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.client.AccountFieldName;
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index ebceded..f861ea7 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -567,6 +567,26 @@
input, nameOrEmailSearchers, visibilitySupplierCanSee(), accountActivityPredicate());
}
+ /**
+ * Same as {@link #resolveByNameOrEmail(String)}, but with exact matching for the full name, email
+ * and full name.
+ *
+ * @param input input string.
+ * @return a result describing matching accounts. Never null even if the result set is empty.
+ * @throws ConfigInvalidException if an error occurs.
+ * @throws IOException if an error occurs.
+ * @deprecated for use only by MailUtil for parsing commit footers; that class needs to be
+ * reevaluated.
+ */
+ @Deprecated
+ public Result resolveByExactNameOrEmail(String input) throws ConfigInvalidException, IOException {
+ return searchImpl(
+ input,
+ ImmutableList.of(new ByNameAndEmail(), new ByEmail(), new ByFullName(), new ByUsername()),
+ visibilitySupplierCanSee(),
+ accountActivityPredicate());
+ }
+
private Supplier<Predicate<AccountState>> visibilitySupplierCanSee() {
return () -> accountControlFactory.get()::canSee;
}
diff --git a/java/com/google/gerrit/server/account/CapabilityCollection.java b/java/com/google/gerrit/server/account/CapabilityCollection.java
index c1a8f73..7621929 100644
--- a/java/com/google/gerrit/server/account/CapabilityCollection.java
+++ b/java/com/google/gerrit/server/account/CapabilityCollection.java
@@ -18,12 +18,12 @@
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.server.config.AdministrateServerGroups;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.inject.Inject;
@@ -62,7 +62,6 @@
if (section == null) {
section = AccessSection.create(AccessSection.GLOBAL_CAPABILITIES);
}
-
Map<String, List<PermissionRule>> tmp = new HashMap<>();
for (Permission permission : section.getPermissions()) {
for (PermissionRule rule : permission.getRules()) {
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 3137c95..13b71cf 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -18,6 +18,7 @@
import static java.util.stream.Collectors.toSet;
import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.Sets;
import com.google.common.collect.Streams;
import com.google.gerrit.entities.Account;
@@ -61,6 +62,7 @@
private final IdentifiedUser.GenericFactory userFactory;
private final Provider<CurrentUser> self;
private final PermissionBackend permissionBackend;
+ private final ServiceUserClassifier serviceUserClassifier;
@Inject
InternalAccountDirectory(
@@ -68,12 +70,14 @@
DynamicItem<AvatarProvider> avatar,
IdentifiedUser.GenericFactory userFactory,
Provider<CurrentUser> self,
- PermissionBackend permissionBackend) {
+ PermissionBackend permissionBackend,
+ ServiceUserClassifier serviceUserClassifier) {
this.accountCache = accountCache;
this.avatar = avatar;
this.userFactory = userFactory;
this.self = self;
this.permissionBackend = permissionBackend;
+ this.serviceUserClassifier = serviceUserClassifier;
}
@Override
@@ -155,6 +159,13 @@
info.inactive = account.inactive() ? true : null;
}
+ if (options.contains(FillOptions.TAGS)) {
+ info.tags =
+ serviceUserClassifier.isServiceUser(account.id())
+ ? ImmutableList.of(AccountInfo.Tag.SERVICE_USER)
+ : null;
+ }
+
if (options.contains(FillOptions.AVATARS)) {
AvatarProvider ap = avatar.get();
if (ap != null) {
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifier.java b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
new file mode 100644
index 0000000..2d2a646
--- /dev/null
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifier.java
@@ -0,0 +1,35 @@
+// 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.account;
+
+import com.google.gerrit.entities.Account;
+
+public interface ServiceUserClassifier {
+ /**
+ * Name of the Service Users group used by this class to determine whether an account is a service
+ * user; if an account is a part of this group, that account is considered a service user.
+ */
+ public static final String SERVICE_USERS = "Service Users";
+ /** Returns {@code true} if the given user is considered a {@code Service User} user. */
+ boolean isServiceUser(Account.Id user);
+
+ /** An instance that can be used for testing and will consider no user to be a Service User. */
+ class NoOp implements ServiceUserClassifier {
+ @Override
+ public boolean isServiceUser(Account.Id user) {
+ return false;
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
new file mode 100644
index 0000000..3ee2c54
--- /dev/null
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -0,0 +1,103 @@
+// 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.account;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import javax.inject.Inject;
+
+/**
+ * An implementation of {@link ServiceUserClassifier} that will consider a user to be a robot if
+ * they are a member in the {@code Service Users} group.
+ */
+@Singleton
+public class ServiceUserClassifierImpl implements ServiceUserClassifier {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ public static Module module() {
+ return new AbstractModule() {
+ @Override
+ protected void configure() {
+ bind(ServiceUserClassifier.class).to(ServiceUserClassifierImpl.class).in(Scopes.SINGLETON);
+ }
+ };
+ }
+
+ private final GroupCache groupCache;
+ private final InternalGroupBackend internalGroupBackend;
+ private final IdentifiedUser.GenericFactory identifiedUserFactory;
+
+ @Inject
+ ServiceUserClassifierImpl(
+ GroupCache groupCache,
+ InternalGroupBackend internalGroupBackend,
+ IdentifiedUser.GenericFactory identifiedUserFactory) {
+ this.groupCache = groupCache;
+ this.internalGroupBackend = internalGroupBackend;
+ this.identifiedUserFactory = identifiedUserFactory;
+ }
+
+ @Override
+ public boolean isServiceUser(Account.Id user) {
+ Optional<InternalGroup> maybeGroup = groupCache.get(AccountGroup.nameKey(SERVICE_USERS));
+ if (!maybeGroup.isPresent()) {
+ return false;
+ }
+ List<AccountGroup.UUID> toTraverse = new ArrayList<>();
+ toTraverse.add(maybeGroup.get().getGroupUUID());
+ Set<AccountGroup.UUID> seen = new HashSet<>();
+ while (!toTraverse.isEmpty()) {
+ InternalGroup currentGroup =
+ groupCache
+ .get(toTraverse.remove(0))
+ .orElseThrow(() -> new IllegalStateException("invalid subgroup"));
+ if (seen.contains(currentGroup.getGroupUUID())) {
+ logger.atWarning().log(
+ "Skipping %s because it's a cyclic subgroup", currentGroup.getGroupUUID());
+ continue;
+ }
+ seen.add(currentGroup.getGroupUUID());
+ if (currentGroup.getMembers().contains(user)) {
+ // The user is a member of the 'Service Users' group or a subgroup.
+ return true;
+ }
+ boolean hasExternalSubgroup =
+ currentGroup.getSubgroups().stream().anyMatch(g -> !internalGroupBackend.handles(g));
+ if (hasExternalSubgroup) {
+ // 'Service Users or a subgroup of Service User' contains an external subgroup, so we have
+ // to default to the more expensive evaluation of getting all of the user's group
+ // memberships.
+ return identifiedUserFactory
+ .create(user)
+ .getEffectiveGroups()
+ .contains(maybeGroup.get().getGroupUUID());
+ }
+ toTraverse.addAll(currentGroup.getSubgroups());
+ }
+ return false;
+ }
+}
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index b4a5da7..0992bcd 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -158,7 +158,7 @@
private final GetAssignee getAssignee;
private final GetPastAssignees getPastAssignees;
private final DeleteAssignee deleteAssignee;
- private final ListChangeComments listComments;
+ private final Provider<ListChangeComments> listCommentsProvider;
private final ListChangeRobotComments listChangeRobotComments;
private final ListChangeDrafts listDrafts;
private final ChangeEditApiImpl.Factory changeEditApi;
@@ -211,7 +211,7 @@
GetAssignee getAssignee,
GetPastAssignees getPastAssignees,
DeleteAssignee deleteAssignee,
- ListChangeComments listComments,
+ Provider<ListChangeComments> listCommentsProvider,
ListChangeRobotComments listChangeRobotComments,
ListChangeDrafts listDrafts,
ChangeEditApiImpl.Factory changeEditApi,
@@ -262,7 +262,7 @@
this.getAssignee = getAssignee;
this.getPastAssignees = getPastAssignees;
this.deleteAssignee = deleteAssignee;
- this.listComments = listComments;
+ this.listCommentsProvider = listCommentsProvider;
this.listChangeRobotComments = listChangeRobotComments;
this.listDrafts = listDrafts;
this.changeEditApi = changeEditApi;
@@ -599,21 +599,30 @@
}
@Override
- public Map<String, List<CommentInfo>> comments() throws RestApiException {
- try {
- return listComments.apply(change).value();
- } catch (Exception e) {
- throw asRestApiException("Cannot get comments", e);
- }
- }
+ public CommentsRequest commentsRequest() throws RestApiException {
+ return new CommentsRequest() {
+ @Override
+ public Map<String, List<CommentInfo>> get() throws RestApiException {
+ try {
+ ListChangeComments listComments = listCommentsProvider.get();
+ listComments.setContext(this.getContext());
+ return listComments.apply(change).value();
+ } catch (Exception e) {
+ throw asRestApiException("Cannot get comments", e);
+ }
+ }
- @Override
- public List<CommentInfo> commentsAsList() throws RestApiException {
- try {
- return listComments.getComments(change);
- } catch (Exception e) {
- throw asRestApiException("Cannot get comments", e);
- }
+ @Override
+ public List<CommentInfo> getAsList() throws RestApiException {
+ try {
+ ListChangeComments listComments = listCommentsProvider.get();
+ listComments.setContext(this.getContext());
+ return listComments.getComments(change);
+ } catch (Exception e) {
+ throw asRestApiException("Cannot get comments", e);
+ }
+ }
+ };
}
@Override
diff --git a/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index c506b2e..cf9f243 100644
--- a/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -123,9 +123,6 @@
if (r.getBase() != null) {
getDiff.setBase(r.getBase());
}
- if (r.getContext() != null) {
- getDiff.setContext(r.getContext());
- }
if (r.getIntraline() != null) {
getDiff.setIntraline(r.getIntraline());
}
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index b515dfe..04d2e8ae 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -79,6 +79,8 @@
import com.google.gerrit.server.restapi.change.GetPatch;
import com.google.gerrit.server.restapi.change.GetRelated;
import com.google.gerrit.server.restapi.change.GetRevisionActions;
+import com.google.gerrit.server.restapi.change.ListPortedComments;
+import com.google.gerrit.server.restapi.change.ListPortedDrafts;
import com.google.gerrit.server.restapi.change.ListRevisionComments;
import com.google.gerrit.server.restapi.change.ListRevisionDrafts;
import com.google.gerrit.server.restapi.change.ListRobotComments;
@@ -130,6 +132,8 @@
private final FileApiImpl.Factory fileApi;
private final ListRevisionComments listComments;
private final ListRobotComments listRobotComments;
+ private final ListPortedComments listPortedComments;
+ private final ListPortedDrafts listPortedDrafts;
private final ApplyFix applyFix;
private final GetFixPreview getFixPreview;
private final Fixes fixes;
@@ -175,6 +179,8 @@
FileApiImpl.Factory fileApi,
ListRevisionComments listComments,
ListRobotComments listRobotComments,
+ ListPortedComments listPortedComments,
+ ListPortedDrafts listPortedDrafts,
ApplyFix applyFix,
GetFixPreview getFixPreview,
Fixes fixes,
@@ -219,6 +225,8 @@
this.listComments = listComments;
this.robotComments = robotComments;
this.listRobotComments = listRobotComments;
+ this.listPortedComments = listPortedComments;
+ this.listPortedDrafts = listPortedDrafts;
this.applyFix = applyFix;
this.getFixPreview = getFixPreview;
this.fixes = fixes;
@@ -454,6 +462,24 @@
}
@Override
+ public Map<String, List<CommentInfo>> portedComments() throws RestApiException {
+ try {
+ return listPortedComments.apply(revision).value();
+ } catch (Exception e) {
+ throw asRestApiException("Cannot retrieve ported comments", e);
+ }
+ }
+
+ @Override
+ public Map<String, List<CommentInfo>> portedDrafts() throws RestApiException {
+ try {
+ return listPortedDrafts.apply(revision).value();
+ } catch (Exception e) {
+ throw asRestApiException("Cannot retrieve ported draft comments", e);
+ }
+ }
+
+ @Override
public EditInfo applyFix(String fixId) throws RestApiException {
try {
return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
diff --git a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
index 3bb88e5..03ecd91 100644
--- a/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
+++ b/java/com/google/gerrit/server/auth/oauth/OAuthTokenCache.java
@@ -40,15 +40,32 @@
private final DynamicItem<OAuthTokenEncrypter> encrypter;
+ public enum AccountIdSerializer implements CacheSerializer<Account.Id> {
+ INSTANCE;
+
+ private final Converter<Account.Id, Integer> converter =
+ Converter.from(Account.Id::get, Account::id);
+
+ private final Converter<Integer, Account.Id> reverse = converter.reverse();
+
+ @Override
+ public byte[] serialize(Account.Id object) {
+ return IntegerCacheSerializer.INSTANCE.serialize(converter.convert(object));
+ }
+
+ @Override
+ public Account.Id deserialize(byte[] in) {
+ return reverse.convert(IntegerCacheSerializer.INSTANCE.deserialize(in));
+ }
+ }
+
public static Module module() {
return new CacheModule() {
@Override
protected void configure() {
persist(OAUTH_TOKENS, Account.Id.class, OAuthToken.class)
.version(1)
- .keySerializer(
- CacheSerializer.convert(
- IntegerCacheSerializer.INSTANCE, Converter.from(Account.Id::get, Account::id)))
+ .keySerializer(AccountIdSerializer.INSTANCE)
.valueSerializer(new Serializer());
}
};
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializer.java
index af5fefd..f572c62 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializer.java
@@ -16,8 +16,8 @@
import static com.google.common.collect.ImmutableList.toImmutableList;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.server.cache.proto.Cache;
/** Helper to (de)serialize values for caches. */
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/BUILD b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
index 5fd28ec..cb8c4ae 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/java/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -11,6 +11,7 @@
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/git",
"//java/com/google/gerrit/proto",
+ "//java/com/google/gerrit/server/cache/serialize",
"//lib:guava",
"//lib:jgit",
"//lib:protobuf",
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
new file mode 100644
index 0000000..40ef794
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
@@ -0,0 +1,168 @@
+// 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.cache.serialize.entities;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import java.util.Optional;
+
+/** Helper to (de)serialize values for caches. */
+public class CachedProjectConfigSerializer {
+ public static CachedProjectConfig deserialize(Cache.CachedProjectConfigProto proto) {
+ CachedProjectConfig.Builder builder =
+ CachedProjectConfig.builder()
+ .setProject(ProjectSerializer.deserialize(proto.getProject()))
+ .setMaxObjectSizeLimit(proto.getMaxObjectSizeLimit())
+ .setCheckReceivedObjects(proto.getCheckReceivedObjects());
+ if (proto.hasBranchOrderSection()) {
+ builder.setBranchOrderSection(
+ Optional.of(BranchOrderSectionSerializer.deserialize(proto.getBranchOrderSection())));
+ }
+ ImmutableList<ConfiguredMimeTypes.TypeMatcher> matchers =
+ proto.getMimeTypesList().stream()
+ .map(ConfiguredMimeTypeSerializer::deserialize)
+ .collect(toImmutableList());
+ builder.setMimeTypes(ConfiguredMimeTypes.create(matchers));
+ if (!proto.getRulesId().isEmpty()) {
+ builder.setRulesId(
+ Optional.of(ObjectIdConverter.create().fromByteString(proto.getRulesId())));
+ }
+ if (!proto.getRevision().isEmpty()) {
+ builder.setRevision(
+ Optional.of(ObjectIdConverter.create().fromByteString(proto.getRevision())));
+ }
+ proto
+ .getExtensionPanelsMap()
+ .entrySet()
+ .forEach(
+ panelSection -> {
+ builder
+ .extensionPanelSectionsBuilder()
+ .put(
+ panelSection.getKey(),
+ panelSection.getValue().getSectionList().stream().collect(toImmutableList()));
+ });
+ ImmutableList<PermissionRule> accounts =
+ proto.getAccountsSectionList().stream()
+ .map(PermissionRuleSerializer::deserialize)
+ .collect(toImmutableList());
+ builder.setAccountsSection(AccountsSection.create(accounts));
+
+ proto.getGroupListList().stream()
+ .map(GroupReferenceSerializer::deserialize)
+ .forEach(builder::addGroup);
+ proto.getAccessSectionsList().stream()
+ .map(AccessSectionSerializer::deserialize)
+ .forEach(builder::addAccessSection);
+ proto.getContributorAgreementsList().stream()
+ .map(ContributorAgreementSerializer::deserialize)
+ .forEach(builder::addContributorAgreement);
+ proto.getNotifyConfigsList().stream()
+ .map(NotifyConfigSerializer::deserialize)
+ .forEach(builder::addNotifySection);
+ proto.getLabelSectionsList().stream()
+ .map(LabelTypeSerializer::deserialize)
+ .forEach(builder::addLabelSection);
+ proto.getSubscribeSectionsList().stream()
+ .map(SubscribeSectionSerializer::deserialize)
+ .forEach(builder::addSubscribeSection);
+ proto.getCommentLinksList().stream()
+ .map(StoredCommentLinkInfoSerializer::deserialize)
+ .forEach(builder::addCommentLinkSection);
+ proto
+ .getPluginConfigsMap()
+ .entrySet()
+ .forEach(e -> builder.addPluginConfig(e.getKey(), e.getValue()));
+ proto
+ .getProjectLevelConfigsMap()
+ .entrySet()
+ .forEach(e -> builder.addProjectLevelConfig(e.getKey(), e.getValue()));
+
+ return builder.build();
+ }
+
+ public static Cache.CachedProjectConfigProto serialize(CachedProjectConfig autoValue) {
+ Cache.CachedProjectConfigProto.Builder builder =
+ Cache.CachedProjectConfigProto.newBuilder()
+ .setProject(ProjectSerializer.serialize(autoValue.getProject()))
+ .setMaxObjectSizeLimit(autoValue.getMaxObjectSizeLimit())
+ .setCheckReceivedObjects(autoValue.getCheckReceivedObjects());
+
+ if (autoValue.getBranchOrderSection().isPresent()) {
+ builder.setBranchOrderSection(
+ BranchOrderSectionSerializer.serialize(autoValue.getBranchOrderSection().get()));
+ }
+ autoValue.getMimeTypes().matchers().stream()
+ .map(ConfiguredMimeTypeSerializer::serialize)
+ .forEach(builder::addMimeTypes);
+
+ if (autoValue.getRulesId().isPresent()) {
+ builder.setRulesId(ObjectIdConverter.create().toByteString(autoValue.getRulesId().get()));
+ }
+ if (autoValue.getRevision().isPresent()) {
+ builder.setRevision(ObjectIdConverter.create().toByteString(autoValue.getRevision().get()));
+ }
+
+ autoValue
+ .getExtensionPanelSections()
+ .entrySet()
+ .forEach(
+ panelSection -> {
+ builder.putExtensionPanels(
+ panelSection.getKey(),
+ Cache.CachedProjectConfigProto.ExtensionPanelSectionProto.newBuilder()
+ .addAllSection(panelSection.getValue())
+ .build());
+ });
+ autoValue.getAccountsSection().getSameGroupVisibility().stream()
+ .map(PermissionRuleSerializer::serialize)
+ .forEach(builder::addAccountsSection);
+
+ autoValue.getGroups().values().stream()
+ .map(GroupReferenceSerializer::serialize)
+ .forEach(builder::addGroupList);
+ autoValue.getAccessSections().values().stream()
+ .map(AccessSectionSerializer::serialize)
+ .forEach(builder::addAccessSections);
+ autoValue.getContributorAgreements().values().stream()
+ .map(ContributorAgreementSerializer::serialize)
+ .forEach(builder::addContributorAgreements);
+ autoValue.getNotifySections().values().stream()
+ .map(NotifyConfigSerializer::serialize)
+ .forEach(builder::addNotifyConfigs);
+ autoValue.getLabelSections().values().stream()
+ .map(LabelTypeSerializer::serialize)
+ .forEach(builder::addLabelSections);
+ autoValue.getSubscribeSections().values().stream()
+ .map(SubscribeSectionSerializer::serialize)
+ .forEach(builder::addSubscribeSections);
+ autoValue.getCommentLinkSections().values().stream()
+ .map(StoredCommentLinkInfoSerializer::serialize)
+ .forEach(builder::addCommentLinks);
+ builder.putAllPluginConfigs(autoValue.getPluginConfigs());
+ builder.putAllProjectLevelConfigs(autoValue.getProjectLevelConfigs());
+
+ return builder.build();
+ }
+
+ private CachedProjectConfigSerializer() {}
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializer.java
index 54d0703..19edf4f 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializer.java
@@ -18,7 +18,7 @@
import static com.google.common.base.Strings.nullToEmpty;
import static com.google.common.collect.ImmutableList.toImmutableList;
-import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.entities.ContributorAgreement;
import com.google.gerrit.server.cache.proto.Cache;
/** Helper to (de)serialize values for caches. */
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
index 1566e22..291db4a 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -20,8 +20,8 @@
import com.google.common.base.Enums;
import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Shorts;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.server.cache.proto.Cache;
/** Helper to (de)serialize values for caches. */
@@ -53,6 +53,7 @@
.setMaxNegative(Shorts.saturatedCast(proto.getMaxNegative()))
.setMaxPositive(Shorts.saturatedCast(proto.getMaxPositive()))
.setRefPatterns(proto.getRefPatternsList())
+ .setCanOverride(proto.getCanOverride())
.build();
}
@@ -81,6 +82,7 @@
.setMaxPositive(Shorts.saturatedCast(autoValue.getMaxPositive()))
.addAllRefPatterns(
autoValue.getRefPatterns() == null ? ImmutableList.of() : autoValue.getRefPatterns())
+ .setCanOverride(autoValue.isCanOverride())
.build();
}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializer.java
index d310f18..41fb85f 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializer.java
@@ -16,7 +16,7 @@
import com.google.common.base.Converter;
import com.google.common.base.Enums;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.server.cache.proto.Cache;
/** Helper to (de)serialize values for caches. */
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/PermissionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/PermissionSerializer.java
index 983d926..01d3393 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/PermissionSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/PermissionSerializer.java
@@ -16,8 +16,8 @@
import static com.google.common.collect.ImmutableList.toImmutableList;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.server.cache.proto.Cache;
/** Helper to (de)serialize values for caches. */
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializer.java
index 6125818d..2046f3a 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializer.java
@@ -14,8 +14,8 @@
package com.google.gerrit.server.cache.serialize.entities;
-import com.google.gerrit.common.data.SubscribeSection;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubscribeSection;
import com.google.gerrit.server.cache.proto.Cache;
/** Helper to (de)serialize values for caches. */
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index 48de684..8053b30 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -16,43 +16,107 @@
import static java.util.Objects.requireNonNull;
+import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.util.AttentionSetEmail;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
/** Add a specified user to the attention set. */
public class AddToAttentionSetOp implements BatchUpdateOp {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public interface Factory {
- AddToAttentionSetOp create(Account.Id attentionUserId, String reason);
+ AddToAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
}
+ private final ChangeData.Factory changeDataFactory;
+ private final MessageIdGenerator messageIdGenerator;
+ private final AddToAttentionSetSender.Factory addToAttentionSetSender;
+ private final AttentionSetEmail.Factory attentionSetEmailFactory;
+
private final Account.Id attentionUserId;
private final String reason;
+ private Change change;
+ private boolean notify;
+
/**
* Add a specified user to the attention set.
*
* @param attentionUserId the id of the user we want to add to the attention set.
- * @param reason The reason for adding that user.
+ * @param reason the reason for adding that user.
+ * @param notify whether or not to send emails if the operation is successful.
*/
@Inject
- AddToAttentionSetOp(@Assisted Account.Id attentionUserId, @Assisted String reason) {
+ AddToAttentionSetOp(
+ ChangeData.Factory changeDataFactory,
+ AddToAttentionSetSender.Factory addToAttentionSetSender,
+ MessageIdGenerator messageIdGenerator,
+ AttentionSetEmail.Factory attentionSetEmailFactory,
+ @Assisted Account.Id attentionUserId,
+ @Assisted String reason,
+ @Assisted boolean notify) {
+ this.changeDataFactory = changeDataFactory;
+ this.addToAttentionSetSender = addToAttentionSetSender;
+ this.messageIdGenerator = messageIdGenerator;
+ this.attentionSetEmailFactory = attentionSetEmailFactory;
+
this.attentionUserId = requireNonNull(attentionUserId, "user");
this.reason = requireNonNull(reason, "reason");
+ this.notify = notify;
}
@Override
public boolean updateChange(ChangeContext ctx) throws RestApiException {
+ ChangeData changeData = changeDataFactory.create(ctx.getNotes());
+ if (changeData.attentionSet().stream()
+ .anyMatch(
+ u ->
+ u.account().equals(attentionUserId)
+ && u.operation() == AttentionSetUpdate.Operation.ADD)) {
+ // We still need to perform this update to ensure that we don't remove the user in a follow-up
+ // operation, but no need to send an email about it.
+ notify = false;
+ }
+
+ change = ctx.getChange();
+
ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
update.addToPlannedAttentionSetUpdates(
AttentionSetUpdate.createForWrite(
attentionUserId, AttentionSetUpdate.Operation.ADD, reason));
return true;
}
+
+ @Override
+ public void postUpdate(Context ctx) {
+ if (!notify) {
+ return;
+ }
+ try {
+ attentionSetEmailFactory
+ .create(
+ addToAttentionSetSender.create(ctx.getProject(), change.getId()),
+ ctx,
+ change,
+ reason,
+ messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
+ attentionUserId)
+ .sendAsync();
+ } catch (IOException e) {
+ logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/change/AttentionSetUnchangedOp.java b/java/com/google/gerrit/server/change/AttentionSetUnchangedOp.java
index cfb54e1..8f8a57c 100644
--- a/java/com/google/gerrit/server/change/AttentionSetUnchangedOp.java
+++ b/java/com/google/gerrit/server/change/AttentionSetUnchangedOp.java
@@ -21,14 +21,14 @@
/**
* Ensures that the attention set will not be changed, thus blocks {@link RemoveFromAttentionSetOp}
- * and {@link AddToAttentionSetOp}.
+ * and {@link AddToAttentionSetOp} and updates in {@link ChangeUpdate}.
*/
public class AttentionSetUnchangedOp implements BatchUpdateOp {
@Override
public boolean updateChange(ChangeContext ctx) throws RestApiException {
ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
- update.ignoreDefaultAttentionSetRules();
+ update.ignoreFurtherAttentionSetUpdates();
return true;
}
}
diff --git a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
index 95355cf..663d7aa 100644
--- a/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
+++ b/java/com/google/gerrit/server/change/ChangeAttributeFactory.java
@@ -32,6 +32,7 @@
* href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">plugin
* developer documentation for more details and examples.
*/
+@Deprecated
public interface ChangeAttributeFactory {
/**
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index b749270..6091091 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -28,30 +28,32 @@
import com.google.common.collect.Iterables;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.PatchSetInfo;
import com.google.gerrit.entities.SubmissionId;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.ApprovalsUtil;
import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.config.UrlFormatter;
import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.extensions.events.CommentAdded;
import com.google.gerrit.server.extensions.events.RevisionCreated;
@@ -110,6 +112,7 @@
private final CommentAdded commentAdded;
private final ReviewerAdder reviewerAdder;
private final MessageIdGenerator messageIdGenerator;
+ private final DynamicItem<UrlFormatter> urlFormatter;
private final Change.Id changeId;
private final PatchSet.Id psId;
@@ -159,6 +162,7 @@
RevisionCreated revisionCreated,
ReviewerAdder reviewerAdder,
MessageIdGenerator messageIdGenerator,
+ DynamicItem<UrlFormatter> urlFormatter,
@Assisted Change.Id changeId,
@Assisted ObjectId commitId,
@Assisted String refName) {
@@ -175,6 +179,7 @@
this.commentAdded = commentAdded;
this.reviewerAdder = reviewerAdder;
this.messageIdGenerator = messageIdGenerator;
+ this.urlFormatter = urlFormatter;
this.changeId = changeId;
this.psId = PatchSet.id(changeId, INITIAL_PATCH_SET_ID);
@@ -191,7 +196,7 @@
public Change createChange(Context ctx) throws IOException {
change =
new Change(
- getChangeKey(ctx.getRevWalk(), commitId),
+ getChangeKey(ctx.getRevWalk()),
changeId,
ctx.getAccountId(),
BranchNameKey.create(ctx.getProject(), refName),
@@ -206,10 +211,10 @@
return change;
}
- private static Change.Key getChangeKey(RevWalk rw, ObjectId id) throws IOException {
- RevCommit commit = rw.parseCommit(id);
+ private Change.Key getChangeKey(RevWalk rw) throws IOException {
+ RevCommit commit = rw.parseCommit(commitId);
rw.parseBody(commit);
- List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
+ List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
if (!idList.isEmpty()) {
return Change.key(idList.get(idList.size() - 1).trim());
}
@@ -541,6 +546,7 @@
cmd,
projectState.getProject(),
change.getDest().branch(),
+ ctx.getRepoView().getConfig(),
ctx.getRevWalk().getObjectReader(),
commitId,
ctx.getIdentifiedUser())) {
@@ -584,13 +590,15 @@
change,
patchSetInfo.getCommitId(),
patchSetInfo.getAuthor().getAccount(),
- NotifyHandling.NONE)),
+ NotifyHandling.NONE,
+ change.getOwner())),
Streams.stream(
newAddReviewerInputFromCommitIdentity(
change,
patchSetInfo.getCommitId(),
patchSetInfo.getCommitter().getAccount(),
- NotifyHandling.NONE)))
+ NotifyHandling.NONE,
+ change.getOwner())))
.collect(toImmutableList());
}
}
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 31df6a4..0cfc6cb 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -39,6 +39,7 @@
import com.google.common.base.Joiner;
import com.google.common.base.MoreObjects;
import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ListMultimap;
@@ -46,10 +47,6 @@
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRecord.Status;
-import com.google.gerrit.common.data.SubmitRequirement;
-import com.google.gerrit.common.data.SubmitTypeRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.Change;
@@ -57,6 +54,10 @@
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Status;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.FixInput;
import com.google.gerrit.extensions.client.ListChangesOption;
@@ -67,6 +68,7 @@
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeMessageInfo;
import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
import com.google.gerrit.extensions.common.ProblemInfo;
import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
import com.google.gerrit.extensions.common.RevisionInfo;
@@ -156,13 +158,17 @@
}
public ChangeJson create(Iterable<ListChangesOption> options) {
- return factory.create(options, Optional.empty());
+ return factory.create(options, Optional.empty(), Optional.empty());
}
public ChangeJson create(
Iterable<ListChangesOption> options,
- PluginDefinedAttributesFactory pluginDefinedAttributesFactory) {
- return factory.create(options, Optional.of(pluginDefinedAttributesFactory));
+ PluginDefinedAttributesFactory pluginDefinedAttributesFactory,
+ PluginDefinedInfosFactory pluginDefinedInfosFactory) {
+ return factory.create(
+ options,
+ Optional.of(pluginDefinedAttributesFactory),
+ Optional.of(pluginDefinedInfosFactory));
}
public ChangeJson create(ListChangesOption first, ListChangesOption... rest) {
@@ -173,7 +179,8 @@
public interface AssistedFactory {
ChangeJson create(
Iterable<ListChangesOption> options,
- Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory);
+ Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
+ Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory);
}
@Singleton
@@ -220,6 +227,7 @@
private final Metrics metrics;
private final RevisionJson revisionJson;
private final Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory;
+ private final Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory;
private final boolean includeMergeable;
private final boolean lazyLoad;
@@ -236,14 +244,15 @@
Provider<ConsistencyChecker> checkerProvider,
ActionJson actionJson,
ChangeNotes.Factory notesFactory,
- LabelsJson.Factory labelsJsonFactory,
+ LabelsJson labelsJson,
RemoveReviewerControl removeReviewerControl,
TrackingFooters trackingFooters,
Metrics metrics,
RevisionJson.Factory revisionJsonFactory,
@GerritServerConfig Config cfg,
@Assisted Iterable<ListChangesOption> options,
- @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory) {
+ @Assisted Optional<PluginDefinedAttributesFactory> pluginDefinedAttributesFactory,
+ @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
this.userProvider = user;
this.changeDataFactory = cdf;
this.permissionBackend = permissionBackend;
@@ -252,7 +261,7 @@
this.checkerProvider = checkerProvider;
this.actionJson = actionJson;
this.notesFactory = notesFactory;
- this.labelsJson = labelsJsonFactory.create(options);
+ this.labelsJson = labelsJson;
this.removeReviewerControl = removeReviewerControl;
this.trackingFooters = trackingFooters;
this.metrics = metrics;
@@ -261,6 +270,7 @@
this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
this.pluginDefinedAttributesFactory = pluginDefinedAttributesFactory;
+ this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
logger.atFine().log("options = %s", options);
}
@@ -279,12 +289,12 @@
}
public ChangeInfo format(ChangeData cd) {
- return format(cd, Optional.empty(), true);
+ return format(cd, Optional.empty(), true, getPluginInfos(cd));
}
public ChangeInfo format(RevisionResource rsrc) {
ChangeData cd = changeDataFactory.create(rsrc.getNotes());
- return format(cd, Optional.of(rsrc.getPatchSet().id()), true);
+ return format(cd, Optional.of(rsrc.getPatchSet().id()), true, getPluginInfos(cd));
}
public List<List<ChangeInfo>> format(List<QueryResult<ChangeData>> in)
@@ -293,8 +303,10 @@
accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
List<List<ChangeInfo>> res = new ArrayList<>(in.size());
Map<Change.Id, ChangeInfo> cache = Maps.newHashMapWithExpectedSize(in.size());
+ ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange =
+ getPluginInfos(in.stream().flatMap(e -> e.entities().stream()).collect(toList()));
for (QueryResult<ChangeData> r : in) {
- List<ChangeInfo> infos = toChangeInfos(r.entities(), cache);
+ List<ChangeInfo> infos = toChangeInfos(r.entities(), cache, pluginInfosByChange);
if (!infos.isEmpty() && r.more()) {
infos.get(infos.size() - 1)._moreChanges = true;
}
@@ -309,8 +321,9 @@
accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
ensureLoaded(in);
List<ChangeInfo> out = new ArrayList<>(in.size());
+ ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange = getPluginInfos(in);
for (ChangeData cd : in) {
- out.add(format(cd, Optional.empty(), false));
+ out.add(format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId())));
}
accountLoader.fill();
return out;
@@ -326,7 +339,8 @@
}
return checkOnly(changeDataFactory.create(project, id));
}
- return format(changeDataFactory.create(notes), Optional.empty(), true);
+ ChangeData cd = changeDataFactory.create(notes);
+ return format(cd, Optional.empty(), true, getPluginInfos(cd));
}
private static Collection<SubmitRequirementInfo> requirementsFor(ChangeData cd) {
@@ -358,15 +372,18 @@
}
private ChangeInfo format(
- ChangeData cd, Optional<PatchSet.Id> limitToPsId, boolean fillAccountLoader) {
+ ChangeData cd,
+ Optional<PatchSet.Id> limitToPsId,
+ boolean fillAccountLoader,
+ List<PluginDefinedInfo> pluginInfosForChange) {
try {
if (fillAccountLoader) {
accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
- ChangeInfo res = toChangeInfo(cd, limitToPsId);
+ ChangeInfo res = toChangeInfo(cd, limitToPsId, pluginInfosForChange);
accountLoader.fill();
return res;
}
- return toChangeInfo(cd, limitToPsId);
+ return toChangeInfo(cd, limitToPsId, pluginInfosForChange);
} catch (PatchListNotAvailableException
| GpgException
| IOException
@@ -404,7 +421,9 @@
}
private List<ChangeInfo> toChangeInfos(
- List<ChangeData> changes, Map<Change.Id, ChangeInfo> cache) {
+ List<ChangeData> changes,
+ Map<Change.Id, ChangeInfo> cache,
+ ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange) {
try (Timer0.Context ignored = metrics.toChangeInfosLatency.start()) {
List<ChangeInfo> changeInfos = new ArrayList<>(changes.size());
for (int i = 0; i < changes.size(); i++) {
@@ -425,7 +444,7 @@
// Compute and cache if possible
try {
ensureLoaded(Collections.singleton(cd));
- info = format(cd, Optional.empty(), false);
+ info = format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId()));
changeInfos.add(info);
if (isCacheable) {
cache.put(Change.id(info._number), info);
@@ -480,14 +499,18 @@
return info;
}
- private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+ private ChangeInfo toChangeInfo(
+ ChangeData cd,
+ Optional<PatchSet.Id> limitToPsId,
+ List<PluginDefinedInfo> pluginInfosForChange)
throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
try (Timer0.Context ignored = metrics.toChangeInfoLatency.start()) {
- return toChangeInfoImpl(cd, limitToPsId);
+ return toChangeInfoImpl(cd, limitToPsId, pluginInfosForChange);
}
}
- private ChangeInfo toChangeInfoImpl(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
+ private ChangeInfo toChangeInfoImpl(
+ ChangeData cd, Optional<PatchSet.Id> limitToPsId, List<PluginDefinedInfo> pluginInfos)
throws PatchListNotAvailableException, GpgException, PermissionBackendException, IOException {
ChangeInfo out = new ChangeInfo();
CurrentUser user = userProvider.get();
@@ -589,6 +612,15 @@
if (pluginDefinedAttributesFactory.isPresent()) {
out.plugins = pluginDefinedAttributesFactory.get().create(cd);
}
+
+ if (!pluginInfos.isEmpty()) {
+ if (out.plugins == null) {
+ out.plugins = pluginInfos;
+ } else {
+ out.plugins = new ArrayList<>(out.plugins);
+ out.plugins.addAll(pluginInfos);
+ }
+ }
out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
out.submissionId = cd.change().getSubmissionId();
out.cherryPickOfChange =
@@ -819,4 +851,16 @@
}
return map;
}
+
+ private List<PluginDefinedInfo> getPluginInfos(ChangeData cd) {
+ return getPluginInfos(Collections.singleton(cd)).get(cd.getId());
+ }
+
+ private ImmutableListMultimap<Change.Id, PluginDefinedInfo> getPluginInfos(
+ Collection<ChangeData> cds) {
+ if (pluginDefinedInfosFactory.isPresent()) {
+ return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
+ }
+ return ImmutableListMultimap.of();
+ }
}
diff --git a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java b/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
deleted file mode 100644
index 0db4cea..0000000
--- a/java/com/google/gerrit/server/change/ChangeKeyAdapter.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2019 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.gerrit.entities.Change;
-import com.google.gson.JsonDeserializationContext;
-import com.google.gson.JsonDeserializer;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParseException;
-import com.google.gson.JsonSerializationContext;
-import com.google.gson.JsonSerializer;
-import java.lang.reflect.Type;
-
-/**
- * Adapter that serializes {@link com.google.gerrit.entities.Change.Key}'s {@code key} field as
- * {@code id}, for backwards compatibility in stream-events.
- */
-// TODO(dborowitz): auto-value-gson should support this directly using @SerializedName on the
-// AutoValue method.
-public class ChangeKeyAdapter implements JsonSerializer<Change.Key>, JsonDeserializer<Change.Key> {
- @Override
- public JsonElement serialize(Change.Key src, Type typeOfSrc, JsonSerializationContext context) {
- JsonObject obj = new JsonObject();
- obj.addProperty("id", src.get());
- return obj;
- }
-
- @Override
- public Change.Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
- throws JsonParseException {
- JsonElement keyJson = json.getAsJsonObject().get("id");
- if (keyJson == null || !keyJson.isJsonPrimitive() || !keyJson.getAsJsonPrimitive().isString()) {
- throw new JsonParseException("Key is not a string: " + keyJson);
- }
- String key = keyJson.getAsJsonPrimitive().getAsString();
- return Change.key(key);
- }
-}
diff --git a/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java b/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java
new file mode 100644
index 0000000..c6ceb61
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ChangePluginDefinedInfoFactory.java
@@ -0,0 +1,52 @@
+// 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.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.DynamicOptions.BeanProvider;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
+import java.util.Map;
+
+/**
+ * Interface for plugins to provide additional fields in {@link
+ * com.google.gerrit.extensions.common.ChangeInfo ChangeInfo}.
+ *
+ * <p>Register a {@code ChangePluginDefinedInfoFactory} in a plugin {@code Module} like this:
+ *
+ * <pre>
+ * DynamicSet.bind(binder(), ChangePluginDefinedInfoFactory.class).to(YourClass.class);
+ * </pre>
+ *
+ * <p>See the <a
+ * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">
+ * plugin developer documentation for more details and examples.
+ */
+public interface ChangePluginDefinedInfoFactory {
+
+ /**
+ * Create a plugin-provided info field for each of the provided {@link ChangeData}s.
+ *
+ * <p>Typically, implementations will subclass {@code PluginDefinedInfo} to add additional fields.
+ *
+ * @param cds changes.
+ * @param beanProvider provider of {@code DynamicBean}s, which may be used for reading options.
+ * @param plugin plugin name.
+ * @return map of the plugin's special info for each change
+ */
+ Map<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+ Collection<ChangeData> cds, BeanProvider beanProvider, String plugin);
+}
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..0265f60
--- /dev/null
+++ b/java/com/google/gerrit/server/change/CommentThread.java
@@ -0,0 +1,78 @@
+// 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.Streams;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * 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() {
+ Optional<HumanComment> lastHumanComment =
+ Streams.findLast(
+ comments().stream()
+ .filter(HumanComment.class::isInstance)
+ .map(HumanComment.class::cast));
+ // We often use false == null for boolean fields. It's also a safe fall-back if no human comment
+ // is part of the thread.
+ return lastHumanComment.map(comment -> comment.unresolved).orElse(false);
+ }
+
+ 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/change/ConsistencyChecker.java b/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 61616c0..7d0bda1 100644
--- a/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -28,7 +28,6 @@
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.SetMultimap;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.FooterConstants;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
@@ -38,12 +37,14 @@
import com.google.gerrit.extensions.api.changes.FixInput;
import com.google.gerrit.extensions.common.ProblemInfo;
import com.google.gerrit.extensions.common.ProblemInfo.Status;
+import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.config.UrlFormatter;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.PatchSetState;
@@ -115,6 +116,7 @@
private final Provider<CurrentUser> user;
private final Provider<PersonIdent> serverIdent;
private final RetryHelper retryHelper;
+ private final DynamicItem<UrlFormatter> urlFormatter;
private BatchUpdate.Factory updateFactory;
private FixInput fix;
@@ -141,7 +143,8 @@
PatchSetInserter.Factory patchSetInserterFactory,
PatchSetUtil psUtil,
Provider<CurrentUser> user,
- RetryHelper retryHelper) {
+ RetryHelper retryHelper,
+ DynamicItem<UrlFormatter> urlFormatter) {
this.accounts = accounts;
this.accountPatchReviewStore = accountPatchReviewStore;
this.notesFactory = notesFactory;
@@ -152,6 +155,7 @@
this.retryHelper = retryHelper;
this.serverIdent = serverIdent;
this.user = user;
+ this.urlFormatter = urlFormatter;
reset();
}
@@ -456,7 +460,8 @@
// No patch set for this commit; insert one.
rw.parseBody(commit);
String changeId =
- Iterables.getFirst(commit.getFooterLines(FooterConstants.CHANGE_ID), null);
+ Iterables.getFirst(
+ ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get()), null);
// Missing Change-Id footer is ok, but mismatched is not.
if (changeId != null && !changeId.equals(change().getKey().get())) {
problem(
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index 68d9184..07cb04f 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -18,11 +18,11 @@
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
diff --git a/java/com/google/gerrit/server/change/DraftCommentResource.java b/java/com/google/gerrit/server/change/DraftCommentResource.java
index e0648cf..19a495d 100644
--- a/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -20,6 +20,7 @@
import com.google.gerrit.extensions.restapi.RestResource;
import com.google.gerrit.extensions.restapi.RestView;
import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.inject.TypeLiteral;
public class DraftCommentResource implements RestResource {
@@ -50,6 +51,10 @@
return comment;
}
+ public ChangeNotes getNotes() {
+ return rev.getNotes();
+ }
+
public String getId() {
return comment.key.uuid;
}
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index fe254e0..cacfbe7 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -137,7 +137,9 @@
emailSender.setPatchSetComment(patchSetComment);
emailSender.setLabels(labels);
emailSender.setNotify(notify);
- emailSender.setMessageId(messageIdGenerator.fromChangeUpdate(repoView, patchSet.id()));
+ emailSender.setMessageId(
+ messageIdGenerator.fromChangeUpdateAndReason(
+ repoView, patchSet.id(), "EmailReviewComments"));
emailSender.send();
} catch (Exception e) {
logger.atSevere().withCause(e).log("Cannot email comments for %s", patchSet.id());
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 619b939..30343d4 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -22,9 +22,9 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.server.notedb.ChangeNotes;
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index d9e81b1..76992e8 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -32,27 +32,25 @@
import com.google.common.flogger.FluentLogger;
import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.common.VotingRangeInfo;
-import com.google.gerrit.server.ApprovalsUtil;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.permissions.LabelPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
+import com.google.inject.Singleton;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
@@ -68,28 +66,15 @@
/**
* Produces label-related entities, like {@link LabelInfo}s, which is serialized to JSON afterwards.
*/
+@Singleton
public class LabelsJson {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- public interface Factory {
- LabelsJson create(Iterable<ListChangesOption> options);
- }
-
- private final ApprovalsUtil approvalsUtil;
- private final ChangeNotes.Factory notesFactory;
private final PermissionBackend permissionBackend;
- private final boolean lazyLoad;
@Inject
- LabelsJson(
- ApprovalsUtil approvalsUtil,
- ChangeNotes.Factory notesFactory,
- PermissionBackend permissionBackend,
- @Assisted Iterable<ListChangesOption> options) {
- this.approvalsUtil = approvalsUtil;
- this.notesFactory = notesFactory;
+ LabelsJson(PermissionBackend permissionBackend) {
this.permissionBackend = permissionBackend;
- this.lazyLoad = containsAnyOf(Sets.immutableEnumSet(options), ChangeJson.REQUIRE_LAZY_LOAD);
}
/**
@@ -253,14 +238,10 @@
private Map<String, Short> currentLabels(Account.Id accountId, ChangeData cd) {
Map<String, Short> result = new HashMap<>();
- for (PatchSetApproval psa :
- approvalsUtil.byPatchSetUser(
- lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
- cd.change().currentPatchSetId(),
- accountId,
- null,
- null)) {
- result.put(psa.label(), psa.value());
+ for (PatchSetApproval psa : cd.currentApprovals()) {
+ if (psa.accountId().equals(accountId)) {
+ result.put(psa.label(), psa.value());
+ }
}
return result;
}
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index 882352d..ef06ea1 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -342,6 +342,7 @@
.orElseThrow(illegalState(origNotes.getProjectName()))
.getProject(),
origNotes.getChange().getDest().branch(),
+ ctx.getRepoView().getConfig(),
ctx.getRevWalk().getObjectReader(),
commitId,
ctx.getIdentifiedUser())) {
diff --git a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
index 9928125..b474dab 100644
--- a/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
+++ b/java/com/google/gerrit/server/change/PluginDefinedAttributesFactories.java
@@ -18,12 +18,15 @@
import static java.util.concurrent.TimeUnit.MINUTES;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.common.PluginDefinedInfo;
import com.google.gerrit.extensions.registration.Extension;
import com.google.gerrit.server.DynamicOptions.BeanProvider;
import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
import java.util.Objects;
import java.util.stream.Stream;
@@ -60,5 +63,44 @@
return pdi;
}
+ public static ImmutableListMultimap<Change.Id, PluginDefinedInfo> createAll(
+ Collection<ChangeData> cds,
+ BeanProvider beanProvider,
+ Stream<Extension<ChangePluginDefinedInfoFactory>> infoFactories) {
+ ImmutableListMultimap.Builder<Change.Id, PluginDefinedInfo> pluginInfosByChangeBuilder =
+ ImmutableListMultimap.builder();
+ infoFactories.forEach(
+ e -> tryCreate(cds, beanProvider, e.getPluginName(), e.get(), pluginInfosByChangeBuilder));
+ ImmutableListMultimap<Change.Id, PluginDefinedInfo> result = pluginInfosByChangeBuilder.build();
+ return result;
+ }
+
+ private static void tryCreate(
+ Collection<ChangeData> cds,
+ BeanProvider beanProvider,
+ String plugin,
+ ChangePluginDefinedInfoFactory infoFactory,
+ ImmutableListMultimap.Builder<Change.Id, PluginDefinedInfo> pluginInfosByChangeBuilder) {
+ try {
+ infoFactory
+ .createPluginDefinedInfos(cds, beanProvider, plugin)
+ .forEach(
+ (id, pdi) -> {
+ if (pdi != null) {
+ pdi.name = plugin;
+ pluginInfosByChangeBuilder.put(id, pdi);
+ }
+ });
+ } catch (RuntimeException ex) {
+ /* Propagate runtime exceptions as structured API data types so that queries don't fail. */
+ logger.atWarning().atMostEvery(1, MINUTES).withCause(ex).log(
+ "error populating attribute on changes from plugin %s", plugin);
+ PluginDefinedInfo errorInfo = new PluginDefinedInfo();
+ errorInfo.name = plugin;
+ errorInfo.message = "Something went wrong in plugin: " + plugin;
+ cds.forEach(cd -> pluginInfosByChangeBuilder.put(cd.getId(), errorInfo));
+ }
+ }
+
private PluginDefinedAttributesFactories() {}
}
diff --git a/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java b/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java
new file mode 100644
index 0000000..db57e29
--- /dev/null
+++ b/java/com/google/gerrit/server/change/PluginDefinedInfosFactory.java
@@ -0,0 +1,42 @@
+// 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.common.collect.ImmutableListMultimap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Collection;
+
+/**
+ * Interface to generate {@code PluginDefinedInfo}s from registered {@code
+ * ChangePluginDefinedInfoFactory}s.
+ *
+ * <p>See the <a
+ * href="https://gerrit-review.googlesource.com/Documentation/dev-plugins.html#query_attributes">
+ * plugin developer documentation for more details and examples.
+ */
+public interface PluginDefinedInfosFactory {
+
+ /**
+ * Create a plugin-provided info field from all the plugins for each of the provided {@link
+ * ChangeData}s.
+ *
+ * @param cds changes.
+ * @return map of the all plugin's special infos for each change.
+ */
+ ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+ Collection<ChangeData> cds);
+}
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index 6be1343..e532409 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -16,43 +16,107 @@
import static java.util.Objects.requireNonNull;
+import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.AttentionSetUpdate.Operation;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.util.AttentionSetEmail;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.Optional;
/** Remove a specified user from the attention set. */
public class RemoveFromAttentionSetOp implements BatchUpdateOp {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public interface Factory {
- RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason);
+ RemoveFromAttentionSetOp create(Account.Id attentionUserId, String reason, boolean notify);
}
+ private final ChangeData.Factory changeDataFactory;
+ private final MessageIdGenerator messageIdGenerator;
+ private final RemoveFromAttentionSetSender.Factory removeFromAttentionSetSender;
+ private final AttentionSetEmail.Factory attentionSetEmailFactory;
+
private final Account.Id attentionUserId;
private final String reason;
+ private Change change;
+ private boolean notify;
+
/**
* Remove a specified user from the attention set.
*
* @param attentionUserId the id of the user we want to add to the attention set.
- * @param reason The reason for adding that user.
+ * @param reason the reason for adding that user.
+ * @param notify whether or not to send emails if the operation is successful.
*/
@Inject
- RemoveFromAttentionSetOp(@Assisted Account.Id attentionUserId, @Assisted String reason) {
+ RemoveFromAttentionSetOp(
+ ChangeData.Factory changeDataFactory,
+ MessageIdGenerator messageIdGenerator,
+ RemoveFromAttentionSetSender.Factory removeFromAttentionSetSenderFactory,
+ AttentionSetEmail.Factory attentionSetEmailFactory,
+ @Assisted Account.Id attentionUserId,
+ @Assisted String reason,
+ @Assisted boolean notify) {
+ this.changeDataFactory = changeDataFactory;
+ this.messageIdGenerator = messageIdGenerator;
+ this.removeFromAttentionSetSender = removeFromAttentionSetSenderFactory;
+ this.attentionSetEmailFactory = attentionSetEmailFactory;
this.attentionUserId = requireNonNull(attentionUserId, "user");
this.reason = requireNonNull(reason, "reason");
+ this.notify = notify;
}
@Override
public boolean updateChange(ChangeContext ctx) throws RestApiException {
+ ChangeData changeData = changeDataFactory.create(ctx.getNotes());
+ Optional<AttentionSetUpdate> existingEntry =
+ changeData.attentionSet().stream()
+ .filter(u -> u.account().equals(attentionUserId))
+ .findAny();
+ if (!existingEntry.isPresent() || existingEntry.get().operation() == Operation.REMOVE) {
+ // We still need to perform this update to ensure that we don't add the user in a follow-up
+ // operation, but no need to send an email about it.
+ notify = false;
+ }
+
+ change = ctx.getChange();
+
ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
update.addToPlannedAttentionSetUpdates(
AttentionSetUpdate.createForWrite(attentionUserId, Operation.REMOVE, reason));
return true;
}
+
+ @Override
+ public void postUpdate(Context ctx) {
+ if (!notify) {
+ return;
+ }
+ try {
+ attentionSetEmailFactory
+ .create(
+ removeFromAttentionSetSender.create(ctx.getProject(), change.getId()),
+ ctx,
+ change,
+ reason,
+ messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()),
+ attentionUserId)
+ .sendAsync();
+ } catch (IOException e) {
+ logger.atSevere().withCause(e).log(e.getMessage(), change.getId());
+ }
+ }
}
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerAdder.java
index c271651..5d55b4d 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerAdder.java
@@ -19,7 +19,6 @@
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.extensions.client.ReviewerState.CC;
-import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
@@ -129,19 +128,23 @@
}
public static Optional<InternalAddReviewerInput> newAddReviewerInputFromCommitIdentity(
- Change change, ObjectId commitId, @Nullable Account.Id accountId, NotifyHandling notify) {
- if (accountId == null || accountId.equals(change.getOwner())) {
+ Change change,
+ ObjectId commitId,
+ @Nullable Account.Id accountId,
+ NotifyHandling notify,
+ Account.Id mostRecentUploader) {
+ if (accountId == null || accountId.equals(mostRecentUploader)) {
// If git ident couldn't be resolved to a user, or if it's not forged, do nothing.
return Optional.empty();
}
logger.atFine().log(
- "Adding account %d from author/committer identity of commit %s as reviewer to change %d",
+ "Adding account %d from author/committer identity of commit %s as cc to change %d",
accountId.get(), commitId.name(), change.getChangeId());
InternalAddReviewerInput in = new InternalAddReviewerInput();
in.reviewer = accountId.toString();
- in.state = REVIEWER;
+ in.state = CC;
in.notify = notify;
in.otherFailureBehavior = FailureBehavior.IGNORE;
return Optional.of(in);
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index d493fd0..a3136d4a 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -18,13 +18,13 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.api.changes.ReviewerInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.server.ApprovalsUtil;
diff --git a/java/com/google/gerrit/server/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
new file mode 100644
index 0000000..c4a49b0
--- /dev/null
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2012 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.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.extensions.events.TopicEdited;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SetTopicOp implements BatchUpdateOp {
+ public interface Factory {
+ SetTopicOp create(@Nullable String topic);
+ }
+
+ private final String topic;
+ private final TopicEdited topicEdited;
+ private final ChangeMessagesUtil cmUtil;
+
+ private Change change;
+ private String oldTopicName;
+ private String newTopicName;
+
+ @Inject
+ public SetTopicOp(
+ TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Nullable @Assisted String topic) {
+ this.topic = topic;
+ this.topicEdited = topicEdited;
+ this.cmUtil = cmUtil;
+ }
+
+ @Override
+ public boolean updateChange(ChangeContext ctx) throws BadRequestException {
+ change = ctx.getChange();
+ ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+ newTopicName = Strings.nullToEmpty(topic);
+ oldTopicName = Strings.nullToEmpty(change.getTopic());
+ if (oldTopicName.equals(newTopicName)) {
+ return false;
+ }
+
+ String summary;
+ if (oldTopicName.isEmpty()) {
+ summary = "Topic set to " + newTopicName;
+ } else if (newTopicName.isEmpty()) {
+ summary = "Topic " + oldTopicName + " removed";
+ } else {
+ summary = String.format("Topic changed from %s to %s", oldTopicName, newTopicName);
+ }
+ change.setTopic(Strings.emptyToNull(newTopicName));
+ try {
+ update.setTopic(change.getTopic());
+ } catch (ValidationException ex) {
+ throw new BadRequestException(ex.getMessage());
+ }
+ ChangeMessage cmsg =
+ ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
+ cmUtil.addChangeMessage(update, cmsg);
+ return true;
+ }
+
+ @Override
+ public void postUpdate(Context ctx) {
+ if (change != null) {
+ topicEdited.fire(change, ctx.getAccount(), oldTopicName, ctx.getWhen());
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCache.java b/java/com/google/gerrit/server/comment/CommentContextCache.java
new file mode 100644
index 0000000..8c40763
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCache.java
@@ -0,0 +1,42 @@
+// 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.comment;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.CommentContext;
+
+/**
+ * Caches the context lines of comments (source file content surrounding and including the lines
+ * where the comment was written)
+ */
+public interface CommentContextCache {
+
+ /**
+ * Returns the context lines for a single comment.
+ *
+ * @param key a key representing a subset of fields for a comment that serves as an identifier.
+ * @return a {@link CommentContext} object containing all line numbers and text of the context.
+ */
+ CommentContext get(CommentContextKey key);
+
+ /**
+ * Returns the context lines for multiple comments - identified by their {@code keys}.
+ *
+ * @param keys list of keys, where each key represents a single comment through its project,
+ * change ID, patchset, path and ID. The keys can belong to different projects and changes.
+ * @return {@code Map} of {@code CommentContext} containing the context for all comments.
+ */
+ ImmutableMap<CommentContextKey, CommentContext> getAll(Iterable<CommentContextKey> keys);
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
new file mode 100644
index 0000000..c4e29d8
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -0,0 +1,256 @@
+// 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.comment;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.CommentContextLoader;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto;
+import com.google.gerrit.server.cache.proto.Cache.AllCommentContextProto.CommentContextProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/** Implementation of {@link CommentContextCache}. */
+public class CommentContextCacheImpl implements CommentContextCache {
+ private static final String CACHE_NAME = "comment_context";
+
+ public static Module module() {
+ return new CacheModule() {
+ @Override
+ protected void configure() {
+ persist(CACHE_NAME, CommentContextKey.class, CommentContext.class)
+ .version(1)
+ .diskLimit(1 << 30) // limit the total cache size to 1 GB
+ .maximumWeight(1 << 23) // Limit the size of the in-memory cache to 8 MB
+ .weigher(CommentContextWeigher.class)
+ .keySerializer(CommentContextKey.Serializer.INSTANCE)
+ .valueSerializer(CommentContextSerializer.INSTANCE)
+ .loader(Loader.class);
+
+ bind(CommentContextCache.class).to(CommentContextCacheImpl.class);
+ }
+ };
+ }
+
+ private final LoadingCache<CommentContextKey, CommentContext> contextCache;
+
+ @Inject
+ CommentContextCacheImpl(
+ @Named(CACHE_NAME) LoadingCache<CommentContextKey, CommentContext> contextCache) {
+ this.contextCache = contextCache;
+ }
+
+ @Override
+ public CommentContext get(CommentContextKey comment) {
+ return getAll(ImmutableList.of(comment)).get(comment);
+ }
+
+ @Override
+ public ImmutableMap<CommentContextKey, CommentContext> getAll(
+ Iterable<CommentContextKey> inputKeys) {
+ ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
+
+ // Convert the input keys to the same keys but with their file paths hashed
+ Map<CommentContextKey, CommentContextKey> keysToCacheKeys =
+ Streams.stream(inputKeys)
+ .collect(
+ Collectors.toMap(
+ Function.identity(),
+ k -> k.toBuilder().path(Loader.hashPath(k.path())).build()));
+
+ try {
+ ImmutableMap<CommentContextKey, CommentContext> allContext =
+ contextCache.getAll(keysToCacheKeys.values());
+
+ for (CommentContextKey inputKey : inputKeys) {
+ CommentContextKey cacheKey = keysToCacheKeys.get(inputKey);
+ result.put(inputKey, allContext.get(cacheKey));
+ }
+ return result.build();
+ } catch (ExecutionException e) {
+ throw new StorageException("Failed to retrieve comments' context", e);
+ }
+ }
+
+ public enum CommentContextSerializer implements CacheSerializer<CommentContext> {
+ INSTANCE;
+
+ @Override
+ public byte[] serialize(CommentContext commentContext) {
+ AllCommentContextProto.Builder allBuilder = AllCommentContextProto.newBuilder();
+
+ commentContext
+ .lines()
+ .entrySet()
+ .forEach(
+ c ->
+ allBuilder.addContext(
+ CommentContextProto.newBuilder()
+ .setLineNumber(c.getKey())
+ .setContextLine(c.getValue())));
+ return Protos.toByteArray(allBuilder.build());
+ }
+
+ @Override
+ public CommentContext deserialize(byte[] in) {
+ ImmutableMap.Builder<Integer, String> contextLinesMap = ImmutableMap.builder();
+ Protos.parseUnchecked(AllCommentContextProto.parser(), in).getContextList().stream()
+ .forEach(c -> contextLinesMap.put(c.getLineNumber(), c.getContextLine()));
+ return CommentContext.create(contextLinesMap.build());
+ }
+ }
+
+ static class Loader extends CacheLoader<CommentContextKey, CommentContext> {
+ private final ChangeNotes.Factory notesFactory;
+ private final CommentsUtil commentsUtil;
+ private final CommentContextLoader.Factory factory;
+
+ @Inject
+ Loader(
+ CommentsUtil commentsUtil,
+ ChangeNotes.Factory notesFactory,
+ CommentContextLoader.Factory factory) {
+ this.commentsUtil = commentsUtil;
+ this.notesFactory = notesFactory;
+ this.factory = factory;
+ }
+
+ @Override
+ public CommentContext load(CommentContextKey key) {
+ return loadAll(ImmutableList.of(key)).get(key);
+ }
+
+ @Override
+ public Map<CommentContextKey, CommentContext> loadAll(
+ Iterable<? extends CommentContextKey> keys) {
+ ImmutableMap.Builder<CommentContextKey, CommentContext> result =
+ ImmutableMap.builderWithExpectedSize(Iterables.size(keys));
+
+ Map<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> groupedKeys =
+ Streams.stream(keys)
+ .distinct()
+ .map(k -> (CommentContextKey) k)
+ .collect(
+ Collectors.groupingBy(
+ CommentContextKey::project,
+ Collectors.groupingBy(CommentContextKey::changeId)));
+
+ for (Map.Entry<Project.NameKey, Map<Change.Id, List<CommentContextKey>>> perProject :
+ groupedKeys.entrySet()) {
+ Map<Change.Id, List<CommentContextKey>> keysPerProject = perProject.getValue();
+
+ for (Map.Entry<Change.Id, List<CommentContextKey>> perChange : keysPerProject.entrySet()) {
+ Map<CommentContextKey, CommentContext> context =
+ loadForSameChange(perChange.getValue(), perProject.getKey(), perChange.getKey());
+ result.putAll(context);
+ }
+ }
+ return result.build();
+ }
+
+ /**
+ * Load the comment context for comments of the same project and change ID.
+ *
+ * @param keys a list of keys corresponding to some comments
+ * @param project a gerrit project/repository
+ * @param changeId an identifier for a change
+ * @return a map of the input keys to their corresponding {@link CommentContext}
+ */
+ private Map<CommentContextKey, CommentContext> loadForSameChange(
+ List<CommentContextKey> keys, Project.NameKey project, Change.Id changeId) {
+ ChangeNotes notes = notesFactory.createChecked(project, changeId);
+ List<HumanComment> humanComments = commentsUtil.publishedHumanCommentsByChange(notes);
+ CommentContextLoader loader = factory.create(project);
+ Map<Comment, CommentContextKey> commentsToKeys = new HashMap<>();
+ for (CommentContextKey key : keys) {
+ commentsToKeys.put(getCommentForKey(humanComments, key), key);
+ }
+ Map<Comment, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
+ return allContext.entrySet().stream()
+ .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
+ }
+
+ /**
+ * Return the single comment from the {@code allComments} input list corresponding to the key
+ * parameter.
+ *
+ * @param allComments a list of comments.
+ * @param key a key representing a single comment.
+ * @return the single comment corresponding to the key parameter.
+ */
+ private Comment getCommentForKey(List<HumanComment> allComments, CommentContextKey key) {
+ return allComments.stream()
+ .filter(
+ c ->
+ key.id().equals(c.key.uuid)
+ && key.patchset() == c.key.patchSetId
+ && key.path().equals(hashPath(c.key.filename)))
+ .findFirst()
+ .orElseThrow(() -> new IllegalArgumentException("Unable to find comment for key " + key));
+ }
+
+ /**
+ * Hash an input String using the general {@link Hashing#murmur3_128()} hash.
+ *
+ * @param input the input String
+ * @return a hashed representation of the input String
+ */
+ static String hashPath(String input) {
+ return Hashing.murmur3_128().hashString(input, UTF_8).toString();
+ }
+ }
+
+ private static class CommentContextWeigher implements Weigher<CommentContextKey, CommentContext> {
+ @Override
+ public int weigh(CommentContextKey key, CommentContext commentContext) {
+ int size = 0;
+ size += key.id().length();
+ size += key.path().length();
+ size += key.project().get().length();
+ size += 4;
+ for (String line : commentContext.lines().values()) {
+ size += 4; // line number
+ size += line.length(); // number of characters in the context line
+ }
+ return size;
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/comment/CommentContextKey.java b/java/com/google/gerrit/server/comment/CommentContextKey.java
new file mode 100644
index 0000000..e4a927a
--- /dev/null
+++ b/java/com/google/gerrit/server/comment/CommentContextKey.java
@@ -0,0 +1,82 @@
+package com.google.gerrit.server.comment;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import java.util.Collection;
+
+/**
+ * An identifier of a comment that should be used to load the comment context using {@link
+ * CommentContextCache#get(CommentContextKey)}, or {@link CommentContextCache#getAll(Collection)}.
+ *
+ * <p>The {@link CommentContextCacheImpl} implementation uses this class as the cache key, while
+ * replacing the {@link #path()} field with the hashed path.
+ */
+@AutoValue
+public abstract class CommentContextKey {
+ abstract Project.NameKey project();
+
+ abstract Change.Id changeId();
+
+ /** The unique comment ID. */
+ abstract String id();
+
+ /** File path at which the comment was written. */
+ abstract String path();
+
+ abstract Integer patchset();
+
+ abstract Builder toBuilder();
+
+ public static Builder builder() {
+ return new AutoValue_CommentContextKey.Builder();
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public abstract Builder project(Project.NameKey nameKey);
+
+ public abstract Builder changeId(Change.Id changeId);
+
+ public abstract Builder id(String id);
+
+ public abstract Builder path(String path);
+
+ public abstract Builder patchset(Integer patchset);
+
+ public abstract CommentContextKey build();
+ }
+
+ public enum Serializer implements CacheSerializer<CommentContextKey> {
+ INSTANCE;
+
+ @Override
+ public byte[] serialize(CommentContextKey key) {
+ return Protos.toByteArray(
+ Cache.CommentContextKeyProto.newBuilder()
+ .setProject(key.project().get())
+ .setChangeId(key.changeId().toString())
+ .setPatchset(key.patchset())
+ .setPathHash(key.path())
+ .setCommentId(key.id())
+ .build());
+ }
+
+ @Override
+ public CommentContextKey deserialize(byte[] in) {
+ Cache.CommentContextKeyProto proto =
+ Protos.parseUnchecked(Cache.CommentContextKeyProto.parser(), in);
+ return CommentContextKey.builder()
+ .project(Project.NameKey.parse(proto.getProject()))
+ .changeId(Change.Id.tryParse(proto.getChangeId()).get())
+ .patchset(proto.getPatchset())
+ .id(proto.getCommentId())
+ .path(proto.getPathHash())
+ .build();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java b/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
index 64937db..9f6ecfb5 100644
--- a/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
+++ b/java/com/google/gerrit/server/config/AdministrateServerGroupsProvider.java
@@ -16,8 +16,8 @@
import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.PermissionRule;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.GroupBackends;
import com.google.gerrit.server.util.RequestContext;
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index cf592bf..6a25afd 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -95,6 +95,7 @@
import com.google.gerrit.server.account.GroupCacheImpl;
import com.google.gerrit.server.account.GroupControl;
import com.google.gerrit.server.account.GroupIncludeCacheImpl;
+import com.google.gerrit.server.account.ServiceUserClassifierImpl;
import com.google.gerrit.server.account.VersionedAuthorizedKeys;
import com.google.gerrit.server.account.externalids.ExternalIdModule;
import com.google.gerrit.server.auth.AuthBackend;
@@ -109,10 +110,11 @@
import com.google.gerrit.server.change.ChangeFinder;
import com.google.gerrit.server.change.ChangeJson;
import com.google.gerrit.server.change.ChangeKindCacheImpl;
-import com.google.gerrit.server.change.LabelsJson;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
import com.google.gerrit.server.change.MergeabilityCacheImpl;
import com.google.gerrit.server.change.ReviewerSuggestion;
import com.google.gerrit.server.change.RevisionJson;
+import com.google.gerrit.server.comment.CommentContextCacheImpl;
import com.google.gerrit.server.events.EventFactory;
import com.google.gerrit.server.events.EventListener;
import com.google.gerrit.server.events.EventsMetrics;
@@ -186,9 +188,11 @@
import com.google.gerrit.server.rules.RulesCache;
import com.google.gerrit.server.rules.SubmitRule;
import com.google.gerrit.server.ssh.SshAddressesModule;
+import com.google.gerrit.server.submit.ConfiguredSubscriptionGraphFactory;
import com.google.gerrit.server.submit.GitModules;
import com.google.gerrit.server.submit.MergeSuperSetComputation;
import com.google.gerrit.server.submit.SubmitStrategy;
+import com.google.gerrit.server.submit.SubscriptionGraph;
import com.google.gerrit.server.tools.ToolsCatalog;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.util.IdGenerator;
@@ -239,6 +243,7 @@
install(GroupCacheImpl.module());
install(GroupIncludeCacheImpl.module());
install(MergeabilityCacheImpl.module());
+ install(ServiceUserClassifierImpl.module());
install(PatchListCacheImpl.module());
install(ProjectCacheImpl.module());
install(SectionSortCache.module());
@@ -246,6 +251,7 @@
install(TagCache.module());
install(OAuthTokenCache.module());
install(PureRevertCache.module());
+ install(CommentContextCacheImpl.module());
install(new AccessControlModule());
install(new CmdLineParserModule());
@@ -266,7 +272,6 @@
factory(ChangeData.AssistedFactory.class);
factory(ChangeJson.AssistedFactory.class);
factory(ChangeIsVisibleToPredicate.Factory.class);
- factory(LabelsJson.Factory.class);
factory(MergeUtil.Factory.class);
factory(PatchScriptFactory.Factory.class);
factory(PatchScriptFactoryForAutoFix.Factory.class);
@@ -431,7 +436,9 @@
DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
+ DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class);
DynamicSet.setOf(binder(), ChangeAttributeFactory.class);
+ DynamicSet.setOf(binder(), ChangePluginDefinedInfoFactory.class);
install(new GitwebConfig.LegacyModule(cfg));
@@ -450,6 +457,7 @@
factory(VersionedAuthorizedKeys.Factory.class);
bind(AccountManager.class);
+ bind(SubscriptionGraph.Factory.class).to(ConfiguredSubscriptionGraphFactory.class);
bind(new TypeLiteral<List<CommentLinkInfo>>() {}).toProvider(CommentLinkProvider.class);
DynamicSet.bind(binder(), GerritConfigListener.class).to(CommentLinkProvider.class);
diff --git a/java/com/google/gerrit/server/config/PluginConfig.java b/java/com/google/gerrit/server/config/PluginConfig.java
index 0eebd98..2b363f1 100644
--- a/java/com/google/gerrit/server/config/PluginConfig.java
+++ b/java/com/google/gerrit/server/config/PluginConfig.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// 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.
@@ -14,63 +14,94 @@
package com.google.gerrit.server.config;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.CachedProjectConfig;
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectState;
import java.util.Arrays;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
-public class PluginConfig {
+@AutoValue
+public abstract class PluginConfig {
private static final String PLUGIN = "plugin";
- private final String pluginName;
- private Config cfg;
- private final ProjectConfig projectConfig;
+ protected abstract String pluginName();
- public PluginConfig(String pluginName, Config cfg) {
- this(pluginName, cfg, null);
+ protected abstract Config cfg();
+
+ protected abstract Optional<CachedProjectConfig> projectConfig();
+
+ /** Mappings parsed from {@code groups} files. */
+ protected abstract ImmutableMap<AccountGroup.UUID, GroupReference> groupReferences();
+
+ public static PluginConfig create(
+ String pluginName, Config cfg, @Nullable CachedProjectConfig projectConfig) {
+ ImmutableMap.Builder<AccountGroup.UUID, GroupReference> groupReferences =
+ ImmutableMap.builder();
+ if (projectConfig != null) {
+ groupReferences.putAll(projectConfig.getGroups());
+ }
+ return new AutoValue_PluginConfig(
+ pluginName, copyConfig(cfg), Optional.ofNullable(projectConfig), groupReferences.build());
}
- public PluginConfig(String pluginName, Config cfg, ProjectConfig projectConfig) {
- this.pluginName = pluginName;
- this.cfg = cfg;
- this.projectConfig = projectConfig;
+ public static PluginConfig createFromGerritConfig(String pluginName, Config cfg) {
+ // There is no need to make a defensive copy here because this value won't be cached.
+ // gerrit.config uses baseConfig's (a member of Config) which would also make defensive copies
+ // fail.
+ return new AutoValue_PluginConfig(pluginName, cfg, Optional.empty(), ImmutableMap.of());
}
PluginConfig withInheritance(ProjectState.Factory projectStateFactory) {
- if (projectConfig == null) {
+ checkState(projectConfig().isPresent(), "no project config provided");
+
+ ProjectState state = projectStateFactory.create(projectConfig().get());
+ ProjectState parent = Iterables.getFirst(state.parents(), null);
+ if (parent == null) {
return this;
}
- ProjectState state = projectStateFactory.create(projectConfig);
- ProjectState parent = Iterables.getFirst(state.parents(), null);
- if (parent != null) {
- PluginConfig parentPluginConfig =
- parent.getBareConfig().getPluginConfig(pluginName).withInheritance(projectStateFactory);
- Set<String> allNames = cfg.getNames(PLUGIN, pluginName);
- cfg = copyConfig(cfg);
- for (String name : parentPluginConfig.cfg.getNames(PLUGIN, pluginName)) {
- if (!allNames.contains(name)) {
- List<String> values =
- Arrays.asList(parentPluginConfig.cfg.getStringList(PLUGIN, pluginName, name));
- for (String value : values) {
- GroupReference groupRef =
- parentPluginConfig.projectConfig.getGroup(GroupReference.extractGroupName(value));
- if (groupRef != null) {
- projectConfig.resolve(groupRef);
- }
+ Map<AccountGroup.UUID, GroupReference> groupReferences = new HashMap<>();
+ groupReferences.putAll(groupReferences());
+ PluginConfig parentPluginConfig =
+ parent.getPluginConfig(pluginName()).withInheritance(projectStateFactory);
+ Set<String> allNames = cfg().getNames(PLUGIN, pluginName());
+ Config newCfg = copyConfig(cfg());
+ for (String name : parentPluginConfig.cfg().getNames(PLUGIN, pluginName())) {
+ if (!allNames.contains(name)) {
+ List<String> values =
+ Arrays.asList(parentPluginConfig.cfg().getStringList(PLUGIN, pluginName(), name));
+ for (String value : values) {
+ Optional<GroupReference> groupRef =
+ parentPluginConfig
+ .projectConfig()
+ .get()
+ .getGroupByName(GroupReference.extractGroupName(value));
+ if (groupRef.isPresent()) {
+ groupReferences.putIfAbsent(groupRef.get().getUUID(), groupRef.get());
}
- cfg.setStringList(PLUGIN, pluginName, name, values);
}
+ newCfg.setStringList(PLUGIN, pluginName(), name, values);
}
}
- return this;
+ return new AutoValue_PluginConfig(
+ pluginName(), newCfg, projectConfig(), ImmutableMap.copyOf(groupReferences));
}
private static Config copyConfig(Config cfg) {
@@ -85,86 +116,150 @@
}
public String getString(String name) {
- return cfg.getString(PLUGIN, pluginName, name);
+ return cfg().getString(PLUGIN, pluginName(), name);
}
public String getString(String name, String defaultValue) {
if (defaultValue == null) {
- return cfg.getString(PLUGIN, pluginName, name);
+ return cfg().getString(PLUGIN, pluginName(), name);
}
- return MoreObjects.firstNonNull(cfg.getString(PLUGIN, pluginName, name), defaultValue);
- }
-
- public void setString(String name, String value) {
- if (Strings.isNullOrEmpty(value)) {
- cfg.unset(PLUGIN, pluginName, name);
- } else {
- cfg.setString(PLUGIN, pluginName, name, value);
- }
+ return MoreObjects.firstNonNull(cfg().getString(PLUGIN, pluginName(), name), defaultValue);
}
public String[] getStringList(String name) {
- return cfg.getStringList(PLUGIN, pluginName, name);
- }
-
- public void setStringList(String name, List<String> values) {
- if (values == null || values.isEmpty()) {
- cfg.unset(PLUGIN, pluginName, name);
- } else {
- cfg.setStringList(PLUGIN, pluginName, name, values);
- }
+ return cfg().getStringList(PLUGIN, pluginName(), name);
}
public int getInt(String name, int defaultValue) {
- return cfg.getInt(PLUGIN, pluginName, name, defaultValue);
- }
-
- public void setInt(String name, int value) {
- cfg.setInt(PLUGIN, pluginName, name, value);
+ return cfg().getInt(PLUGIN, pluginName(), name, defaultValue);
}
public long getLong(String name, long defaultValue) {
- return cfg.getLong(PLUGIN, pluginName, name, defaultValue);
- }
-
- public void setLong(String name, long value) {
- cfg.setLong(PLUGIN, pluginName, name, value);
+ return cfg().getLong(PLUGIN, pluginName(), name, defaultValue);
}
public boolean getBoolean(String name, boolean defaultValue) {
- return cfg.getBoolean(PLUGIN, pluginName, name, defaultValue);
- }
-
- public void setBoolean(String name, boolean value) {
- cfg.setBoolean(PLUGIN, pluginName, name, value);
+ return cfg().getBoolean(PLUGIN, pluginName(), name, defaultValue);
}
public <T extends Enum<?>> T getEnum(String name, T defaultValue) {
- return cfg.getEnum(PLUGIN, pluginName, name, defaultValue);
- }
-
- public <T extends Enum<?>> void setEnum(String name, T value) {
- cfg.setEnum(PLUGIN, pluginName, name, value);
+ return cfg().getEnum(PLUGIN, pluginName(), name, defaultValue);
}
public <T extends Enum<?>> T getEnum(T[] all, String name, T defaultValue) {
- return cfg.getEnum(all, PLUGIN, pluginName, name, defaultValue);
- }
-
- public void unset(String name) {
- cfg.unset(PLUGIN, pluginName, name);
+ return cfg().getEnum(all, PLUGIN, pluginName(), name, defaultValue);
}
public Set<String> getNames() {
- return cfg.getNames(PLUGIN, pluginName, true);
+ return cfg().getNames(PLUGIN, pluginName(), true);
}
- public GroupReference getGroupReference(String name) {
- return projectConfig.getGroup(GroupReference.extractGroupName(getString(name)));
+ public Optional<GroupReference> getGroupReference(String name) {
+ String exactName = GroupReference.extractGroupName(getString(name));
+ return groupReferences().values().stream().filter(g -> g.getName().equals(exactName)).findAny();
}
- public void setGroupReference(String name, GroupReference value) {
- GroupReference groupRef = projectConfig.resolve(value);
- setString(name, groupRef.toConfigValue());
+ /** Mutable representation of {@link PluginConfig}. Used for updates. */
+ public static class Update {
+ private final String pluginName;
+ private Config cfg;
+ private final Optional<ProjectConfig> projectConfig;
+
+ public Update(String pluginName, Config cfg, Optional<ProjectConfig> projectConfig) {
+ this.pluginName = pluginName;
+ this.cfg = cfg;
+ this.projectConfig = projectConfig;
+ }
+
+ @VisibleForTesting
+ public static Update forTest(String pluginName, Config cfg) {
+ return new Update(pluginName, cfg, Optional.empty());
+ }
+
+ public PluginConfig asPluginConfig() {
+ return PluginConfig.create(
+ pluginName, cfg, projectConfig.map(ProjectConfig::getCacheable).orElse(null));
+ }
+
+ public String getString(String name) {
+ return cfg.getString(PLUGIN, pluginName, name);
+ }
+
+ public String getString(String name, String defaultValue) {
+ if (defaultValue == null) {
+ return cfg.getString(PLUGIN, pluginName, name);
+ }
+ return MoreObjects.firstNonNull(cfg.getString(PLUGIN, pluginName, name), defaultValue);
+ }
+
+ public String[] getStringList(String name) {
+ return cfg.getStringList(PLUGIN, pluginName, name);
+ }
+
+ public int getInt(String name, int defaultValue) {
+ return cfg.getInt(PLUGIN, pluginName, name, defaultValue);
+ }
+
+ public long getLong(String name, long defaultValue) {
+ return cfg.getLong(PLUGIN, pluginName, name, defaultValue);
+ }
+
+ public boolean getBoolean(String name, boolean defaultValue) {
+ return cfg.getBoolean(PLUGIN, pluginName, name, defaultValue);
+ }
+
+ public <T extends Enum<?>> T getEnum(String name, T defaultValue) {
+ return cfg.getEnum(PLUGIN, pluginName, name, defaultValue);
+ }
+
+ public <T extends Enum<?>> T getEnum(T[] all, String name, T defaultValue) {
+ return cfg.getEnum(all, PLUGIN, pluginName, name, defaultValue);
+ }
+
+ public Set<String> getNames() {
+ return cfg.getNames(PLUGIN, pluginName, true);
+ }
+
+ public void setString(String name, String value) {
+ if (Strings.isNullOrEmpty(value)) {
+ cfg.unset(PLUGIN, pluginName, name);
+ } else {
+ cfg.setString(PLUGIN, pluginName, name, value);
+ }
+ }
+
+ public void setStringList(String name, List<String> values) {
+ if (values == null || values.isEmpty()) {
+ cfg.unset(PLUGIN, pluginName, name);
+ } else {
+ cfg.setStringList(PLUGIN, pluginName, name, values);
+ }
+ }
+
+ public void setInt(String name, int value) {
+ cfg.setInt(PLUGIN, pluginName, name, value);
+ }
+
+ public void setLong(String name, long value) {
+ cfg.setLong(PLUGIN, pluginName, name, value);
+ }
+
+ public void setBoolean(String name, boolean value) {
+ cfg.setBoolean(PLUGIN, pluginName, name, value);
+ }
+
+ public <T extends Enum<?>> void setEnum(String name, T value) {
+ cfg.setEnum(PLUGIN, pluginName, name, value);
+ }
+
+ public void unset(String name) {
+ cfg.unset(PLUGIN, pluginName, name);
+ }
+
+ public void setGroupReference(String name, GroupReference value) {
+ checkState(projectConfig.isPresent(), "no project config provided");
+ GroupReference groupRef = projectConfig.get().resolve(value);
+ setString(name, groupRef.toConfigValue());
+ }
}
}
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 4ba8ef6..a9abd1e 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -111,7 +111,7 @@
cfgSnapshot = FileSnapshot.save(configFile);
cfg = cfgProvider.get();
}
- return new PluginConfig(pluginName, cfg);
+ return PluginConfig.createFromGerritConfig(pluginName, cfg);
}
/**
@@ -150,7 +150,7 @@
* @return the plugin configuration from the 'project.config' file of the specified project
*/
public PluginConfig getFromProjectConfig(ProjectState projectState, String pluginName) {
- return projectState.getBareConfig().getPluginConfig(pluginName);
+ return projectState.getPluginConfig(pluginName);
}
/**
diff --git a/java/com/google/gerrit/server/config/UrlFormatter.java b/java/com/google/gerrit/server/config/UrlFormatter.java
index d3f90e5..5054da6 100644
--- a/java/com/google/gerrit/server/config/UrlFormatter.java
+++ b/java/com/google/gerrit/server/config/UrlFormatter.java
@@ -57,17 +57,10 @@
return getChangeViewUrl(change.getProject(), change.getId()).map(url -> url + "?tab=findings");
}
- /** Returns the URL for viewing a file in a given patch set of a change. */
- default Optional<String> getPatchFileView(Change change, int patchsetId, String filename) {
+ /** Returns the URL for viewing a comment in a file for a change. */
+ default Optional<String> getInlineCommentView(Change change, String uuid) {
return getChangeViewUrl(change.getProject(), change.getId())
- .map(url -> url + "/" + patchsetId + "/" + filename);
- }
-
- /** Returns the URL for viewing a comment in a file in a given patch set of a change. */
- default Optional<String> getInlineCommentView(
- Change change, int patchsetId, String filename, short side, int startLine) {
- return getPatchFileView(change, patchsetId, filename)
- .map(url -> url + String.format("@%s%d", side == 0 ? "a" : "", startLine));
+ .map(url -> url + "/comment/" + uuid);
}
/** Returns a URL pointing to the settings page. */
diff --git a/java/com/google/gerrit/server/data/SubmitLabelAttribute.java b/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
index fec8f7f..a3890c7 100644
--- a/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitLabelAttribute.java
@@ -15,8 +15,8 @@
package com.google.gerrit.server.data;
/**
- * Represents a {@link com.google.gerrit.common.data.SubmitRecord.Label} that does not depend on
- * Gerrit internal classes, to be serialized.
+ * Represents a {@link com.google.gerrit.entities.SubmitRecord.Label} that does not depend on Gerrit
+ * internal classes, to be serialized.
*/
public class SubmitLabelAttribute {
public String label;
diff --git a/java/com/google/gerrit/server/data/SubmitRecordAttribute.java b/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
index 2c3d401..e6c308e 100644
--- a/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRecordAttribute.java
@@ -14,11 +14,12 @@
package com.google.gerrit.server.data;
+import com.google.gerrit.entities.SubmitRecord;
import java.util.List;
/**
- * Represents a {@link com.google.gerrit.common.data.SubmitRecord} that does not depend on Gerrit
- * internal classes, to be serialized.
+ * Represents a {@link SubmitRecord} that does not depend on Gerrit internal classes, to be
+ * serialized.
*/
public class SubmitRecordAttribute {
public String status;
diff --git a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
index 2364ec4..ed4ea8a 100644
--- a/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
+++ b/java/com/google/gerrit/server/data/SubmitRequirementAttribute.java
@@ -14,9 +14,11 @@
package com.google.gerrit.server.data;
+import com.google.gerrit.entities.SubmitRequirement;
+
/**
- * Represents a {@link com.google.gerrit.common.data.SubmitRequirement} that does not depend on
- * Gerrit internal classes, to be serialized
+ * Represents a {@link SubmitRequirement} that does not depend on Gerrit internal classes, to be
+ * serialized
*/
public class SubmitRequirementAttribute {
public String type;
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 48683ea..3bfabdd 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -16,7 +16,7 @@
import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import com.google.common.collect.ImmutableList;
+import com.google.common.base.Charsets;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
@@ -53,8 +53,13 @@
import java.io.IOException;
import java.sql.Timestamp;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.TimeZone;
+import org.eclipse.jgit.diff.DiffAlgorithm;
+import org.eclipse.jgit.diff.DiffAlgorithm.SupportedAlgorithm;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.diff.RawTextComparator;
import org.eclipse.jgit.dircache.InvalidPathException;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.CommitBuilder;
@@ -64,6 +69,9 @@
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.MergeAlgorithm;
+import org.eclipse.jgit.merge.MergeChunk;
+import org.eclipse.jgit.merge.MergeResult;
import org.eclipse.jgit.merge.MergeStrategy;
import org.eclipse.jgit.merge.ThreeWayMerger;
import org.eclipse.jgit.revwalk.RevCommit;
@@ -83,12 +91,12 @@
public class ChangeEditModifier {
private final TimeZone tz;
- private final ChangeIndexer indexer;
private final Provider<CurrentUser> currentUser;
private final PermissionBackend permissionBackend;
private final ChangeEditUtil changeEditUtil;
private final PatchSetUtil patchSetUtil;
private final ProjectCache projectCache;
+ private final NoteDbEdits noteDbEdits;
@Inject
ChangeEditModifier(
@@ -99,13 +107,14 @@
ChangeEditUtil changeEditUtil,
PatchSetUtil patchSetUtil,
ProjectCache projectCache) {
- this.indexer = indexer;
this.currentUser = currentUser;
this.permissionBackend = permissionBackend;
this.tz = gerritIdent.getTimeZone();
this.changeEditUtil = changeEditUtil;
this.patchSetUtil = patchSetUtil;
this.projectCache = projectCache;
+
+ noteDbEdits = new NoteDbEdits(tz, indexer, currentUser);
}
/**
@@ -115,7 +124,6 @@
* @param notes the {@link ChangeNotes} of the change for which the change edit should be created
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws InvalidChangeOperationException if a change edit already existed for the change
- * @throws PermissionBackendException
*/
public void createEdit(Repository repository, ChangeNotes notes)
throws AuthException, IOException, InvalidChangeOperationException,
@@ -130,7 +138,7 @@
PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
ObjectId patchSetCommitId = currentPatchSet.commitId();
- createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
+ noteDbEdits.createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
}
/**
@@ -142,11 +150,9 @@
* @throws InvalidChangeOperationException if a change edit doesn't exist for the specified
* change, the change edit is already based on the latest patch set, or the change represents
* the root commit
- * @throws MergeConflictException if rebase fails due to merge conflicts
- * @throws PermissionBackendException
*/
public void rebaseEdit(Repository repository, ChangeNotes notes)
- throws AuthException, InvalidChangeOperationException, IOException, MergeConflictException,
+ throws AuthException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
assertCanEdit(notes);
@@ -176,8 +182,7 @@
"Rebase change edit against root commit not supported");
}
- Change change = changeEdit.getChange();
- RevCommit basePatchSetCommit = lookupCommit(repository, currentPatchSet);
+ RevCommit basePatchSetCommit = NoteDbEdits.lookupCommit(repository, currentPatchSet.commitId());
RevTree basePatchSetTree = basePatchSetCommit.getTree();
ObjectId newTreeId = merge(repository, changeEdit, basePatchSetTree);
@@ -186,15 +191,8 @@
ObjectId newEditCommitId =
createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
- String newEditRefName = getEditRefName(change, currentPatchSet);
- updateReferenceWithNameChange(
- repository,
- changeEdit.getRefName(),
- currentEditCommit,
- newEditRefName,
- newEditCommitId,
- nowTimestamp);
- reindex(change);
+ noteDbEdits.baseEditOnDifferentPatchset(
+ repository, changeEdit, currentPatchSet, currentEditCommit, newEditCommitId, nowTimestamp);
}
/**
@@ -206,42 +204,17 @@
* modified
* @param newCommitMessage the new commit message
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
- * @throws UnchangedCommitMessageException if the commit message is the same as before
- * @throws PermissionBackendException
+ * @throws InvalidChangeOperationException if the commit message is the same as before
* @throws BadRequestException if the commit message is malformed
*/
public void modifyMessage(Repository repository, ChangeNotes notes, String newCommitMessage)
- throws AuthException, IOException, UnchangedCommitMessageException,
+ throws AuthException, IOException, InvalidChangeOperationException,
PermissionBackendException, BadRequestException, ResourceConflictException {
- assertCanEdit(notes);
- newCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(newCommitMessage);
-
- Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
- PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, notes);
- RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
- RevCommit baseCommit =
- optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
-
- String currentCommitMessage = baseCommit.getFullMessage();
- if (newCommitMessage.equals(currentCommitMessage)) {
- throw new UnchangedCommitMessageException();
- }
-
- RevTree baseTree = baseCommit.getTree();
- Timestamp nowTimestamp = TimeUtil.nowTs();
- ObjectId newEditCommit =
- createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
-
- if (optionalChangeEdit.isPresent()) {
- updateEdit(
- notes.getProjectName(),
- repository,
- optionalChangeEdit.get(),
- newEditCommit,
- nowTimestamp);
- } else {
- createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
- }
+ modifyCommit(
+ repository,
+ notes,
+ new ModificationIntention.LatestCommit(),
+ CommitModification.builder().newCommitMessage(newCommitMessage).build());
}
/**
@@ -255,14 +228,19 @@
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
* @throws InvalidChangeOperationException if the file already had the specified content
- * @throws PermissionBackendException
* @throws ResourceConflictException if the project state does not permit the operation
*/
public void modifyFile(
Repository repository, ChangeNotes notes, String filePath, RawInput newContent)
throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
- modifyTree(repository, notes, new ChangeFileContentModification(filePath, newContent));
+ modifyCommit(
+ repository,
+ notes,
+ new ModificationIntention.LatestCommit(),
+ CommitModification.builder()
+ .addTreeModification(new ChangeFileContentModification(filePath, newContent))
+ .build());
}
/**
@@ -275,13 +253,16 @@
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
* @throws InvalidChangeOperationException if the file does not exist
- * @throws PermissionBackendException
* @throws ResourceConflictException if the project state does not permit the operation
*/
public void deleteFile(Repository repository, ChangeNotes notes, String file)
throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
- modifyTree(repository, notes, new DeleteFileModification(file));
+ modifyCommit(
+ repository,
+ notes,
+ new ModificationIntention.LatestCommit(),
+ CommitModification.builder().addTreeModification(new DeleteFileModification(file)).build());
}
/**
@@ -296,14 +277,19 @@
* @throws BadRequestException if the user provided bad input (e.g. invalid file paths)
* @throws InvalidChangeOperationException if the file was already renamed to the specified new
* name
- * @throws PermissionBackendException
* @throws ResourceConflictException if the project state does not permit the operation
*/
public void renameFile(
Repository repository, ChangeNotes notes, String currentFilePath, String newFilePath)
throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
- modifyTree(repository, notes, new RenameFileModification(currentFilePath, newFilePath));
+ modifyCommit(
+ repository,
+ notes,
+ new ModificationIntention.LatestCommit(),
+ CommitModification.builder()
+ .addTreeModification(new RenameFileModification(currentFilePath, newFilePath))
+ .build());
}
/**
@@ -316,43 +302,17 @@
* @param file the path of the file which should be restored
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws InvalidChangeOperationException if the file was already restored
- * @throws PermissionBackendException
*/
public void restoreFile(Repository repository, ChangeNotes notes, String file)
throws AuthException, BadRequestException, InvalidChangeOperationException, IOException,
PermissionBackendException, ResourceConflictException {
- modifyTree(repository, notes, new RestoreFileModification(file));
- }
-
- private void modifyTree(
- Repository repository, ChangeNotes notes, TreeModification treeModification)
- throws AuthException, BadRequestException, IOException, InvalidChangeOperationException,
- PermissionBackendException, ResourceConflictException {
- assertCanEdit(notes);
-
- Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
- PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, notes);
- RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
- RevCommit baseCommit =
- optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
-
- ObjectId newTreeId = createNewTree(repository, baseCommit, ImmutableList.of(treeModification));
-
- String commitMessage = baseCommit.getFullMessage();
- Timestamp nowTimestamp = TimeUtil.nowTs();
- ObjectId newEditCommit =
- createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
-
- if (optionalChangeEdit.isPresent()) {
- updateEdit(
- notes.getProjectName(),
- repository,
- optionalChangeEdit.get(),
- newEditCommit,
- nowTimestamp);
- } else {
- createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
- }
+ modifyCommit(
+ repository,
+ notes,
+ new ModificationIntention.LatestCommit(),
+ CommitModification.builder()
+ .addTreeModification(new RestoreFileModification(file))
+ .build());
}
/**
@@ -363,7 +323,7 @@
* @param repository the affected Git repository
* @param notes the {@link ChangeNotes} of the change to which the patch set belongs
* @param patchSet the {@code PatchSet} which should be modified
- * @param treeModifications the modifications which should be applied
+ * @param commitModification the modifications which should be applied
* @return the resulting {@code ChangeEdit}
* @throws AuthException if the user isn't authenticated or not allowed to use change edits
* @throws InvalidChangeOperationException if the existing change edit is based on another patch
@@ -375,41 +335,54 @@
Repository repository,
ChangeNotes notes,
PatchSet patchSet,
- List<TreeModification> treeModifications)
+ CommitModification commitModification)
throws AuthException, BadRequestException, IOException, InvalidChangeOperationException,
- MergeConflictException, PermissionBackendException, ResourceConflictException {
+ PermissionBackendException, ResourceConflictException {
+ return modifyCommit(
+ repository, notes, new ModificationIntention.PatchsetCommit(patchSet), commitModification);
+ }
+
+ private ChangeEdit modifyCommit(
+ Repository repository,
+ ChangeNotes notes,
+ ModificationIntention modificationIntention,
+ CommitModification commitModification)
+ throws AuthException, BadRequestException, IOException, InvalidChangeOperationException,
+ PermissionBackendException, ResourceConflictException {
assertCanEdit(notes);
Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
- ensureAllowedPatchSet(notes, optionalChangeEdit, patchSet);
+ EditBehavior editBehavior =
+ optionalChangeEdit
+ .<EditBehavior>map(changeEdit -> new ExistingEditBehavior(changeEdit, noteDbEdits))
+ .orElseGet(() -> new NewEditBehavior(noteDbEdits));
+ ModificationTarget modificationTarget =
+ editBehavior.getModificationTarget(notes, modificationIntention);
- RevCommit patchSetCommit = lookupCommit(repository, patchSet);
- ObjectId newTreeId = createNewTree(repository, patchSetCommit, treeModifications);
+ RevCommit commitToModify = modificationTarget.getCommit(repository);
+ ObjectId newTreeId =
+ createNewTree(repository, commitToModify, commitModification.treeModifications());
+ newTreeId = editBehavior.mergeTreesIfNecessary(repository, newTreeId, commitToModify);
- if (optionalChangeEdit.isPresent()) {
- ChangeEdit changeEdit = optionalChangeEdit.get();
- newTreeId = merge(repository, changeEdit, newTreeId);
- if (ObjectId.isEqual(newTreeId, changeEdit.getEditCommit().getTree())) {
- // Modifications are already contained in the change edit.
- return changeEdit;
- }
+ PatchSet basePatchset = modificationTarget.getBasePatchset();
+ RevCommit basePatchsetCommit = NoteDbEdits.lookupCommit(repository, basePatchset.commitId());
+
+ String newCommitMessage =
+ createNewCommitMessage(editBehavior, commitModification, commitToModify);
+ newCommitMessage = editBehavior.mergeCommitMessageIfNecessary(newCommitMessage, commitToModify);
+
+ Optional<ChangeEdit> unmodifiedEdit =
+ editBehavior.getEditIfNoModification(newTreeId, newCommitMessage);
+ if (unmodifiedEdit.isPresent()) {
+ return unmodifiedEdit.get();
}
- String commitMessage =
- optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(patchSetCommit).getFullMessage();
Timestamp nowTimestamp = TimeUtil.nowTs();
ObjectId newEditCommit =
- createCommit(repository, patchSetCommit, newTreeId, commitMessage, nowTimestamp);
+ createCommit(repository, basePatchsetCommit, newTreeId, newCommitMessage, nowTimestamp);
- if (optionalChangeEdit.isPresent()) {
- return updateEdit(
- notes.getProjectName(),
- repository,
- optionalChangeEdit.get(),
- newEditCommit,
- nowTimestamp);
- }
- return createEdit(repository, notes, patchSet, newEditCommit, nowTimestamp);
+ return editBehavior.updateEditInStorage(
+ repository, notes, basePatchset, newEditCommit, nowTimestamp);
}
private void assertCanEdit(ChangeNotes notes)
@@ -437,40 +410,11 @@
}
}
- private static void ensureAllowedPatchSet(
- ChangeNotes notes, Optional<ChangeEdit> optionalChangeEdit, PatchSet patchSet)
- throws InvalidChangeOperationException {
- if (optionalChangeEdit.isPresent()) {
- ChangeEdit changeEdit = optionalChangeEdit.get();
- if (!isBasedOn(changeEdit, patchSet)) {
- throw new InvalidChangeOperationException(
- String.format(
- "Only the patch set %s on which the existing change edit is based may be modified "
- + "(specified patch set: %s)",
- changeEdit.getBasePatchSet().id(), patchSet.id()));
- }
- } else {
- PatchSet.Id patchSetId = patchSet.id();
- PatchSet.Id currentPatchSetId = notes.getChange().currentPatchSetId();
- if (!patchSetId.equals(currentPatchSetId)) {
- throw new InvalidChangeOperationException(
- String.format(
- "A change edit may only be created for the current patch set %s (and not for %s)",
- currentPatchSetId, patchSetId));
- }
- }
- }
-
private Optional<ChangeEdit> lookupChangeEdit(ChangeNotes notes)
throws AuthException, IOException {
return changeEditUtil.byChange(notes);
}
- private PatchSet getBasePatchSet(Optional<ChangeEdit> optionalChangeEdit, ChangeNotes notes) {
- Optional<PatchSet> editBasePatchSet = optionalChangeEdit.map(ChangeEdit::getBasePatchSet);
- return editBasePatchSet.isPresent() ? editBasePatchSet.get() : lookupCurrentPatchSet(notes);
- }
-
private PatchSet lookupCurrentPatchSet(ChangeNotes notes) {
return patchSetUtil.current(notes);
}
@@ -480,25 +424,16 @@
return editBasePatchSet.id().equals(patchSet.id());
}
- private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
- throws IOException {
- ObjectId patchSetCommitId = patchSet.commitId();
- return lookupCommit(repository, patchSetCommitId);
- }
-
- private static RevCommit lookupCommit(Repository repository, ObjectId commitId)
- throws IOException {
- try (RevWalk revWalk = new RevWalk(repository)) {
- return revWalk.parseCommit(commitId);
- }
- }
-
private static ObjectId createNewTree(
Repository repository, RevCommit baseCommit, List<TreeModification> treeModifications)
throws BadRequestException, IOException, InvalidChangeOperationException {
+ if (treeModifications.isEmpty()) {
+ return baseCommit.getTree();
+ }
+
ObjectId newTreeId;
try {
- TreeCreator treeCreator = new TreeCreator(baseCommit);
+ TreeCreator treeCreator = TreeCreator.basedOn(baseCommit);
treeCreator.addTreeModifications(treeModifications);
newTreeId = treeCreator.createNewTreeAndGetId(repository);
} catch (InvalidPathException e) {
@@ -528,9 +463,28 @@
return threeWayMerger.getResultTreeId();
}
+ private String createNewCommitMessage(
+ EditBehavior editBehavior, CommitModification commitModification, RevCommit commitToModify)
+ throws InvalidChangeOperationException, BadRequestException {
+ if (!commitModification.newCommitMessage().isPresent()) {
+ return editBehavior.getUnmodifiedCommitMessage(commitToModify);
+ }
+
+ String newCommitMessage =
+ CommitMessageUtil.checkAndSanitizeCommitMessage(
+ commitModification.newCommitMessage().get());
+
+ if (newCommitMessage.equals(commitToModify.getFullMessage())) {
+ throw new InvalidChangeOperationException(
+ "New commit message cannot be same as existing commit message");
+ }
+
+ return newCommitMessage;
+ }
+
private ObjectId createCommit(
Repository repository,
- RevCommit basePatchSetCommit,
+ RevCommit basePatchsetCommit,
ObjectId tree,
String commitMessage,
Timestamp timestamp)
@@ -538,8 +492,8 @@
try (ObjectInserter objectInserter = repository.newObjectInserter()) {
CommitBuilder builder = new CommitBuilder();
builder.setTreeId(tree);
- builder.setParentIds(basePatchSetCommit.getParents());
- builder.setAuthor(basePatchSetCommit.getAuthorIdent());
+ builder.setParentIds(basePatchsetCommit.getParents());
+ builder.setAuthor(basePatchsetCommit.getAuthorIdent());
builder.setCommitter(getCommitterIdent(timestamp));
builder.setMessage(commitMessage);
ObjectId newCommitId = objectInserter.insert(builder);
@@ -553,107 +507,330 @@
return user.newCommitterIdent(commitTimestamp, tz);
}
- private ChangeEdit createEdit(
- Repository repository,
- ChangeNotes notes,
- PatchSet basePatchSet,
- ObjectId newEditCommitId,
- Timestamp timestamp)
- throws IOException {
- Change change = notes.getChange();
- String editRefName = getEditRefName(change, basePatchSet);
- updateReference(
- notes.getProjectName(),
- repository,
- editRefName,
- ObjectId.zeroId(),
- newEditCommitId,
- timestamp);
- reindex(change);
+ /**
+ * Strategy to apply depending on the current situation regarding change edits (e.g. creating a
+ * new edit requires different storage modifications than updating an existing edit).
+ */
+ private interface EditBehavior {
- RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
- return new ChangeEdit(change, editRefName, newEditCommit, basePatchSet);
+ ModificationTarget getModificationTarget(
+ ChangeNotes notes, ModificationIntention targetIntention)
+ throws InvalidChangeOperationException;
+
+ ObjectId mergeTreesIfNecessary(
+ Repository repository, ObjectId newTreeId, ObjectId commitToModify)
+ throws IOException, MergeConflictException;
+
+ String getUnmodifiedCommitMessage(RevCommit commitToModify);
+
+ String mergeCommitMessageIfNecessary(String newCommitMessage, RevCommit commitToModify)
+ throws MergeConflictException;
+
+ Optional<ChangeEdit> getEditIfNoModification(ObjectId newTreeId, String newCommitMessage);
+
+ ChangeEdit updateEditInStorage(
+ Repository repository,
+ ChangeNotes notes,
+ PatchSet basePatchSet,
+ ObjectId newEditCommitId,
+ Timestamp timestamp)
+ throws IOException;
}
- private String getEditRefName(Change change, PatchSet basePatchSet) {
- IdentifiedUser me = currentUser.get().asIdentifiedUser();
- return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.id());
- }
+ private static class ExistingEditBehavior implements EditBehavior {
- private ChangeEdit updateEdit(
- Project.NameKey projectName,
- Repository repository,
- ChangeEdit changeEdit,
- ObjectId newEditCommitId,
- Timestamp timestamp)
- throws IOException {
- String editRefName = changeEdit.getRefName();
- RevCommit currentEditCommit = changeEdit.getEditCommit();
- updateReference(
- projectName, repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
- reindex(changeEdit.getChange());
+ private final ChangeEdit changeEdit;
+ private final NoteDbEdits noteDbEdits;
- RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
- return new ChangeEdit(
- changeEdit.getChange(), editRefName, newEditCommit, changeEdit.getBasePatchSet());
- }
+ ExistingEditBehavior(ChangeEdit changeEdit, NoteDbEdits noteDbEdits) {
+ this.changeEdit = changeEdit;
+ this.noteDbEdits = noteDbEdits;
+ }
- private void updateReference(
- Project.NameKey projectName,
- Repository repository,
- String refName,
- ObjectId currentObjectId,
- ObjectId targetObjectId,
- Timestamp timestamp)
- throws IOException {
- RefUpdate ru = repository.updateRef(refName);
- ru.setExpectedOldObjectId(currentObjectId);
- ru.setNewObjectId(targetObjectId);
- ru.setRefLogIdent(getRefLogIdent(timestamp));
- ru.setRefLogMessage("inline edit (amend)", false);
- ru.setForceUpdate(true);
- try (RevWalk revWalk = new RevWalk(repository)) {
- RefUpdate.Result res = ru.update(revWalk);
- String message = "cannot update " + ru.getName() + " in " + projectName + ": " + res;
- if (res == RefUpdate.Result.LOCK_FAILURE) {
- throw new LockFailureException(message, ru);
+ @Override
+ public ModificationTarget getModificationTarget(
+ ChangeNotes notes, ModificationIntention targetIntention)
+ throws InvalidChangeOperationException {
+ ModificationTarget modificationTarget = targetIntention.getTargetWhenEditExists(changeEdit);
+ // It would be better to do this validation in the implementation of the REST endpoints
+ // before calling any write actions on ChangeEditModifier.
+ modificationTarget.ensureTargetMayBeModifiedDespiteExistingEdit(changeEdit);
+ return modificationTarget;
+ }
+
+ @Override
+ public ObjectId mergeTreesIfNecessary(
+ Repository repository, ObjectId newTreeId, ObjectId commitToModify)
+ throws IOException, MergeConflictException {
+ if (ObjectId.isEqual(changeEdit.getEditCommit(), commitToModify)) {
+ return newTreeId;
}
- if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
- throw new IOException(message);
+ return merge(repository, changeEdit, newTreeId);
+ }
+
+ @Override
+ public String getUnmodifiedCommitMessage(RevCommit commitToModify) {
+ return changeEdit.getEditCommit().getFullMessage();
+ }
+
+ @Override
+ public String mergeCommitMessageIfNecessary(String newCommitMessage, RevCommit commitToModify)
+ throws MergeConflictException {
+ if (ObjectId.isEqual(changeEdit.getEditCommit(), commitToModify)) {
+ return newCommitMessage;
}
+ String editCommitMessage = changeEdit.getEditCommit().getFullMessage();
+ if (editCommitMessage.equals(newCommitMessage)) {
+ return editCommitMessage;
+ }
+ return mergeCommitMessage(newCommitMessage, commitToModify, editCommitMessage);
+ }
+
+ private String mergeCommitMessage(
+ String newCommitMessage, RevCommit commitToModify, String editCommitMessage)
+ throws MergeConflictException {
+ MergeAlgorithm mergeAlgorithm =
+ new MergeAlgorithm(DiffAlgorithm.getAlgorithm(SupportedAlgorithm.MYERS));
+ RawText baseMessage = new RawText(commitToModify.getFullMessage().getBytes(Charsets.UTF_8));
+ RawText oldMessage = new RawText(editCommitMessage.getBytes(Charsets.UTF_8));
+ RawText newMessage = new RawText(newCommitMessage.getBytes(Charsets.UTF_8));
+ RawTextComparator textComparator = RawTextComparator.DEFAULT;
+ MergeResult<RawText> mergeResult =
+ mergeAlgorithm.merge(textComparator, baseMessage, oldMessage, newMessage);
+ if (mergeResult.containsConflicts()) {
+ throw new MergeConflictException(
+ "The chosen modification adjusted the commit message. However, the new commit message"
+ + " could not be merged with the commit message of the existing change edit."
+ + " Please manually apply the desired changes to the commit message of the change"
+ + " edit.");
+ }
+
+ StringBuilder resultingCommitMessage = new StringBuilder();
+ for (MergeChunk mergeChunk : mergeResult) {
+ RawText mergedMessagePart = mergeResult.getSequences().get(mergeChunk.getSequenceIndex());
+ resultingCommitMessage.append(
+ mergedMessagePart.getString(mergeChunk.getBegin(), mergeChunk.getEnd(), false));
+ }
+ return resultingCommitMessage.toString();
+ }
+
+ @Override
+ public Optional<ChangeEdit> getEditIfNoModification(
+ ObjectId newTreeId, String newCommitMessage) {
+ if (!ObjectId.isEqual(newTreeId, changeEdit.getEditCommit().getTree())) {
+ return Optional.empty();
+ }
+ if (!Objects.equals(newCommitMessage, changeEdit.getEditCommit().getFullMessage())) {
+ return Optional.empty();
+ }
+ // Modifications are already contained in the change edit.
+ return Optional.of(changeEdit);
+ }
+
+ @Override
+ public ChangeEdit updateEditInStorage(
+ Repository repository,
+ ChangeNotes notes,
+ PatchSet basePatchSet,
+ ObjectId newEditCommitId,
+ Timestamp timestamp)
+ throws IOException {
+ return noteDbEdits.updateEdit(
+ notes.getProjectName(), repository, changeEdit, newEditCommitId, timestamp);
}
}
- private void updateReferenceWithNameChange(
- Repository repository,
- String currentRefName,
- ObjectId currentObjectId,
- String newRefName,
- ObjectId targetObjectId,
- Timestamp timestamp)
- throws IOException {
- BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
- batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
- batchRefUpdate.addCommand(
- new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
- batchRefUpdate.setRefLogMessage("rebase edit", false);
- batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
- try (RevWalk revWalk = new RevWalk(repository)) {
- batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+ private static class NewEditBehavior implements EditBehavior {
+
+ private final NoteDbEdits noteDbEdits;
+
+ NewEditBehavior(NoteDbEdits noteDbEdits) {
+ this.noteDbEdits = noteDbEdits;
}
- for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
- if (cmd.getResult() != ReceiveCommand.Result.OK) {
- throw new IOException("failed: " + cmd);
+
+ @Override
+ public ModificationTarget getModificationTarget(
+ ChangeNotes notes, ModificationIntention targetIntention)
+ throws InvalidChangeOperationException {
+ ModificationTarget modificationTarget = targetIntention.getTargetWhenNoEdit(notes);
+ // It would be better to do this validation in the implementation of the REST endpoints
+ // before calling any write actions on ChangeEditModifier.
+ modificationTarget.ensureNewEditMayBeBasedOnTarget(notes.getChange());
+ return modificationTarget;
+ }
+
+ @Override
+ public ObjectId mergeTreesIfNecessary(
+ Repository repository, ObjectId newTreeId, ObjectId commitToModify) {
+ return newTreeId;
+ }
+
+ @Override
+ public String getUnmodifiedCommitMessage(RevCommit commitToModify) {
+ return commitToModify.getFullMessage();
+ }
+
+ @Override
+ public String mergeCommitMessageIfNecessary(String newCommitMessage, RevCommit commitToModify) {
+ return newCommitMessage;
+ }
+
+ @Override
+ public Optional<ChangeEdit> getEditIfNoModification(
+ ObjectId newTreeId, String newCommitMessage) {
+ return Optional.empty();
+ }
+
+ @Override
+ public ChangeEdit updateEditInStorage(
+ Repository repository,
+ ChangeNotes notes,
+ PatchSet basePatchSet,
+ ObjectId newEditCommitId,
+ Timestamp timestamp)
+ throws IOException {
+ return noteDbEdits.createEdit(repository, notes, basePatchSet, newEditCommitId, timestamp);
+ }
+ }
+
+ private static class NoteDbEdits {
+ private final TimeZone tz;
+ private final ChangeIndexer indexer;
+ private final Provider<CurrentUser> currentUser;
+
+ NoteDbEdits(TimeZone tz, ChangeIndexer indexer, Provider<CurrentUser> currentUser) {
+ this.tz = tz;
+ this.indexer = indexer;
+ this.currentUser = currentUser;
+ }
+
+ ChangeEdit createEdit(
+ Repository repository,
+ ChangeNotes notes,
+ PatchSet basePatchset,
+ ObjectId newEditCommitId,
+ Timestamp timestamp)
+ throws IOException {
+ Change change = notes.getChange();
+ String editRefName = getEditRefName(change, basePatchset);
+ updateReference(
+ notes.getProjectName(),
+ repository,
+ editRefName,
+ ObjectId.zeroId(),
+ newEditCommitId,
+ timestamp);
+ reindex(change);
+
+ RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+ return new ChangeEdit(change, editRefName, newEditCommit, basePatchset);
+ }
+
+ private String getEditRefName(Change change, PatchSet basePatchset) {
+ IdentifiedUser me = currentUser.get().asIdentifiedUser();
+ return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchset.id());
+ }
+
+ ChangeEdit updateEdit(
+ Project.NameKey projectName,
+ Repository repository,
+ ChangeEdit changeEdit,
+ ObjectId newEditCommitId,
+ Timestamp timestamp)
+ throws IOException {
+ String editRefName = changeEdit.getRefName();
+ RevCommit currentEditCommit = changeEdit.getEditCommit();
+ updateReference(
+ projectName, repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
+ reindex(changeEdit.getChange());
+
+ RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+ return new ChangeEdit(
+ changeEdit.getChange(), editRefName, newEditCommit, changeEdit.getBasePatchSet());
+ }
+
+ private void updateReference(
+ Project.NameKey projectName,
+ Repository repository,
+ String refName,
+ ObjectId currentObjectId,
+ ObjectId targetObjectId,
+ Timestamp timestamp)
+ throws IOException {
+ RefUpdate ru = repository.updateRef(refName);
+ ru.setExpectedOldObjectId(currentObjectId);
+ ru.setNewObjectId(targetObjectId);
+ ru.setRefLogIdent(getRefLogIdent(timestamp));
+ ru.setRefLogMessage("inline edit (amend)", false);
+ ru.setForceUpdate(true);
+ try (RevWalk revWalk = new RevWalk(repository)) {
+ RefUpdate.Result res = ru.update(revWalk);
+ String message = "cannot update " + ru.getName() + " in " + projectName + ": " + res;
+ if (res == RefUpdate.Result.LOCK_FAILURE) {
+ throw new LockFailureException(message, ru);
+ }
+ if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
+ throw new IOException(message);
+ }
}
}
- }
- private PersonIdent getRefLogIdent(Timestamp timestamp) {
- IdentifiedUser user = currentUser.get().asIdentifiedUser();
- return user.newRefLogIdent(timestamp, tz);
- }
+ void baseEditOnDifferentPatchset(
+ Repository repository,
+ ChangeEdit changeEdit,
+ PatchSet currentPatchSet,
+ ObjectId currentEditCommit,
+ ObjectId newEditCommitId,
+ Timestamp nowTimestamp)
+ throws IOException {
+ String newEditRefName = getEditRefName(changeEdit.getChange(), currentPatchSet);
+ updateReferenceWithNameChange(
+ repository,
+ changeEdit.getRefName(),
+ currentEditCommit,
+ newEditRefName,
+ newEditCommitId,
+ nowTimestamp);
+ reindex(changeEdit.getChange());
+ }
- private void reindex(Change change) {
- indexer.index(change);
+ private void updateReferenceWithNameChange(
+ Repository repository,
+ String currentRefName,
+ ObjectId currentObjectId,
+ String newRefName,
+ ObjectId targetObjectId,
+ Timestamp timestamp)
+ throws IOException {
+ BatchRefUpdate batchRefUpdate = repository.getRefDatabase().newBatchUpdate();
+ batchRefUpdate.addCommand(new ReceiveCommand(ObjectId.zeroId(), targetObjectId, newRefName));
+ batchRefUpdate.addCommand(
+ new ReceiveCommand(currentObjectId, ObjectId.zeroId(), currentRefName));
+ batchRefUpdate.setRefLogMessage("rebase edit", false);
+ batchRefUpdate.setRefLogIdent(getRefLogIdent(timestamp));
+ try (RevWalk revWalk = new RevWalk(repository)) {
+ batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+ }
+ for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
+ if (cmd.getResult() != ReceiveCommand.Result.OK) {
+ throw new IOException("failed: " + cmd);
+ }
+ }
+ }
+
+ static RevCommit lookupCommit(Repository repository, ObjectId commitId) throws IOException {
+ try (RevWalk revWalk = new RevWalk(repository)) {
+ return revWalk.parseCommit(commitId);
+ }
+ }
+
+ private PersonIdent getRefLogIdent(Timestamp timestamp) {
+ IdentifiedUser user = currentUser.get().asIdentifiedUser();
+ return user.newRefLogIdent(timestamp, tz);
+ }
+
+ private void reindex(Change change) {
+ indexer.index(change);
+ }
}
}
diff --git a/java/com/google/gerrit/server/edit/CommitModification.java b/java/com/google/gerrit/server/edit/CommitModification.java
new file mode 100644
index 0000000..f9ed58e
--- /dev/null
+++ b/java/com/google/gerrit/server/edit/CommitModification.java
@@ -0,0 +1,48 @@
+// 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.edit;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import java.util.Optional;
+
+@AutoValue
+public abstract class CommitModification {
+
+ public abstract ImmutableList<TreeModification> treeModifications();
+
+ public abstract Optional<String> newCommitMessage();
+
+ public static Builder builder() {
+ return new AutoValue_CommitModification.Builder();
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+ public Builder addTreeModification(TreeModification treeModification) {
+ treeModificationsBuilder().add(treeModification);
+ return this;
+ }
+
+ abstract ImmutableList.Builder<TreeModification> treeModificationsBuilder();
+
+ public abstract Builder treeModifications(ImmutableList<TreeModification> treeModifications);
+
+ public abstract Builder newCommitMessage(String newCommitMessage);
+
+ public abstract CommitModification build();
+ }
+}
diff --git a/java/com/google/gerrit/server/edit/ModificationIntention.java b/java/com/google/gerrit/server/edit/ModificationIntention.java
new file mode 100644
index 0000000..531f682
--- /dev/null
+++ b/java/com/google/gerrit/server/edit/ModificationIntention.java
@@ -0,0 +1,72 @@
+// 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.edit;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.notedb.ChangeNotes;
+
+/**
+ * Intended modification target.
+ *
+ * <p>See also {@link ModificationTarget}. Some modifications may have a fixed target (e.g.
+ * suggested fixes of robot comments). For other modifications, the presence of a change edit
+ * influences their target. The latter comes from the REST endpoints of change edits which work no
+ * matter whether a change edit is present or not. If it's not present, a new change edit is created
+ * based on the current patchset. As we don't want to create an "empty" commit for the new change
+ * edit first, we need this class/interface for the flexible handling.
+ */
+interface ModificationIntention {
+
+ ModificationTarget getTargetWhenEditExists(ChangeEdit changeEdit);
+
+ ModificationTarget getTargetWhenNoEdit(ChangeNotes notes);
+
+ /** A specific patchset is the modification target. */
+ class PatchsetCommit implements ModificationIntention {
+
+ private final PatchSet patchSet;
+
+ PatchsetCommit(PatchSet patchSet) {
+ this.patchSet = patchSet;
+ }
+
+ @Override
+ public ModificationTarget getTargetWhenEditExists(ChangeEdit changeEdit) {
+ return new ModificationTarget.PatchsetCommit(patchSet);
+ }
+
+ @Override
+ public ModificationTarget getTargetWhenNoEdit(ChangeNotes notes) {
+ return new ModificationTarget.PatchsetCommit(patchSet);
+ }
+ }
+
+ /**
+ * The latest commit should be the modification target. If a change edit exists, it's considered
+ * to be the latest commit. Otherwise, defer to the latest patchset commit.
+ */
+ class LatestCommit implements ModificationIntention {
+
+ @Override
+ public ModificationTarget getTargetWhenEditExists(ChangeEdit changeEdit) {
+ return new ModificationTarget.EditCommit(changeEdit);
+ }
+
+ @Override
+ public ModificationTarget getTargetWhenNoEdit(ChangeNotes notes) {
+ return new ModificationTarget.PatchsetCommit(notes.getCurrentPatchSet());
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/edit/ModificationTarget.java b/java/com/google/gerrit/server/edit/ModificationTarget.java
new file mode 100644
index 0000000..0de0149
--- /dev/null
+++ b/java/com/google/gerrit/server/edit/ModificationTarget.java
@@ -0,0 +1,140 @@
+// 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.edit;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Target of the modification of a commit.
+ *
+ * <p>This is currently used in the context of change edits which involves both direct actions on
+ * change edits (e.g. creating a change edit; modifying a file of a change edit) as well as indirect
+ * creation/modification of them (e.g. via applying a suggested fix of a robot comment.)
+ *
+ * <p>Depending on the situation and exact action, either an existing {@link ChangeEdit} (-> {@link
+ * EditCommit} or a specific patchset commit (-> {@link PatchsetCommit}) is the target of a
+ * modification.
+ */
+public interface ModificationTarget {
+
+ void ensureNewEditMayBeBasedOnTarget(Change change) throws InvalidChangeOperationException;
+
+ void ensureTargetMayBeModifiedDespiteExistingEdit(ChangeEdit changeEdit)
+ throws InvalidChangeOperationException;
+
+ /** Commit to modify. */
+ RevCommit getCommit(Repository repository) throws IOException;
+
+ /**
+ * Patchset within whose context the modification happens. This also applies to change edits as
+ * each change edit is based on a specific patchset.
+ */
+ PatchSet getBasePatchset();
+
+ /** A specific patchset commit is the target of the modification. */
+ class PatchsetCommit implements ModificationTarget {
+
+ private final PatchSet patchset;
+
+ PatchsetCommit(PatchSet patchset) {
+ this.patchset = patchset;
+ }
+
+ @Override
+ public void ensureTargetMayBeModifiedDespiteExistingEdit(ChangeEdit changeEdit)
+ throws InvalidChangeOperationException {
+ if (!isBasedOn(changeEdit, patchset)) {
+ throw new InvalidChangeOperationException(
+ String.format(
+ "Only the patch set %s on which the existing change edit is based may be modified "
+ + "(specified patch set: %s)",
+ changeEdit.getBasePatchSet().id(), patchset.id()));
+ }
+ }
+
+ private static boolean isBasedOn(ChangeEdit changeEdit, PatchSet patchSet) {
+ PatchSet editBasePatchSet = changeEdit.getBasePatchSet();
+ return editBasePatchSet.id().equals(patchSet.id());
+ }
+
+ @Override
+ public void ensureNewEditMayBeBasedOnTarget(Change change)
+ throws InvalidChangeOperationException {
+ PatchSet.Id patchSetId = patchset.id();
+ PatchSet.Id currentPatchSetId = change.currentPatchSetId();
+ if (!patchSetId.equals(currentPatchSetId)) {
+ throw new InvalidChangeOperationException(
+ String.format(
+ "A change edit may only be created for the current patch set %s (and not for %s)",
+ currentPatchSetId, patchSetId));
+ }
+ }
+
+ @Override
+ public RevCommit getCommit(Repository repository) throws IOException {
+ try (RevWalk revWalk = new RevWalk(repository)) {
+ return revWalk.parseCommit(patchset.commitId());
+ }
+ }
+
+ @Override
+ public PatchSet getBasePatchset() {
+ return patchset;
+ }
+ }
+
+ /** An existing {@link ChangeEdit} commit is the target of the modification. */
+ class EditCommit implements ModificationTarget {
+
+ private final ChangeEdit changeEdit;
+
+ EditCommit(ChangeEdit changeEdit) {
+ this.changeEdit = changeEdit;
+ }
+
+ @Override
+ public void ensureNewEditMayBeBasedOnTarget(Change change) {
+ // The current code will never create a new edit if one already exists. It would be a
+ // programmer error if this changes in the future (without adjusting the storage of change
+ // edits).
+ throw new IllegalStateException(
+ String.format(
+ "Change %d already has a change edit for the calling user. A new change edit can't"
+ + " be created.",
+ changeEdit.getChange().getChangeId()));
+ }
+
+ @Override
+ public void ensureTargetMayBeModifiedDespiteExistingEdit(ChangeEdit changeEdit) {
+ // The target is the change edit and hence can be modified.
+ }
+
+ @Override
+ public RevCommit getCommit(Repository repository) throws IOException {
+ return changeEdit.getEditCommit();
+ }
+
+ @Override
+ public PatchSet getBasePatchset() {
+ return changeEdit.getBasePatchSet();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index 0adacd8..39ab041 100644
--- a/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -18,6 +18,8 @@
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.ByteStreams;
import com.google.gerrit.extensions.restapi.RawInput;
@@ -33,7 +35,6 @@
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
/** A {@code TreeModification} which changes the content of a file. */
public class ChangeFileContentModification implements TreeModification {
@@ -48,14 +49,15 @@
}
@Override
- public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit) {
+ public List<DirCacheEditor.PathEdit> getPathEdits(
+ Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
DirCacheEditor.PathEdit changeContentEdit = new ChangeContent(filePath, newContent, repository);
return Collections.singletonList(changeContentEdit);
}
@Override
- public String getFilePath() {
- return filePath;
+ public ImmutableSet<String> getFilePaths() {
+ return ImmutableSet.of(filePath);
}
@VisibleForTesting
diff --git a/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java b/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
index feffb70..a725257 100644
--- a/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
@@ -14,11 +14,13 @@
package com.google.gerrit.server.edit.tree;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import java.util.Collections;
import java.util.List;
import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
/** A {@code TreeModification} which deletes a file. */
public class DeleteFileModification implements TreeModification {
@@ -30,13 +32,14 @@
}
@Override
- public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit) {
+ public List<DirCacheEditor.PathEdit> getPathEdits(
+ Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents) {
DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(filePath);
return Collections.singletonList(deletePathEdit);
}
@Override
- public String getFilePath() {
- return filePath;
+ public ImmutableSet<String> getFilePaths() {
+ return ImmutableSet.of(filePath);
}
}
diff --git a/java/com/google/gerrit/server/edit/tree/RenameFileModification.java b/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
index b847599..654d904 100644
--- a/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
@@ -14,13 +14,13 @@
package com.google.gerrit.server.edit.tree;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import java.io.IOException;
-import java.util.Arrays;
-import java.util.Collections;
import java.util.List;
import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
@@ -36,25 +36,29 @@
}
@Override
- public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+ public List<DirCacheEditor.PathEdit> getPathEdits(
+ Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
throws IOException {
+ if (ObjectId.zeroId().equals(treeId)) {
+ return ImmutableList.of();
+ }
+
try (RevWalk revWalk = new RevWalk(repository)) {
- revWalk.parseHeaders(baseCommit);
try (TreeWalk treeWalk =
- TreeWalk.forPath(revWalk.getObjectReader(), currentFilePath, baseCommit.getTree())) {
+ TreeWalk.forPath(revWalk.getObjectReader(), currentFilePath, treeId)) {
if (treeWalk == null) {
- return Collections.emptyList();
+ return ImmutableList.of();
}
DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(currentFilePath);
AddPath addPathEdit =
new AddPath(newFilePath, treeWalk.getFileMode(0), treeWalk.getObjectId(0));
- return Arrays.asList(deletePathEdit, addPathEdit);
+ return ImmutableList.of(deletePathEdit, addPathEdit);
}
}
}
@Override
- public String getFilePath() {
- return newFilePath;
+ public ImmutableSet<String> getFilePaths() {
+ return ImmutableSet.of(currentFilePath, newFilePath);
}
}
diff --git a/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java b/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
index 393a866..f6fd0d7 100644
--- a/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
+++ b/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
@@ -14,10 +14,13 @@
package com.google.gerrit.server.edit.tree;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
@@ -36,16 +39,16 @@
}
@Override
- public List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+ public List<DirCacheEditor.PathEdit> getPathEdits(
+ Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
throws IOException {
- if (baseCommit.getParentCount() == 0) {
+ if (parents.isEmpty()) {
DirCacheEditor.DeletePath deletePath = new DirCacheEditor.DeletePath(filePath);
return Collections.singletonList(deletePath);
}
- RevCommit base = baseCommit.getParent(0);
try (RevWalk revWalk = new RevWalk(repository)) {
- revWalk.parseHeaders(base);
+ RevCommit base = revWalk.parseCommit(parents.get(0));
try (TreeWalk treeWalk =
TreeWalk.forPath(revWalk.getObjectReader(), filePath, base.getTree())) {
if (treeWalk == null) {
@@ -60,7 +63,7 @@
}
@Override
- public String getFilePath() {
- return filePath;
+ public ImmutableSet<String> getFilePaths() {
+ return ImmutableSet.of(filePath);
}
}
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index e6caf97..dfc1ffb 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -14,8 +14,10 @@
package com.google.gerrit.server.edit.tree;
+import static com.google.common.collect.ImmutableList.toImmutableList;
import static java.util.Objects.requireNonNull;
+import com.google.common.collect.ImmutableList;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -31,20 +33,41 @@
/**
* A creator for a new Git tree. To create the new tree, the tree of another commit is taken as a
- * basis and modified.
+ * basis and modified. Alternatively, an empty tree can serve as base.
*/
public class TreeCreator {
- private final RevCommit baseCommit;
+ private final ObjectId baseTreeId;
+ private final ImmutableList<? extends ObjectId> baseParents;
private final List<TreeModification> treeModifications = new ArrayList<>();
- public TreeCreator(RevCommit baseCommit) {
- this.baseCommit = requireNonNull(baseCommit, "baseCommit is required");
+ public static TreeCreator basedOn(RevCommit baseCommit) {
+ requireNonNull(baseCommit, "baseCommit is required");
+ return new TreeCreator(baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()));
+ }
+
+ public static TreeCreator basedOnTree(
+ ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
+ requireNonNull(baseTreeId, "baseTreeId is required");
+ return new TreeCreator(baseTreeId, baseParents);
+ }
+
+ public static TreeCreator basedOnEmptyTree() {
+ return new TreeCreator(ObjectId.zeroId(), ImmutableList.of());
+ }
+
+ private TreeCreator(ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
+ this.baseTreeId = requireNonNull(baseTreeId, "baseTree is required");
+ this.baseParents = baseParents;
}
/**
* Apply modifications to the tree which is taken as a basis. If this method is called multiple
- * times, the modifications are applied subsequently in exactly the order they were provided.
+ * times, the modifications are applied subsequently in exactly the order they were provided
+ * (though JGit applies some internal optimizations which involve sorting, too).
+ *
+ * <p><strong>Beware:</strong> All provided {@link TreeModification}s (even from previous calls of
+ * this method) must touch different file paths!
*
* @param treeModifications modifications which should be applied to the base tree
*/
@@ -63,10 +86,33 @@
* @throws IOException if problems arise when accessing the repository
*/
public ObjectId createNewTreeAndGetId(Repository repository) throws IOException {
+ ensureTreeModificationsDoNotTouchSameFiles();
DirCache newTree = createNewTree(repository);
return writeAndGetId(repository, newTree);
}
+ private void ensureTreeModificationsDoNotTouchSameFiles() {
+ // The current implementation of TreeCreator doesn't properly support modifications which touch
+ // the same files even if they are provided in a logical order. One reason for this is that
+ // JGit's DirCache implementation sorts the given path edits which is necessary due to the
+ // nature of the Git index. The internal sorting doesn't seem to be the only issue, though. Even
+ // applying the modifications in batches within different, subsequent DirCaches just held in
+ // memory didn't seem to work. We might need to fully write each batch to disk before creating
+ // the next.
+ ImmutableList<String> filePaths =
+ treeModifications.stream()
+ .flatMap(treeModification -> treeModification.getFilePaths().stream())
+ .collect(toImmutableList());
+ long distinctFilePathNum = filePaths.stream().distinct().count();
+ if (filePaths.size() != distinctFilePathNum) {
+ throw new IllegalStateException(
+ String.format(
+ "TreeModifications must not refer to the same file paths. This would have"
+ + " unexpected/wrong behavior! Found file paths: %s.",
+ filePaths));
+ }
+ }
+
private DirCache createNewTree(Repository repository) throws IOException {
DirCache newTree = readBaseTree(repository);
List<DirCacheEditor.PathEdit> pathEdits = getPathEdits(repository);
@@ -78,8 +124,9 @@
try (ObjectReader objectReader = repository.newObjectReader()) {
DirCache dirCache = DirCache.newInCore();
DirCacheBuilder dirCacheBuilder = dirCache.builder();
- dirCacheBuilder.addTree(
- new byte[0], DirCacheEntry.STAGE_0, objectReader, baseCommit.getTree());
+ if (!ObjectId.zeroId().equals(baseTreeId)) {
+ dirCacheBuilder.addTree(new byte[0], DirCacheEntry.STAGE_0, objectReader, baseTreeId);
+ }
dirCacheBuilder.finish();
return dirCache;
}
@@ -88,7 +135,8 @@
private List<DirCacheEditor.PathEdit> getPathEdits(Repository repository) throws IOException {
List<DirCacheEditor.PathEdit> pathEdits = new ArrayList<>();
for (TreeModification treeModification : treeModifications) {
- pathEdits.addAll(treeModification.getPathEdits(repository, baseCommit));
+ pathEdits.addAll(
+ treeModification.getPathEdits(repository, baseTreeId, ImmutableList.copyOf(baseParents)));
}
return pathEdits;
}
diff --git a/java/com/google/gerrit/server/edit/tree/TreeModification.java b/java/com/google/gerrit/server/edit/tree/TreeModification.java
index 2656707..ba301fc 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeModification.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeModification.java
@@ -14,12 +14,13 @@
package com.google.gerrit.server.edit.tree;
-import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import java.io.IOException;
import java.util.List;
import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
/** A specific modification of a Git tree. */
public interface TreeModification {
@@ -30,20 +31,21 @@
* shouldn't be changed.
*
* @param repository the affected Git repository
- * @param baseCommit the commit to whose tree this modification is applied
+ * @param treeId tree to which the modification is applied. A value of {@code ObjectId.zero()}
+ * indicates an empty tree.
+ * @param parents parent commits of the commit to whose tree this modification is applied
* @return an ordered list of necessary {@code PathEdit}s
* @throws IOException if problems arise when accessing the repository
*/
- List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
+ List<DirCacheEditor.PathEdit> getPathEdits(
+ Repository repository, ObjectId treeId, ImmutableList<? extends ObjectId> parents)
throws IOException;
/**
- * Indicates a file path which is affected by this {@code TreeModification}. If the modification
- * refers to several file paths (e.g. renaming a file), returning either of them is appropriate as
- * long as the returned value is deterministic.
+ * Indicates all file paths affected by this {@code TreeModification}. If the modification refers
+ * to several file paths (e.g. renaming a file), all of them must be returned.
*
- * @return an affected file path
+ * @return all affected file paths
*/
- @VisibleForTesting
- String getFilePath();
+ ImmutableSet<String> getFilePaths();
}
diff --git a/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index 6e43621..eb4d9ee 100644
--- a/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -17,6 +17,7 @@
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.IdentifiedUser;
import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.revwalk.RevCommit;
@@ -28,6 +29,7 @@
public ReceiveCommand command;
public Project project;
public String refName;
+ public Config repoConfig;
public RevWalk revWalk;
public RevCommit commit;
public IdentifiedUser user;
@@ -40,6 +42,7 @@
ReceiveCommand command,
Project project,
String refName,
+ Config repoConfig,
ObjectReader reader,
ObjectId commitId,
IdentifiedUser user)
@@ -48,6 +51,7 @@
this.command = command;
this.project = project;
this.refName = refName;
+ this.repoConfig = repoConfig;
this.revWalk = new RevWalk(reader);
this.commit = revWalk.parseCommit(commitId);
this.user = user;
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index 728dd01..0fcb64e 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -14,6 +14,7 @@
package com.google.gerrit.server.events;
+import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.BranchNameKey;
@@ -111,7 +112,7 @@
}
protected void fireEvent(Change change, ChangeEvent event) throws PermissionBackendException {
- setInstanceId(event);
+ setInstanceIdWhenEmpty(event);
for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
CurrentUser user = c.call(UserScopedEventListener::getUser);
if (isVisibleTo(change, user)) {
@@ -122,7 +123,7 @@
}
protected void fireEvent(Project.NameKey project, ProjectEvent event) {
- setInstanceId(event);
+ setInstanceIdWhenEmpty(event);
for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
CurrentUser user = c.call(UserScopedEventListener::getUser);
@@ -135,7 +136,7 @@
protected void fireEvent(BranchNameKey branchName, RefEvent event)
throws PermissionBackendException {
- setInstanceId(event);
+ setInstanceIdWhenEmpty(event);
for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
CurrentUser user = c.call(UserScopedEventListener::getUser);
if (isVisibleTo(branchName, user)) {
@@ -146,7 +147,7 @@
}
protected void fireEvent(Event event) throws PermissionBackendException {
- setInstanceId(event);
+ setInstanceIdWhenEmpty(event);
for (PluginSetEntryContext<UserScopedEventListener> c : listeners) {
CurrentUser user = c.call(UserScopedEventListener::getUser);
if (isVisibleTo(event, user)) {
@@ -156,8 +157,10 @@
fireEventForUnrestrictedListeners(event);
}
- protected void setInstanceId(Event event) {
- event.instanceId = gerritInstanceId;
+ protected void setInstanceIdWhenEmpty(Event event) {
+ if (Strings.isNullOrEmpty(event.instanceId)) {
+ event.instanceId = gerritInstanceId;
+ }
}
protected boolean isVisibleTo(Project.NameKey project, CurrentUser user) {
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index 19d2f3d..0c3c4fb 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -20,17 +20,17 @@
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.entities.UserIdentity;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.registration.DynamicItem;
diff --git a/java/com/google/gerrit/server/events/EventGsonProvider.java b/java/com/google/gerrit/server/events/EventGsonProvider.java
index 688507b..72cf7be3 100644
--- a/java/com/google/gerrit/server/events/EventGsonProvider.java
+++ b/java/com/google/gerrit/server/events/EventGsonProvider.java
@@ -15,9 +15,8 @@
package com.google.gerrit.server.events;
import com.google.common.base.Supplier;
-import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.change.ChangeKeyAdapter;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.inject.Provider;
@@ -29,8 +28,8 @@
.registerTypeAdapter(Event.class, new EventDeserializer())
.registerTypeAdapter(Supplier.class, new SupplierSerializer())
.registerTypeAdapter(Supplier.class, new SupplierDeserializer())
- .registerTypeAdapter(Change.Key.class, new ChangeKeyAdapter())
.registerTypeAdapter(Project.NameKey.class, new ProjectNameKeyAdapter())
+ .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
.create();
}
}
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index f286eef..1f90187 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -20,11 +20,11 @@
import com.google.common.base.Suppliers;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
diff --git a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
index 72a5176..cce289a 100644
--- a/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
+++ b/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
@@ -14,26 +14,33 @@
package com.google.gerrit.server.fixes;
+import static com.google.common.collect.ImmutableList.toImmutableList;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.groupingBy;
+import com.google.common.collect.ImmutableList;
import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Comment.Range;
import com.google.gerrit.entities.FixReplacement;
+import com.google.gerrit.entities.Patch;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.edit.CommitModification;
import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.patch.MagicFile;
import com.google.gerrit.server.project.ProjectState;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
-import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Repository;
/** An interpreter for {@code FixReplacement}s. */
@@ -60,7 +67,7 @@
* @throws ResourceConflictException if the replacements can't be transformed into {@code
* TreeModification}s
*/
- public List<TreeModification> toTreeModifications(
+ public CommitModification toCommitModification(
Repository repository,
ProjectState projectState,
ObjectId patchSetCommitId,
@@ -72,14 +79,63 @@
Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
fixReplacements.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
- List<TreeModification> treeModifications = new ArrayList<>();
+ CommitModification.Builder modificationBuilder = CommitModification.builder();
for (Map.Entry<String, List<FixReplacement>> entry : fixReplacementsPerFilePath.entrySet()) {
- TreeModification treeModification =
- toTreeModification(
- repository, projectState, patchSetCommitId, entry.getKey(), entry.getValue());
- treeModifications.add(treeModification);
+ if (Objects.equals(entry.getKey(), Patch.COMMIT_MSG)) {
+ String newCommitMessage =
+ getNewCommitMessage(repository, patchSetCommitId, entry.getValue());
+ modificationBuilder.newCommitMessage(newCommitMessage);
+ } else {
+ TreeModification treeModification =
+ toTreeModification(
+ repository, projectState, patchSetCommitId, entry.getKey(), entry.getValue());
+ modificationBuilder.addTreeModification(treeModification);
+ }
}
- return treeModifications;
+ return modificationBuilder.build();
+ }
+
+ private static String getNewCommitMessage(
+ Repository repository, ObjectId patchSetCommitId, List<FixReplacement> fixReplacements)
+ throws ResourceConflictException, IOException {
+ try (ObjectReader reader = repository.newObjectReader()) {
+ // In the magic /COMMIT_MSG file, the actual commit message is placed after some generated
+ // header lines. -> Need to find out to which actual line of the commit message a replacement
+ // refers.
+ MagicFile commitMessageFile = MagicFile.forCommitMessage(reader, patchSetCommitId);
+ int commitMessageStartLine = commitMessageFile.getStartLineOfModifiableContent();
+ // Line numbers are 1-based. -> Add 1 to not move first line.
+ // Move up for any additionally found lines.
+ int necessaryRangeShift = -commitMessageStartLine + 1;
+ ImmutableList<FixReplacement> adjustedReplacements =
+ shiftRangesBy(fixReplacements, necessaryRangeShift);
+ if (referToNonPositiveLine(adjustedReplacements)) {
+ throw new ResourceConflictException(
+ String.format("The header of the %s file cannot be modified.", Patch.COMMIT_MSG));
+ }
+ String commitMessage = commitMessageFile.modifiableContent();
+ return FixCalculator.getNewFileContent(commitMessage, adjustedReplacements);
+ }
+ }
+
+ private static ImmutableList<FixReplacement> shiftRangesBy(
+ List<FixReplacement> fixReplacements, int shiftedAmount) {
+ return fixReplacements.stream()
+ .map(replacement -> shiftRangesBy(replacement, shiftedAmount))
+ .collect(toImmutableList());
+ }
+
+ private static FixReplacement shiftRangesBy(FixReplacement fixReplacement, int shiftedAmount) {
+ Range adjustedRange = new Range(fixReplacement.range);
+ adjustedRange.startLine += shiftedAmount;
+ adjustedRange.endLine += shiftedAmount;
+ return new FixReplacement(fixReplacement.path, adjustedRange, fixReplacement.replacement);
+ }
+
+ private static boolean referToNonPositiveLine(List<FixReplacement> adjustedReplacements) {
+ return adjustedReplacements.stream()
+ .map(replacement -> replacement.range)
+ .anyMatch(range -> range.startLine <= 0);
}
private TreeModification toTreeModification(
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 0f46199..47cbd60 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -265,6 +265,9 @@
RevCommit revertCommit = revWalk.parseCommit(revertCommitId);
Change changeToRevert = notes.getChange();
Change.Id changeId = Change.id(seq.nextChangeId());
+ if (input.workInProgress) {
+ input.notify = NotifyHandling.OWNER;
+ }
NotifyResolver.Result notify =
notifyResolver.resolve(firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
@@ -284,6 +287,7 @@
ccs.remove(user.getAccountId());
ins.setReviewersAndCcs(reviewers, ccs);
ins.setRevertOf(notes.getChangeId());
+ ins.setWorkInProgress(input.workInProgress);
try (BatchUpdate bu = updateFactory.create(notes.getProjectName(), user, ts)) {
bu.setRepository(git, revWalk, oi);
diff --git a/java/com/google/gerrit/server/git/DelegateRepository.java b/java/com/google/gerrit/server/git/DelegateRepository.java
index b61488b..2816429 100644
--- a/java/com/google/gerrit/server/git/DelegateRepository.java
+++ b/java/com/google/gerrit/server/git/DelegateRepository.java
@@ -14,21 +14,50 @@
package com.google.gerrit.server.git;
+import com.google.gerrit.common.UsedAt;
+import java.io.File;
import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
import org.eclipse.jgit.attributes.AttributesNodeProvider;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.errors.AmbiguousObjectException;
+import org.eclipse.jgit.errors.CorruptObjectException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.NoWorkTreeException;
+import org.eclipse.jgit.errors.RevisionSyntaxException;
+import org.eclipse.jgit.events.ListenerList;
+import org.eclipse.jgit.events.RepositoryEvent;
+import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.BaseRepositoryBuilder;
import org.eclipse.jgit.lib.ObjectDatabase;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.RebaseTodoLine;
+import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.RefRename;
+import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.ReflogReader;
import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryState;
import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.util.FS;
/** Wrapper around {@link Repository} that delegates all calls to the wrapped {@link Repository}. */
-class DelegateRepository extends Repository {
+@UsedAt(UsedAt.Project.PLUGIN_HIGH_AVAILABILITY)
+@UsedAt(UsedAt.Project.PLUGIN_MULTI_SITE)
+public class DelegateRepository extends Repository {
- private final Repository delegate;
+ protected final Repository delegate;
- DelegateRepository(Repository delegate) {
+ protected DelegateRepository(Repository delegate) {
super(toBuilder(delegate));
this.delegate = delegate;
}
@@ -87,4 +116,279 @@
return new BaseRepositoryBuilder<>().setFS(repo.getFS()).setGitDir(repo.getDirectory());
}
+
+ @Override
+ public ListenerList getListenerList() {
+ return delegate.getListenerList();
+ }
+
+ @Override
+ public void fireEvent(RepositoryEvent<?> event) {
+ delegate.fireEvent(event);
+ }
+
+ @Override
+ public void create() throws IOException {
+ delegate.create();
+ }
+
+ @Override
+ public File getDirectory() {
+ return delegate.getDirectory();
+ }
+
+ @Override
+ public ObjectInserter newObjectInserter() {
+ return delegate.newObjectInserter();
+ }
+
+ @Override
+ public ObjectReader newObjectReader() {
+ return delegate.newObjectReader();
+ }
+
+ @Override
+ public FS getFS() {
+ return delegate.getFS();
+ }
+
+ @Override
+ @Deprecated
+ public boolean hasObject(AnyObjectId objectId) {
+ return delegate.hasObject(objectId);
+ }
+
+ @Override
+ public ObjectLoader open(AnyObjectId objectId, int typeHint)
+ throws MissingObjectException, IncorrectObjectTypeException, IOException {
+ return delegate.open(objectId, typeHint);
+ }
+
+ @Override
+ public void incrementOpen() {
+ delegate.incrementOpen();
+ }
+
+ @Override
+ public void close() {
+ delegate.close();
+ }
+
+ @Override
+ public String getFullBranch() throws IOException {
+ return delegate.getFullBranch();
+ }
+
+ @Override
+ public String getBranch() throws IOException {
+ return delegate.getBranch();
+ }
+
+ @Override
+ @Deprecated
+ public Map<String, Ref> getAllRefs() {
+ return delegate.getAllRefs();
+ }
+
+ @Override
+ @Deprecated
+ public Map<String, Ref> getTags() {
+ return delegate.getTags();
+ }
+
+ @Override
+ public DirCache lockDirCache() throws NoWorkTreeException, CorruptObjectException, IOException {
+ return delegate.lockDirCache();
+ }
+
+ @Override
+ public void autoGC(ProgressMonitor monitor) {
+ delegate.autoGC(monitor);
+ }
+
+ @Override
+ public Set<ObjectId> getAdditionalHaves() {
+ return delegate.getAdditionalHaves();
+ }
+
+ @Override
+ public Map<AnyObjectId, Set<Ref>> getAllRefsByPeeledObjectId() {
+ return delegate.getAllRefsByPeeledObjectId();
+ }
+
+ @Override
+ public File getIndexFile() throws NoWorkTreeException {
+ return delegate.getIndexFile();
+ }
+
+ @Override
+ public RepositoryState getRepositoryState() {
+ return delegate.getRepositoryState();
+ }
+
+ @Override
+ public boolean isBare() {
+ return delegate.isBare();
+ }
+
+ @Override
+ public File getWorkTree() throws NoWorkTreeException {
+ return delegate.getWorkTree();
+ }
+
+ @Override
+ public String getRemoteName(String refName) {
+ return delegate.getRemoteName(refName);
+ }
+
+ @Override
+ public String getGitwebDescription() throws IOException {
+ return delegate.getGitwebDescription();
+ }
+
+ @Override
+ public Set<String> getRemoteNames() {
+ return delegate.getRemoteNames();
+ }
+
+ @Override
+ public ObjectLoader open(AnyObjectId objectId) throws MissingObjectException, IOException {
+ return delegate.open(objectId);
+ }
+
+ @Override
+ public RefUpdate updateRef(String ref) throws IOException {
+ return delegate.updateRef(ref);
+ }
+
+ @Override
+ public RefUpdate updateRef(String ref, boolean detach) throws IOException {
+ return delegate.updateRef(ref, detach);
+ }
+
+ @Override
+ public RefRename renameRef(String fromRef, String toRef) throws IOException {
+ return delegate.renameRef(fromRef, toRef);
+ }
+
+ @Override
+ public ObjectId resolve(String revstr)
+ throws AmbiguousObjectException, IncorrectObjectTypeException, RevisionSyntaxException,
+ IOException {
+ return delegate.resolve(revstr);
+ }
+
+ @Override
+ public String simplify(String revstr) throws AmbiguousObjectException, IOException {
+ return delegate.simplify(revstr);
+ }
+
+ @Override
+ @Deprecated
+ public Ref peel(Ref ref) {
+ return delegate.peel(ref);
+ }
+
+ @Override
+ public RevCommit parseCommit(AnyObjectId id)
+ throws IncorrectObjectTypeException, IOException, MissingObjectException {
+ return delegate.parseCommit(id);
+ }
+
+ @Override
+ public DirCache readDirCache() throws NoWorkTreeException, CorruptObjectException, IOException {
+ return delegate.readDirCache();
+ }
+
+ @Override
+ public String shortenRemoteBranchName(String refName) {
+ return delegate.shortenRemoteBranchName(refName);
+ }
+
+ @Override
+ public void setGitwebDescription(String description) throws IOException {
+ delegate.setGitwebDescription(description);
+ }
+
+ @Override
+ public String readMergeCommitMsg() throws IOException, NoWorkTreeException {
+ return delegate.readMergeCommitMsg();
+ }
+
+ @Override
+ public void writeMergeCommitMsg(String msg) throws IOException {
+ delegate.writeMergeCommitMsg(msg);
+ }
+
+ @Override
+ public String readCommitEditMsg() throws IOException, NoWorkTreeException {
+ return delegate.readCommitEditMsg();
+ }
+
+ @Override
+ public void writeCommitEditMsg(String msg) throws IOException {
+ delegate.writeCommitEditMsg(msg);
+ }
+
+ @Override
+ public List<ObjectId> readMergeHeads() throws IOException, NoWorkTreeException {
+ return delegate.readMergeHeads();
+ }
+
+ @Override
+ public void writeMergeHeads(List<? extends ObjectId> heads) throws IOException {
+ delegate.writeMergeHeads(heads);
+ }
+
+ @Override
+ public ObjectId readCherryPickHead() throws IOException, NoWorkTreeException {
+ return delegate.readCherryPickHead();
+ }
+
+ @Override
+ public ObjectId readRevertHead() throws IOException, NoWorkTreeException {
+ return delegate.readRevertHead();
+ }
+
+ @Override
+ public void writeCherryPickHead(ObjectId head) throws IOException {
+ delegate.writeCherryPickHead(head);
+ }
+
+ @Override
+ public void writeRevertHead(ObjectId head) throws IOException {
+ delegate.writeRevertHead(head);
+ }
+
+ @Override
+ public void writeOrigHead(ObjectId head) throws IOException {
+ delegate.writeOrigHead(head);
+ }
+
+ @Override
+ public ObjectId readOrigHead() throws IOException, NoWorkTreeException {
+ return delegate.readOrigHead();
+ }
+
+ @Override
+ public String readSquashCommitMsg() throws IOException {
+ return delegate.readSquashCommitMsg();
+ }
+
+ @Override
+ public void writeSquashCommitMsg(String msg) throws IOException {
+ delegate.writeSquashCommitMsg(msg);
+ }
+
+ @Override
+ public List<RebaseTodoLine> readRebaseTodo(String path, boolean includeComments)
+ throws IOException {
+ return delegate.readRebaseTodo(path, includeComments);
+ }
+
+ @Override
+ public void writeRebaseTodoFile(String path, List<RebaseTodoLine> steps, boolean append)
+ throws IOException {
+ delegate.writeRebaseTodoFile(path, steps, append);
+ }
}
diff --git a/java/com/google/gerrit/server/git/GroupCollector.java b/java/com/google/gerrit/server/git/GroupCollector.java
index 9e0f2ee..5bbe5e2 100644
--- a/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/java/com/google/gerrit/server/git/GroupCollector.java
@@ -29,7 +29,6 @@
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.git.receive.ReceivePackRefCache;
@@ -43,7 +42,6 @@
import java.util.Set;
import java.util.TreeSet;
import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.revwalk.RevCommit;
/**
@@ -231,7 +229,7 @@
private boolean isGroupFromExistingPatchSet(RevCommit commit, String group) throws IOException {
ObjectId id = parseGroup(commit, group);
- return id != null && !receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES).isEmpty();
+ return id != null && !receivePackRefCache.patchSetIdsFromObjectId(id).isEmpty();
}
private Set<String> resolveGroups(ObjectId forCommit, Collection<String> candidates)
@@ -273,17 +271,13 @@
private Iterable<String> resolveGroup(ObjectId forCommit, String group) throws IOException {
ObjectId id = parseGroup(forCommit, group);
if (id != null) {
- Ref ref =
- Iterables.getFirst(receivePackRefCache.tipsFromObjectId(id, RefNames.REFS_CHANGES), null);
- if (ref != null) {
- PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
- if (psId != null) {
- List<String> groups = groupLookup.lookup(psId);
- // Group for existing patch set may be missing, e.g. if group has not
- // been migrated yet.
- if (groups != null && !groups.isEmpty()) {
- return groups;
- }
+ PatchSet.Id psId = Iterables.getFirst(receivePackRefCache.patchSetIdsFromObjectId(id), null);
+ if (psId != null) {
+ List<String> groups = groupLookup.lookup(psId);
+ // Group for existing patch set may be missing, e.g. if group has not
+ // been migrated yet.
+ if (groups != null && !groups.isEmpty()) {
+ return groups;
}
}
}
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index dccb97a..8666f26 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -31,12 +31,12 @@
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.FooterConstants;
-import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.exceptions.InvalidMergeStrategyException;
@@ -48,6 +48,7 @@
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.UrlFormatter;
@@ -531,7 +532,7 @@
msgbuf.append('\n');
}
- if (!contains(footers, FooterConstants.CHANGE_ID, c.getKey().get())) {
+ if (ChangeUtil.getChangeIdsFromFooter(n, urlFormatter.get()).isEmpty()) {
msgbuf.append(FooterConstants.CHANGE_ID.getName());
msgbuf.append(": ");
msgbuf.append(c.getKey().get());
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index 766a835..b59d431 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -19,6 +19,7 @@
"//java/com/google/gerrit/metrics",
"//java/com/google/gerrit/server",
"//java/com/google/gerrit/server/logging",
+ "//java/com/google/gerrit/server/restapi",
"//java/com/google/gerrit/server/util/time",
"//java/com/google/gerrit/util/cli",
"//lib:args4j",
diff --git a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
index 7b5f90bd..55261223 100644
--- a/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
+++ b/java/com/google/gerrit/server/git/receive/BranchCommitValidator.java
@@ -37,7 +37,9 @@
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.notes.NoteMap;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.transport.ReceiveCommand;
@@ -94,6 +96,7 @@
/**
* Validates a single commit. If the commit does not validate, the command is rejected.
*
+ * @param repository the repository
* @param objectReader the object reader to use.
* @param cmd the ReceiveCommand executing the push.
* @param commit the commit being validated.
@@ -102,6 +105,7 @@
* @return The validation {@link Result}.
*/
Result validateCommit(
+ Repository repository,
ObjectReader objectReader,
ReceiveCommand cmd,
RevCommit commit,
@@ -109,12 +113,14 @@
NoteMap rejectCommits,
@Nullable Change change)
throws IOException {
- return validateCommit(objectReader, cmd, commit, isMerged, rejectCommits, change, false);
+ return validateCommit(
+ repository, objectReader, cmd, commit, isMerged, rejectCommits, change, false);
}
/**
* Validates a single commit. If the commit does not validate, the command is rejected.
*
+ * @param repository the repository
* @param objectReader the object reader to use.
* @param cmd the ReceiveCommand executing the push.
* @param commit the commit being validated.
@@ -124,6 +130,7 @@
* @return The validation {@link Result}.
*/
Result validateCommit(
+ Repository repository,
ObjectReader objectReader,
ReceiveCommand cmd,
RevCommit commit,
@@ -135,7 +142,14 @@
try (TraceTimer traceTimer = TraceContext.newTimer("BranchCommitValidator#validateCommit")) {
ImmutableList.Builder<CommitValidationMessage> messages = new ImmutableList.Builder<>();
try (CommitReceivedEvent receiveEvent =
- new CommitReceivedEvent(cmd, project, branch.branch(), objectReader, commit, user)) {
+ new CommitReceivedEvent(
+ cmd,
+ project,
+ branch.branch(),
+ new Config(repository.getConfig()),
+ objectReader,
+ commit,
+ user)) {
CommitValidators validators;
if (isMerged) {
validators =
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index 5d36e70..2ec9a8d 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -60,16 +60,15 @@
import com.google.common.collect.SortedSetMultimap;
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.FooterConstants;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetInfo;
import com.google.gerrit.entities.Project;
@@ -113,10 +112,12 @@
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.SetHashtagsOp;
import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.PluginConfig;
import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.config.UrlFormatter;
import com.google.gerrit.server.edit.ChangeEdit;
import com.google.gerrit.server.edit.ChangeEditUtil;
import com.google.gerrit.server.git.BanCommit;
@@ -161,9 +162,9 @@
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.ReplyAttentionSetUpdates;
import com.google.gerrit.server.submit.MergeOp;
import com.google.gerrit.server.submit.MergeOpRepoManager;
-import com.google.gerrit.server.submit.SubmoduleOp;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
@@ -171,12 +172,14 @@
import com.google.gerrit.server.update.RepoContext;
import com.google.gerrit.server.update.RepoOnlyOp;
import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.SubmissionExecutor;
+import com.google.gerrit.server.update.SubmissionListener;
+import com.google.gerrit.server.update.SuperprojectUpdateOnSubmission;
import com.google.gerrit.server.update.UpdateException;
import com.google.gerrit.server.util.LabelVote;
import com.google.gerrit.server.util.MagicBranch;
import com.google.gerrit.server.util.RequestScopePropagator;
import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.server.validators.ValidationException;
import com.google.gerrit.util.cli.CmdLineParser;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -341,10 +344,13 @@
private final RequestScopePropagator requestScopePropagator;
private final Sequences seq;
private final SetHashtagsOp.Factory hashtagsFactory;
- private final SubmoduleOp.Factory subOpFactory;
+ private final SetTopicOp.Factory setTopicFactory;
+ private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
private final TagCache tagCache;
private final ProjectConfig.Factory projectConfigFactory;
private final SetPrivateOp.Factory setPrivateOpFactory;
+ private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
+ private final DynamicItem<UrlFormatter> urlFormatter;
// Assisted injected fields.
private final ProjectState projectState;
@@ -421,9 +427,13 @@
RequestScopePropagator requestScopePropagator,
Sequences seq,
SetHashtagsOp.Factory hashtagsFactory,
- SubmoduleOp.Factory subOpFactory,
+ SetTopicOp.Factory setTopicFactory,
+ @SuperprojectUpdateOnSubmission
+ ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
TagCache tagCache,
SetPrivateOp.Factory setPrivateOpFactory,
+ ReplyAttentionSetUpdates replyAttentionSetUpdates,
+ DynamicItem<UrlFormatter> urlFormatter,
@Assisted ProjectState projectState,
@Assisted IdentifiedUser user,
@Assisted ReceivePack rp,
@@ -445,6 +455,7 @@
this.createGroupPermissionSyncer = createGroupPermissionSyncer;
this.editUtil = editUtil;
this.hashtagsFactory = hashtagsFactory;
+ this.setTopicFactory = setTopicFactory;
this.indexer = indexer;
this.initializers = initializers;
this.mergeOpProvider = mergeOpProvider;
@@ -467,10 +478,12 @@
this.retryHelper = retryHelper;
this.requestScopePropagator = requestScopePropagator;
this.seq = seq;
- this.subOpFactory = subOpFactory;
+ this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
this.tagCache = tagCache;
this.projectConfigFactory = projectConfigFactory;
this.setPrivateOpFactory = setPrivateOpFactory;
+ this.replyAttentionSetUpdates = replyAttentionSetUpdates;
+ this.urlFormatter = urlFormatter;
// Assisted injected fields.
this.projectState = projectState;
@@ -615,7 +628,7 @@
private void processCommandsUnsafe(
Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
logger.atFine().log("Calling user: %s", user.getLoggableName());
- logger.atFine().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+ logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
if (!projectState.getProject().getState().permitsWrite()) {
for (ReceiveCommand cmd : commands) {
@@ -712,12 +725,14 @@
parseRegularCommand(cmd);
}
+ Map<BranchNameKey, ReceiveCommand> branches;
try (BatchUpdate bu =
batchUpdateFactory.create(
project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
ObjectInserter ins = repo.newObjectInserter();
ObjectReader reader = ins.newReader();
- RevWalk rw = new RevWalk(reader)) {
+ RevWalk rw = new RevWalk(reader);
+ MergeOpRepoManager orm = ormProvider.get()) {
bu.setRepository(repo, rw, ins);
bu.setRefLogMessage("push");
@@ -729,46 +744,41 @@
}
}
logger.atFine().log("Added %d additional ref updates", added);
- bu.execute();
+
+ SubmissionExecutor submissionExecutor =
+ new SubmissionExecutor(false, superprojectUpdateSubmissionListeners);
+
+ submissionExecutor.execute(ImmutableList.of(bu));
+
+ orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
+ submissionExecutor.afterExecutions(orm);
+
+ branches = bu.getSuccessfullyUpdatedBranches(false);
} catch (UpdateException | RestApiException e) {
throw new StorageException(e);
}
- Set<BranchNameKey> branches = new HashSet<>();
- for (ReceiveCommand c : cmds) {
- // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
- // should happen in this loops are things that can't happen within one BatchUpdate because
- // they involve kicking off an additional BatchUpdate.
- if (c.getResult() != OK) {
- continue;
- }
- if (isHead(c) || isConfig(c)) {
- switch (c.getType()) {
- case CREATE:
- case UPDATE:
- case UPDATE_NONFASTFORWARD:
- Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
- autoCloseChanges(c, closeProgress);
- closeProgress.end();
- branches.add(BranchNameKey.create(project.getNameKey(), c.getRefName()));
- break;
+ // This could be moved into a SubmissionListener
+ branches.values().stream()
+ .filter(c -> isHead(c) || isConfig(c))
+ .forEach(
+ c -> {
+ // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps
+ // that should happen in this loops are things that can't happen within one
+ // BatchUpdate because they involve kicking off an additional BatchUpdate.
+ switch (c.getType()) {
+ case CREATE:
+ case UPDATE:
+ case UPDATE_NONFASTFORWARD:
+ Task closeProgress = progress.beginSubTask("closed", UNKNOWN);
+ autoCloseChanges(c, closeProgress);
+ closeProgress.end();
+ break;
- case DELETE:
- break;
- }
- }
- }
-
- // Update superproject gitlinks if required.
- if (!branches.isEmpty()) {
- try (MergeOpRepoManager orm = ormProvider.get()) {
- orm.setContext(TimeUtil.nowTs(), user, NotifyResolver.Result.none());
- SubmoduleOp op = subOpFactory.create(branches, orm);
- op.updateSuperProjects();
- } catch (RestApiException e) {
- logger.atWarning().withCause(e).log("Can't update the superprojects");
- }
- }
+ case DELETE:
+ break;
+ }
+ });
}
}
@@ -922,6 +932,19 @@
bu.addOp(
replace.notes.getChangeId(),
publishCommentsOp.create(replace.psId, project.getNameKey()));
+ Optional<ChangeNotes> changeNotes = getChangeNotes(replace.notes.getChangeId());
+ if (!changeNotes.isPresent()) {
+ // If not present, no need to update attention set here since this is a new change.
+ continue;
+ }
+ List<HumanComment> drafts =
+ commentsUtil.draftByChangeAuthor(changeNotes.get(), user.getAccountId());
+ if (drafts.isEmpty()) {
+ // If no comments, attention set shouldn't update since the user didn't reply.
+ continue;
+ }
+ replyAttentionSetUpdates.processAutomaticAttentionSetRulesOnReply(
+ bu, changeNotes.get(), isReadyForReview(changeNotes.get()), user, drafts);
}
}
}
@@ -935,17 +958,9 @@
updateGroups.forEach(r -> r.addOps(bu));
logger.atFine().log("Executing batch");
-
try {
- retryHelper
- .changeUpdate(
- "insertChangesAndPatchSets",
- () -> {
- bu.execute();
- return null;
- })
- .call();
- } catch (Exception e) {
+ bu.execute();
+ } catch (UpdateException e) {
throw asRestApiException(e);
}
@@ -1002,6 +1017,11 @@
}
}
+ private boolean isReadyForReview(ChangeNotes changeNotes) {
+ return (!changeNotes.getChange().isWorkInProgress() && !magicBranch.workInProgress)
+ || magicBranch.ready;
+ }
+
private String buildError(String error, List<String> branches) {
StringBuilder sb = new StringBuilder();
if (branches.size() == 1) {
@@ -1261,15 +1281,11 @@
ProjectConfigEntry configEntry = e.getProvider().get();
String value = pluginCfg.getString(e.getExportName());
String oldValue =
- projectState
- .getBareConfig()
- .getPluginConfig(e.getPluginName())
- .getString(e.getExportName());
+ projectState.getPluginConfig(e.getPluginName()).getString(e.getExportName());
if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
oldValue =
Arrays.stream(
projectState
- .getBareConfig()
.getPluginConfig(e.getPluginName())
.getStringList(e.getExportName()))
.collect(joining("\n"));
@@ -1578,7 +1594,7 @@
name = "--label",
aliases = {"-l"},
metaVar = "LABEL+VALUE",
- usage = "label(s) to assign (defaults to +1 if no value provided")
+ usage = "label(s) to assign (defaults to +1 if no value provided)")
void addLabel(String token) throws CmdLineException {
LabelVote v = LabelVote.parse(token);
try {
@@ -1821,7 +1837,9 @@
magicBranch.perm = permissions.ref(ref);
Optional<AuthException> err =
- checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE);
+ checkRefPermission(magicBranch.perm, RefPermission.READ)
+ .map(Optional::of)
+ .orElse(checkRefPermission(magicBranch.perm, RefPermission.CREATE_CHANGE));
if (err.isPresent()) {
rejectProhibited(cmd, err.get());
return;
@@ -2072,7 +2090,7 @@
} catch (IOException e) {
throw new StorageException("Can't parse commit", e);
}
- List<String> idList = create.commit.getFooterLines(FooterConstants.CHANGE_ID);
+ List<String> idList = ChangeUtil.getChangeIdsFromFooter(create.commit, urlFormatter.get());
if (idList.isEmpty()) {
messages.add(
@@ -2131,15 +2149,15 @@
receivePack.getRevWalk().parseBody(c);
String name = c.name();
groupCollector.visit(c);
- Collection<Ref> existingRefs =
- receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES);
+ Collection<PatchSet.Id> existingPatchSets =
+ receivePackRefCache.patchSetIdsFromObjectId(c);
if (rejectImplicitMerges) {
Collections.addAll(mergedParents, c.getParents());
mergedParents.remove(c);
}
- boolean commitAlreadyTracked = !existingRefs.isEmpty();
+ boolean commitAlreadyTracked = !existingPatchSets.isEmpty();
if (commitAlreadyTracked) {
alreadyTracked++;
// Corner cases where an existing commit might need a new group:
@@ -2155,16 +2173,14 @@
// A's group.
// C) Commit is a PatchSet of a pre-existing change uploaded with a
// different target branch.
- existingRefs.stream()
- .map(r -> PatchSet.Id.fromRef(r.getName()))
- .filter(Objects::nonNull)
+ existingPatchSets.stream()
.forEach(i -> updateGroups.add(new UpdateGroupsRequest(i, c)));
if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
continue;
}
}
- List<String> idList = c.getFooterLines(FooterConstants.CHANGE_ID);
+ List<String> idList = ChangeUtil.getChangeIdsFromFooter(c, urlFormatter.get());
if (!idList.isEmpty()) {
pending.put(c, lookupByChangeKey(c, Change.key(idList.get(idList.size() - 1).trim())));
} else {
@@ -2198,6 +2214,7 @@
BranchCommitValidator.Result validationResult =
validator.validateCommit(
+ repo,
receivePack.getRevWalk().getObjectReader(),
magicBranch.cmd,
c,
@@ -2297,8 +2314,7 @@
// In case the change look up from the index failed,
// double check against the existing refs
- if (foundInExistingRef(
- receivePackRefCache.tipsFromObjectId(p.commit, RefNames.REFS_CHANGES))) {
+ if (foundInExistingPatchSets(receivePackRefCache.patchSetIdsFromObjectId(p.commit))) {
if (pending.size() == 1) {
reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
return Collections.emptyList();
@@ -2346,11 +2362,10 @@
}
}
- private boolean foundInExistingRef(Collection<Ref> existingRefs) {
- try (TraceTimer traceTimer = newTimer("foundInExistingRef")) {
- for (Ref ref : existingRefs) {
- ChangeNotes notes =
- notesFactory.create(project.getNameKey(), Change.Id.fromRef(ref.getName()));
+ private boolean foundInExistingPatchSets(Collection<PatchSet.Id> existingPatchSets) {
+ try (TraceTimer traceTimer = newTimer("foundInExistingPatchSet")) {
+ for (PatchSet.Id psId : existingPatchSets) {
+ ChangeNotes notes = notesFactory.create(project.getNameKey(), psId.changeId());
Change change = notes.getChange();
if (change.getDest().equals(magicBranch.dest)) {
logger.atFine().log("Found change %s from existing refs.", change.getKey());
@@ -2583,7 +2598,7 @@
.setFireEvent(false));
}
if (!Strings.isNullOrEmpty(magicBranch.topic)) {
- bu.addOp(changeId, new SetTopicOp(magicBranch.topic));
+ bu.addOp(changeId, setTopicFactory.create(magicBranch.topic));
}
bu.addOp(
changeId,
@@ -2824,15 +2839,15 @@
return false;
}
- List<Ref> existingChangesWithSameCommit =
- receivePackRefCache.tipsFromObjectId(newCommit, RefNames.REFS_CHANGES);
- if (!existingChangesWithSameCommit.isEmpty()) {
+ List<PatchSet.Id> existingPatchSetsWithSameCommit =
+ receivePackRefCache.patchSetIdsFromObjectId(newCommit);
+ if (!existingPatchSetsWithSameCommit.isEmpty()) {
// TODO(hiesel, hanwen): Remove this check entirely when Gerrit requires change IDs
// without the option to turn that off.
reject(
inputCommand,
"commit already exists (in the project): "
- + existingChangesWithSameCommit.get(0).getName());
+ + existingPatchSetsWithSameCommit.get(0).toRefName());
return false;
}
@@ -3211,13 +3226,13 @@
"more than %d commits, and %s not set", limit, PUSH_OPTION_SKIP_VALIDATION));
return;
}
- if (!receivePackRefCache.tipsFromObjectId(c, RefNames.REFS_CHANGES).isEmpty()) {
+ if (!receivePackRefCache.patchSetIdsFromObjectId(c).isEmpty()) {
continue;
}
BranchCommitValidator.Result validationResult =
validator.validateCommit(
- walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
+ repo, walk.getObjectReader(), cmd, c, false, rejectCommits, null, skipValidation);
messages.addAll(validationResult.messages());
if (!validationResult.isValid()) {
break;
@@ -3280,9 +3295,8 @@
// Check if change refs point to this commit. Usually there are 0-1 change
// refs pointing to this commit.
- for (Ref ref :
- receivePackRefCache.tipsFromObjectId(c.copy(), RefNames.REFS_CHANGES)) {
- PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+ for (PatchSet.Id psId :
+ receivePackRefCache.patchSetIdsFromObjectId(c.copy())) {
Optional<ChangeNotes> notes = getChangeNotes(psId.changeId());
if (notes.isPresent() && notes.get().getChange().getDest().equals(branch)) {
if (submissionId == null) {
@@ -3303,7 +3317,8 @@
}
}
- for (String changeId : c.getFooterLines(FooterConstants.CHANGE_ID)) {
+ for (String changeId :
+ ChangeUtil.getChangeIdsFromFooter(c, urlFormatter.get())) {
if (byKey == null) {
byKey =
retryHelper
@@ -3457,19 +3472,4 @@
b.append(")\n");
return b.toString();
}
-
- private static class SetTopicOp implements BatchUpdateOp {
-
- private final String topic;
-
- public SetTopicOp(String topic) {
- this.topic = topic;
- }
-
- @Override
- public boolean updateChange(ChangeContext ctx) throws ValidationException {
- ctx.getUpdate(ctx.getChange().currentPatchSetId()).setTopic(topic);
- return true;
- }
- }
}
diff --git a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
index 376ab2d..8568810 100644
--- a/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
+++ b/java/com/google/gerrit/server/git/receive/ReceivePackRefCache.java
@@ -21,9 +21,11 @@
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.RefNames;
import java.io.IOException;
import java.util.Map;
+import java.util.Objects;
import org.eclipse.jgit.annotations.Nullable;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
@@ -58,8 +60,8 @@
return new WithAdvertisedRefs(allRefsSupplier);
}
- /** Returns a list of refs whose name starts with {@code prefix} that point to {@code id}. */
- ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix) throws IOException;
+ /** Returns a list of {@link com.google.gerrit.entities.PatchSet.Id}s that point to {@code id}. */
+ ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException;
/** Returns all refs whose name starts with {@code prefix}. */
ImmutableList<Ref> byPrefix(String prefix) throws IOException;
@@ -76,10 +78,10 @@
}
@Override
- public ImmutableList<Ref> tipsFromObjectId(ObjectId id, @Nullable String prefix)
- throws IOException {
+ public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) throws IOException {
return delegate.getTipsWithSha1(id).stream()
- .filter(r -> prefix == null || r.getName().startsWith(prefix))
+ .map(r -> PatchSet.Id.fromRef(r.getName()))
+ .filter(Objects::nonNull)
.collect(toImmutableList());
}
@@ -113,10 +115,11 @@
}
@Override
- public ImmutableList<Ref> tipsFromObjectId(ObjectId id, String prefix) {
+ public ImmutableList<PatchSet.Id> patchSetIdsFromObjectId(ObjectId id) {
lazilyInitRefMaps();
return refsByObjectId.get(id).stream()
- .filter(r -> prefix == null || r.getName().startsWith(prefix))
+ .map(r -> PatchSet.Id.fromRef(r.getName()))
+ .filter(Objects::nonNull)
.collect(toImmutableList());
}
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index b1cb2f9..ce62d7a 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -16,7 +16,6 @@
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
@@ -29,10 +28,10 @@
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.PatchSetInfo;
@@ -41,11 +40,13 @@
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.client.ChangeKind;
import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.server.ApprovalsUtil;
import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.change.AddReviewersOp;
@@ -56,6 +57,7 @@
import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.config.UrlFormatter;
import com.google.gerrit.server.extensions.events.CommentAdded;
import com.google.gerrit.server.extensions.events.RevisionCreated;
import com.google.gerrit.server.git.MergedByPushOp;
@@ -131,6 +133,7 @@
private final ReviewerAdder reviewerAdder;
private final Change change;
private final MessageIdGenerator messageIdGenerator;
+ private final DynamicItem<UrlFormatter> urlFormatter;
private final ProjectState projectState;
private final BranchNameKey dest;
@@ -175,6 +178,7 @@
ReviewerAdder reviewerAdder,
Change change,
MessageIdGenerator messageIdGenerator,
+ DynamicItem<UrlFormatter> urlFormatter,
@Assisted ProjectState projectState,
@Assisted BranchNameKey dest,
@Assisted boolean checkMergedInto,
@@ -202,6 +206,7 @@
this.reviewerAdder = reviewerAdder;
this.change = change;
this.messageIdGenerator = messageIdGenerator;
+ this.urlFormatter = urlFormatter;
this.projectState = projectState;
this.dest = dest;
@@ -348,7 +353,7 @@
return true;
}
- private static ImmutableList<AddReviewerInput> getReviewerInputs(
+ private ImmutableList<AddReviewerInput> getReviewerInputs(
@Nullable MagicBranchInput magicBranch,
MailRecipients fromFooters,
Change change,
@@ -362,13 +367,15 @@
change,
psInfo.getCommitId(),
psInfo.getAuthor().getAccount(),
- NotifyHandling.NONE)),
+ NotifyHandling.NONE,
+ newPatchSet.uploader())),
Streams.stream(
newAddReviewerInputFromCommitIdentity(
change,
psInfo.getCommitId(),
psInfo.getCommitter().getAccount(),
- NotifyHandling.NONE)));
+ NotifyHandling.NONE,
+ newPatchSet.uploader())));
if (magicBranch != null) {
inputs =
Streams.concat(
@@ -481,7 +488,7 @@
change.setStatus(Change.Status.NEW);
change.setCurrentPatchSet(info);
- List<String> idList = commit.getFooterLines(CHANGE_ID);
+ List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
change.setKey(Change.key(idList.get(idList.size() - 1).trim()));
}
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 923ba68..c67df8b 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -34,6 +34,7 @@
import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
@@ -302,7 +303,7 @@
}
RevCommit commit = receiveEvent.commit;
List<CommitValidationMessage> messages = new ArrayList<>();
- List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
+ List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter);
if (idList.isEmpty()) {
String shortMsg = commit.getShortMessage();
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
index b47d7d6..79d53ac 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidationListener.java
@@ -19,6 +19,7 @@
import com.google.gerrit.extensions.annotations.ExtensionPoint;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
import com.google.gerrit.server.project.ProjectState;
import org.eclipse.jgit.lib.Repository;
@@ -33,6 +34,7 @@
* Validate a commit before it is merged.
*
* @param repo the repository
+ * @param revWalk the rev walk
* @param commit commit details
* @param destProject the destination project
* @param destBranch the destination branch
@@ -42,6 +44,7 @@
*/
void onPreMerge(
Repository repo,
+ CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index d1c873a..cbaa121 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -35,6 +35,7 @@
import com.google.gerrit.server.config.PluginConfig;
import com.google.gerrit.server.config.ProjectConfigEntry;
import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -47,10 +48,10 @@
import com.google.inject.Inject;
import java.io.IOException;
import java.util.List;
+import java.util.Objects;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevWalk;
/**
* Collection of validators that run inside Gerrit before a change is submitted. The main purpose is
@@ -91,6 +92,7 @@
*/
public void validatePreMerge(
Repository repo,
+ CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
@@ -105,7 +107,7 @@
groupValidatorFactory.create());
for (MergeValidationListener validator : validators) {
- validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
+ validator.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller);
}
}
@@ -167,11 +169,12 @@
@Override
public void onPreMerge(
- final Repository repo,
- final CodeReviewCommit commit,
- final ProjectState destProject,
- final BranchNameKey destBranch,
- final PatchSet.Id patchSetId,
+ Repository repo,
+ CodeReviewRevWalk revWalk,
+ CodeReviewCommit commit,
+ ProjectState destProject,
+ BranchNameKey destBranch,
+ PatchSet.Id patchSetId,
IdentifiedUser caller)
throws MergeValidationException {
if (RefNames.REFS_CONFIG.equals(destBranch.branch())) {
@@ -228,13 +231,9 @@
String value = pluginCfg.getString(e.getExportName());
String oldValue =
- destProject
- .getBareConfig()
- .getPluginConfig(e.getPluginName())
- .getString(e.getExportName());
+ destProject.getPluginConfig(e.getPluginName()).getString(e.getExportName());
- if ((value == null ? oldValue != null : !value.equals(oldValue))
- && !configEntry.isEditable(destProject)) {
+ if ((!Objects.equals(value, oldValue)) && !configEntry.isEditable(destProject)) {
throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
}
@@ -263,6 +262,7 @@
@Override
public void onPreMerge(
Repository repo,
+ CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
@@ -270,7 +270,7 @@
IdentifiedUser caller)
throws MergeValidationException {
mergeValidationListeners.runEach(
- l -> l.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller),
+ l -> l.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller),
MergeValidationException.class);
}
}
@@ -297,6 +297,7 @@
@Override
public void onPreMerge(
Repository repo,
+ CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
@@ -319,8 +320,9 @@
throw new MergeValidationException("account validation unavailable");
}
- try (RevWalk rw = new RevWalk(repo)) {
- List<String> errorMessages = accountValidator.validate(accountId, repo, rw, null, commit);
+ try {
+ List<String> errorMessages =
+ accountValidator.validate(accountId, repo, revWalk, null, commit);
if (!errorMessages.isEmpty()) {
throw new MergeValidationException(
"invalid account configuration: " + Joiner.on("; ").join(errorMessages));
@@ -348,6 +350,7 @@
@Override
public void onPreMerge(
Repository repo,
+ CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
diff --git a/java/com/google/gerrit/server/git/validators/ValidationMessage.java b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
index faf29fe..b5d7eb1 100644
--- a/java/com/google/gerrit/server/git/validators/ValidationMessage.java
+++ b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
@@ -14,12 +14,15 @@
package com.google.gerrit.server.git.validators;
+import java.util.Objects;
+
/**
* Message used as result of a validation that run during a git operation (for example {@code git
* push}. Intended to be shown to users.
*/
public class ValidationMessage {
public enum Type {
+ FATAL("FATAL: "),
ERROR("ERROR: "),
WARNING("WARNING: "),
HINT("hint: "),
@@ -68,6 +71,25 @@
* Returns {@true} if this message is an error. Used to decide if the operation should be aborted.
*/
public boolean isError() {
- return type == Type.ERROR;
+ return type == Type.FATAL || type == Type.ERROR;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(message, type);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof ValidationMessage) {
+ ValidationMessage other = (ValidationMessage) obj;
+ return Objects.equals(message, other.message) && Objects.equals(type, other.type);
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return getType() + ": " + getMessage();
}
}
diff --git a/java/com/google/gerrit/server/group/db/RenameGroupOp.java b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
index 45dcdfc..843b346 100644
--- a/java/com/google/gerrit/server/group/db/RenameGroupOp.java
+++ b/java/com/google/gerrit/server/group/db/RenameGroupOp.java
@@ -18,12 +18,12 @@
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.CachedProjectConfig;
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.git.DefaultQueueOp;
import com.google.gerrit.server.git.WorkQueue;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.project.CachedProjectConfig;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
index 8b7055e..35f18a2 100644
--- a/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
+++ b/java/com/google/gerrit/server/group/testing/TestGroupBackend.java
@@ -18,6 +18,7 @@
import static java.util.Objects.requireNonNull;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.GroupReference;
@@ -34,6 +35,7 @@
private static final String PREFIX = "testbackend:";
private final Map<AccountGroup.UUID, GroupDescription.Basic> groups = new HashMap<>();
+ private final Map<Account.Id, GroupMembership> memberships = new HashMap<>();
/**
* Create a group by name.
@@ -92,6 +94,14 @@
groups.remove(uuid);
}
+ /**
+ * Makes this backend return the specified {@link GroupMembership} when being asked for the
+ * specified {@link com.google.gerrit.entities.Account.Id}.
+ */
+ public void setMembershipsOf(Account.Id user, GroupMembership membership) {
+ memberships.put(user, membership);
+ }
+
@Override
public boolean handles(AccountGroup.UUID uuid) {
if (uuid != null) {
@@ -113,7 +123,7 @@
@Override
public GroupMembership membershipsOf(IdentifiedUser user) {
- return GroupMembership.EMPTY;
+ return memberships.getOrDefault(user.getAccountId(), GroupMembership.EMPTY);
}
@Override
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index 9e3d91c..ee8dfc8 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -30,7 +30,7 @@
import com.google.gerrit.server.index.account.AccountField;
import com.google.gerrit.server.index.group.GroupField;
import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
import java.io.IOException;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -107,7 +107,7 @@
if (user.isIdentifiedUser()) {
return user.getAccountId().toString();
}
- if (user instanceof SingleGroupUser) {
+ if (user instanceof GroupBackedUser) {
return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
}
return user.toString();
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index e9349c4..832dca6 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -311,7 +311,7 @@
}
}
- private void fail(String error, boolean failed, Exception e) {
+ private void fail(String error, boolean failed, Throwable e) {
if (failed) {
this.failed.update(1);
}
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 160ac14..ef538cb 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -42,8 +42,6 @@
import com.google.common.flogger.FluentLogger;
import com.google.common.io.Files;
import com.google.common.primitives.Longs;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
@@ -52,6 +50,8 @@
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.entities.converter.ChangeProtoConverter;
import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
import com.google.gerrit.entities.converter.PatchSetProtoConverter;
diff --git a/java/com/google/gerrit/server/index/change/StalenessChecker.java b/java/com/google/gerrit/server/index/change/StalenessChecker.java
index a1c6286..7e50104 100644
--- a/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -26,7 +26,6 @@
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
-import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.Change;
@@ -201,7 +200,8 @@
// Quote everything except the '*'s, which become ".*".
String regex =
- Streams.stream(Splitter.on('*').split(pattern))
+ Splitter.on('*')
+ .splitToStream(pattern)
.map(Pattern::quote)
.collect(joining(".*", "^", "$"));
return new AutoValue_StalenessChecker_RefStatePattern(
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 36c7e9e..a1b807d 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -42,9 +42,21 @@
private static final ThreadLocal<MutableTags> tags = new ThreadLocal<>();
private static final ThreadLocal<Boolean> forceLogging = new ThreadLocal<>();
private static final ThreadLocal<Boolean> performanceLogging = new ThreadLocal<>();
+ private static final ThreadLocal<Boolean> aclLogging = new ThreadLocal<>();
+
+ /**
+ * When copying the logging context to a new thread we need to ensure that the mutable log records
+ * (performance logs and ACL logs) that are added in the new thread are added to the same multable
+ * log records instance (see {@link LoggingContextAwareRunnable} and {@link
+ * LoggingContextAwareCallable}). This is important since performance log records are processed
+ * only at the end of the request and performance log records that are created in another thread
+ * should not get lost.
+ */
private static final ThreadLocal<MutablePerformanceLogRecords> performanceLogRecords =
new ThreadLocal<>();
+ private static final ThreadLocal<MutableAclLogRecords> aclLogRecords = new ThreadLocal<>();
+
private LoggingContext() {}
/** This method is expected to be called via reflection (and might otherwise be unused). */
@@ -57,13 +69,10 @@
return runnable;
}
- // Pass the MutablePerformanceLogRecords instance into the LoggingContextAwareRunnable
- // constructor so that performance log records that are created in the wrapped runnable are
- // added to this MutablePerformanceLogRecords instance. This is important since performance
- // log records are processed only at the end of the request and performance log records that
- // are created in another thread should not get lost.
return new LoggingContextAwareRunnable(
- runnable, getInstance().getMutablePerformanceLogRecords());
+ runnable,
+ getInstance().getMutablePerformanceLogRecords(),
+ getInstance().getMutableAclRecords());
}
public static <T> Callable<T> copy(Callable<T> callable) {
@@ -71,20 +80,19 @@
return callable;
}
- // Pass the MutablePerformanceLogRecords instance into the LoggingContextAwareCallable
- // constructor so that performance log records that are created in the wrapped runnable are
- // added to this MutablePerformanceLogRecords instance. This is important since performance
- // log records are processed only at the end of the request and performance log records that
- // are created in another thread should not get lost.
return new LoggingContextAwareCallable<>(
- callable, getInstance().getMutablePerformanceLogRecords());
+ callable,
+ getInstance().getMutablePerformanceLogRecords(),
+ getInstance().getMutableAclRecords());
}
public boolean isEmpty() {
return tags.get() == null
&& forceLogging.get() == null
&& performanceLogging.get() == null
- && performanceLogRecords.get() == null;
+ && performanceLogRecords.get() == null
+ && aclLogging.get() == null
+ && aclLogRecords.get() == null;
}
public void clear() {
@@ -92,6 +100,8 @@
forceLogging.remove();
performanceLogging.remove();
performanceLogRecords.remove();
+ aclLogging.remove();
+ aclLogRecords.remove();
}
@Override
@@ -233,12 +243,7 @@
* <p><strong>Attention:</strong> The passed in {@link MutablePerformanceLogRecords} instance is
* directly stored in the logging context.
*
- * <p>This method is intended to be only used when the logging context is copied to a new thread
- * to ensure that the performance log records that are added in the new thread are added to the
- * same {@link MutablePerformanceLogRecords} instance (see {@link LoggingContextAwareRunnable} and
- * {@link LoggingContextAwareCallable}). This is important since performance log records are
- * processed only at the end of the request and performance log records that are created in
- * another thread should not get lost.
+ * <p>This method is intended to be only used when the logging context is copied to a new thread.
*
* @param mutablePerformanceLogRecords the {@link MutablePerformanceLogRecords} instance in which
* performance log records should be stored
@@ -256,6 +261,101 @@
return records;
}
+ public boolean isAclLogging() {
+ Boolean isAclLogging = aclLogging.get();
+ return isAclLogging != null ? isAclLogging : false;
+ }
+
+ /**
+ * Enables ACL logging.
+ *
+ * <p>It's important to enable ACL logging only in a context that ensures to consume the captured
+ * ACL log records. Otherwise captured ACL log records might leak into other requests that are
+ * executed by the same thread (if a thread pool is used to process requests).
+ *
+ * @param enable whether ACL logging should be enabled.
+ * @return whether ACL logging was be enabled before invoking this method (old value).
+ */
+ boolean aclLogging(boolean enable) {
+ Boolean oldValue = aclLogging.get();
+ if (enable) {
+ aclLogging.set(true);
+ } else {
+ aclLogging.remove();
+ }
+ return oldValue != null ? oldValue : false;
+ }
+
+ /**
+ * Adds an ACL log record.
+ *
+ * @param aclLogRecord ACL log record
+ */
+ public void addAclLogRecord(String aclLogRecord) {
+ if (!isAclLogging()) {
+ return;
+ }
+
+ getMutableAclRecords().add(aclLogRecord);
+ }
+
+ ImmutableList<String> getAclLogRecords() {
+ MutableAclLogRecords records = aclLogRecords.get();
+ if (records != null) {
+ return records.list();
+ }
+ return ImmutableList.of();
+ }
+
+ void clearAclLogEntries() {
+ aclLogRecords.remove();
+ }
+
+ /**
+ * Set the ACL log records in this logging context. Existing log records are overwritten.
+ *
+ * <p>This method makes a defensive copy of the passed in list.
+ *
+ * @param newAclLogRecords ACL log records that should be set
+ */
+ void setAclLogRecords(List<String> newAclLogRecords) {
+ if (newAclLogRecords.isEmpty()) {
+ aclLogRecords.remove();
+ return;
+ }
+
+ getMutableAclRecords().set(newAclLogRecords);
+ }
+
+ /**
+ * Sets a {@link MutableAclLogRecords} instance for storing ACL log records.
+ *
+ * <p><strong>Attention:</strong> The passed in {@link MutableAclLogRecords} instance is directly
+ * stored in the logging context.
+ *
+ * <p>This method is intended to be only used when the logging context is copied to a new thread
+ * to ensure that the ACL log records that are added in the new thread are added to the same
+ * {@link MutableAclLogRecords} instance (see {@link LoggingContextAwareRunnable} and {@link
+ * LoggingContextAwareCallable}). This is important since ACL log records are processed only at
+ * the end of the request and ACL log records that are created in another thread should not get
+ * lost.
+ *
+ * @param mutableAclLogRecords the {@link MutableAclLogRecords} instance in which ACL log records
+ * should be stored
+ */
+ void setMutableAclLogRecords(MutableAclLogRecords mutableAclLogRecords) {
+ aclLogRecords.set(requireNonNull(mutableAclLogRecords));
+ }
+
+ private MutableAclLogRecords getMutableAclRecords() {
+ MutableAclLogRecords records = aclLogRecords.get();
+ if (records == null) {
+ records = new MutableAclLogRecords();
+ aclLogRecords.set(records);
+ }
+ return records;
+ }
+
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
@@ -263,6 +363,8 @@
.add("forceLogging", forceLogging.get())
.add("performanceLogging", performanceLogging.get())
.add("performanceLogRecords", performanceLogRecords.get())
+ .add("aclLogging", aclLogging.get())
+ .add("aclLogRecords", aclLogRecords.get())
.toString();
}
}
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
index d2701d7..ab5db02 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareCallable.java
@@ -40,6 +40,8 @@
private final boolean forceLogging;
private final boolean performanceLogging;
private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+ private final boolean aclLogging;
+ private final MutableAclLogRecords mutableAclLogRecords;
/**
* Creates a LoggingContextAwareCallable that wraps the given {@link Callable}.
@@ -47,15 +49,21 @@
* @param callable Callable that should be wrapped.
* @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
* performance log records that are created from the runnable are added
+ * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+ * that are created from the runnable are added
*/
LoggingContextAwareCallable(
- Callable<T> callable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+ Callable<T> callable,
+ MutablePerformanceLogRecords mutablePerformanceLogRecords,
+ MutableAclLogRecords mutableAclLogRecords) {
this.callable = callable;
this.callingThread = Thread.currentThread();
this.tags = LoggingContext.getInstance().getTagsAsMap();
this.forceLogging = LoggingContext.getInstance().isLoggingForced();
this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+ this.aclLogging = LoggingContext.getInstance().isAclLogging();
+ this.mutableAclLogRecords = mutableAclLogRecords;
}
@Override
@@ -75,14 +83,9 @@
loggingCtx.setTags(tags);
loggingCtx.forceLogging(forceLogging);
loggingCtx.performanceLogging(performanceLogging);
-
- // For the performance log records use the {@link MutablePerformanceLogRecords} instance from
- // the logging context of the calling thread in the logging context of the new thread. This way
- // performance log records that are created from the new thread are available from the logging
- // context of the calling thread. This is important since performance log records are processed
- // only at the end of the request and performance log records that are created in another thread
- // should not get lost.
loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+ loggingCtx.aclLogging(aclLogging);
+ loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
try {
return callable.call();
} finally {
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index 23162b1..3c4c563 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -58,6 +58,8 @@
private final boolean forceLogging;
private final boolean performanceLogging;
private final MutablePerformanceLogRecords mutablePerformanceLogRecords;
+ private final boolean aclLogging;
+ private final MutableAclLogRecords mutableAclLogRecords;
/**
* Creates a LoggingContextAwareRunnable that wraps the given {@link Runnable}.
@@ -65,15 +67,21 @@
* @param runnable Runnable that should be wrapped.
* @param mutablePerformanceLogRecords instance of {@link MutablePerformanceLogRecords} to which
* performance log records that are created from the runnable are added
+ * @param mutableAclLogRecords instance of {@link MutableAclLogRecords} to which ACL log records
+ * that are created from the runnable are added
*/
LoggingContextAwareRunnable(
- Runnable runnable, MutablePerformanceLogRecords mutablePerformanceLogRecords) {
+ Runnable runnable,
+ MutablePerformanceLogRecords mutablePerformanceLogRecords,
+ MutableAclLogRecords mutableAclLogRecords) {
this.runnable = runnable;
this.callingThread = Thread.currentThread();
this.tags = LoggingContext.getInstance().getTagsAsMap();
this.forceLogging = LoggingContext.getInstance().isLoggingForced();
this.performanceLogging = LoggingContext.getInstance().isPerformanceLogging();
this.mutablePerformanceLogRecords = mutablePerformanceLogRecords;
+ this.aclLogging = LoggingContext.getInstance().isAclLogging();
+ this.mutableAclLogRecords = mutableAclLogRecords;
}
public Runnable unwrap() {
@@ -98,14 +106,9 @@
loggingCtx.setTags(tags);
loggingCtx.forceLogging(forceLogging);
loggingCtx.performanceLogging(performanceLogging);
-
- // For the performance log records use the {@link MutablePerformanceLogRecords} instance from
- // the logging context of the calling thread in the logging context of the new thread. This way
- // performance log records that are created from the new thread are available from the logging
- // context of the calling thread. This is important since performance log records are processed
- // only at the end of the request and performance log records that are created in another thread
- // should not get lost.
loggingCtx.setMutablePerformanceLogRecords(mutablePerformanceLogRecords);
+ loggingCtx.aclLogging(aclLogging);
+ loggingCtx.setMutableAclLogRecords(mutableAclLogRecords);
try {
runnable.run();
} finally {
diff --git a/java/com/google/gerrit/server/logging/MutableAclLogRecords.java b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
new file mode 100644
index 0000000..baa9b1f
--- /dev/null
+++ b/java/com/google/gerrit/server/logging/MutableAclLogRecords.java
@@ -0,0 +1,52 @@
+// 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.logging;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe store for ACL log records.
+ *
+ * <p>This class is intended to keep track of user ACL records in {@link LoggingContext}. It needs
+ * to be thread-safe because it gets shared between threads when the logging context is copied to
+ * another thread (see {@link LoggingContextAwareRunnable} and {@link LoggingContextAwareCallable}.
+ * In this case the logging contexts of both threads share the same instance of this class. This is
+ * important since ACL log records are processed only at the end of a request and user ACL records
+ * that are created in another thread should not get lost.
+ */
+public class MutableAclLogRecords {
+ private final ArrayList<String> aclLogRecords = new ArrayList<>();
+
+ public synchronized void add(String record) {
+ aclLogRecords.add(record);
+ }
+
+ public synchronized void set(List<String> records) {
+ aclLogRecords.clear();
+ aclLogRecords.addAll(records);
+ }
+
+ public synchronized ImmutableList<String> list() {
+ return ImmutableList.copyOf(aclLogRecords);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this).add("aclLogRecords", aclLogRecords).toString();
+ }
+}
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 21a4ce6..2fc19b5 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -19,6 +19,7 @@
import com.google.common.base.Stopwatch;
import com.google.common.base.Strings;
import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Table;
import com.google.common.flogger.FluentLogger;
@@ -222,9 +223,17 @@
// Table<TAG_NAME, TAG_VALUE, REMOVE_ON_CLOSE>
private final Table<String, String, Boolean> tags = HashBasedTable.create();
- private boolean stopForceLoggingOnClose;
+ private final boolean oldAclLogging;
+ private final ImmutableList<String> oldAclLogRecords;
- private TraceContext() {}
+ private boolean stopForceLoggingOnClose;
+ private boolean stopAclLoggingOnClose;
+
+ private TraceContext() {
+ // Just in case remember the old state and reset ACL log entries.
+ this.oldAclLogging = LoggingContext.getInstance().isAclLogging();
+ this.oldAclLogRecords = LoggingContext.getInstance().getAclLogRecords();
+ }
public TraceContext addTag(RequestId.Type requestId, Object tagValue) {
return addTag(requireNonNull(requestId, "request ID is required").name(), tagValue);
@@ -265,6 +274,23 @@
.findFirst();
}
+ public TraceContext enableAclLogging() {
+ if (stopAclLoggingOnClose) {
+ return this;
+ }
+
+ stopAclLoggingOnClose = !LoggingContext.getInstance().aclLogging(true);
+ return this;
+ }
+
+ public boolean isAclLoggingEnabled() {
+ return LoggingContext.getInstance().isAclLogging();
+ }
+
+ public ImmutableList<String> getAclLogRecords() {
+ return LoggingContext.getInstance().getAclLogRecords();
+ }
+
@Override
public void close() {
for (Table.Cell<String, String, Boolean> cell : tags.cellSet()) {
@@ -275,5 +301,10 @@
if (stopForceLoggingOnClose) {
LoggingContext.getInstance().forceLogging(false);
}
+
+ if (stopAclLoggingOnClose) {
+ LoggingContext.getInstance().aclLogging(oldAclLogging);
+ LoggingContext.getInstance().setAclLogRecords(oldAclLogRecords);
+ }
}
}
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index cc3db75..ff166b1 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -18,6 +18,7 @@
import com.google.gerrit.server.mail.send.AbandonedSender;
import com.google.gerrit.server.mail.send.AddKeySender;
import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
import com.google.gerrit.server.mail.send.CommentSender;
import com.google.gerrit.server.mail.send.CreateChangeSender;
import com.google.gerrit.server.mail.send.DeleteKeySender;
@@ -26,6 +27,7 @@
import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
import com.google.gerrit.server.mail.send.MergedSender;
import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
import com.google.gerrit.server.mail.send.RestoredSender;
import com.google.gerrit.server.mail.send.RevertedSender;
@@ -49,5 +51,7 @@
factory(RestoredSender.Factory.class);
factory(RevertedSender.Factory.class);
factory(SetAssigneeSender.Factory.class);
+ factory(AddToAttentionSetSender.Factory.class);
+ factory(RemoveFromAttentionSetSender.Factory.class);
}
}
diff --git a/java/com/google/gerrit/server/mail/EmailSettings.java b/java/com/google/gerrit/server/mail/EmailSettings.java
index 6bdb076..15b61d0 100644
--- a/java/com/google/gerrit/server/mail/EmailSettings.java
+++ b/java/com/google/gerrit/server/mail/EmailSettings.java
@@ -37,6 +37,8 @@
public final String password;
public final Encryption encryption;
public final long fetchInterval; // in milliseconds
+ public final boolean sendNewPatchsetEmails;
+ public final boolean isAttentionSetEnabled;
@Inject
EmailSettings(@GerritServerConfig Config cfg) {
@@ -58,5 +60,7 @@
"fetchInterval",
TimeUnit.MILLISECONDS.convert(60, TimeUnit.SECONDS),
TimeUnit.MILLISECONDS);
+ sendNewPatchsetEmails = cfg.getBoolean("change", null, "sendNewPatchsetEmails", true);
+ isAttentionSetEnabled = cfg.getBoolean("change", null, "enableAttentionSet", true);
}
}
diff --git a/java/com/google/gerrit/server/mail/ListMailFilter.java b/java/com/google/gerrit/server/mail/ListMailFilter.java
index 23f7e12..67cef45 100644
--- a/java/com/google/gerrit/server/mail/ListMailFilter.java
+++ b/java/com/google/gerrit/server/mail/ListMailFilter.java
@@ -31,8 +31,8 @@
public enum ListFilterMode {
OFF,
- WHITELIST,
- BLACKLIST
+ ALLOW,
+ BLOCK
}
private final ListFilterMode mode;
@@ -40,12 +40,37 @@
@Inject
ListMailFilter(@GerritServerConfig Config cfg) {
- this.mode = cfg.getEnum("receiveemail", "filter", "mode", ListFilterMode.OFF);
+ mode = getListFilterMode(cfg);
String[] addresses = cfg.getStringList("receiveemail", "filter", "patterns");
String concat = Arrays.asList(addresses).stream().collect(joining("|"));
this.mailPattern = Pattern.compile(concat);
}
+ private static final String LEGACY_ALLOW = "WHITELIST";
+ private static final String LEGACY_BLOCK = "BLACKLIST";
+
+ /** Legacy names are supported, but should be removed in the future. */
+ private ListFilterMode getListFilterMode(Config cfg) {
+ ListFilterMode mode;
+ String modeString = cfg.getString("receiveemail", "filter", "mode");
+ if (modeString == null) {
+ modeString = "";
+ }
+ switch (modeString) {
+ case LEGACY_ALLOW:
+ case "ALLOW":
+ mode = ListFilterMode.ALLOW;
+ break;
+ case LEGACY_BLOCK:
+ case "BLOCK":
+ mode = ListFilterMode.BLOCK;
+ break;
+ default:
+ mode = ListFilterMode.OFF;
+ }
+ return mode;
+ }
+
@Override
public boolean shouldProcessMessage(MailMessage message) {
if (mode == ListFilterMode.OFF) {
@@ -53,8 +78,7 @@
}
boolean match = mailPattern.matcher(message.from().email()).find();
- if ((mode == ListFilterMode.WHITELIST && !match)
- || (mode == ListFilterMode.BLACKLIST && match)) {
+ if ((mode == ListFilterMode.ALLOW && !match) || (mode == ListFilterMode.BLOCK && match)) {
logger.atInfo().log("Mail message from %s rejected by list filter", message.from());
return false;
}
diff --git a/java/com/google/gerrit/server/mail/MailUtil.java b/java/com/google/gerrit/server/mail/MailUtil.java
index ff22d23..aee8209 100644
--- a/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/java/com/google/gerrit/server/mail/MailUtil.java
@@ -72,7 +72,7 @@
@SuppressWarnings("deprecation")
private static Account.Id toAccountId(AccountResolver accountResolver, String nameOrEmail)
throws UnprocessableEntityException, IOException, ConfigInvalidException {
- return accountResolver.resolveByNameOrEmail(nameOrEmail).asUnique().account().id();
+ return accountResolver.resolveByExactNameOrEmail(nameOrEmail).asUnique().account().id();
}
private static boolean isReviewer(FooterLine candidateFooterLine) {
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index bdc933f..df38118 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -59,8 +59,6 @@
import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
import com.google.gerrit.server.mail.send.MessageIdGenerator;
import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.plugincontext.PluginSetContext;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -106,7 +104,6 @@
private final ChangeMessagesUtil changeMessagesUtil;
private final CommentsUtil commentsUtil;
private final OneOffRequestContext oneOffRequestContext;
- private final PatchListCache patchListCache;
private final PatchSetUtil psUtil;
private final Provider<InternalChangeQuery> queryProvider;
private final DynamicMap<MailFilter> mailFilters;
@@ -126,7 +123,6 @@
ChangeMessagesUtil changeMessagesUtil,
CommentsUtil commentsUtil,
OneOffRequestContext oneOffRequestContext,
- PatchListCache patchListCache,
PatchSetUtil psUtil,
Provider<InternalChangeQuery> queryProvider,
DynamicMap<MailFilter> mailFilters,
@@ -143,7 +139,6 @@
this.changeMessagesUtil = changeMessagesUtil;
this.commentsUtil = commentsUtil;
this.oneOffRequestContext = oneOffRequestContext;
- this.patchListCache = patchListCache;
this.psUtil = psUtil;
this.queryProvider = queryProvider;
this.mailFilters = mailFilters;
@@ -330,8 +325,7 @@
}
@Override
- public boolean updateChange(ChangeContext ctx)
- throws UnprocessableEntityException, PatchListNotAvailableException {
+ public boolean updateChange(ChangeContext ctx) throws UnprocessableEntityException {
patchSet = psUtil.get(ctx.getNotes(), psId);
notes = ctx.getNotes();
if (patchSet == null) {
@@ -419,8 +413,7 @@
}
private HumanComment persistentCommentFromMailComment(
- ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment)
- throws UnprocessableEntityException, PatchListNotAvailableException {
+ ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment) {
String fileName;
// The patch set that this comment is based on is different if this
// comment was sent in reply to a comment on a previous patch set.
@@ -435,7 +428,9 @@
HumanComment comment =
commentsUtil.newHumanComment(
- ctx,
+ ctx.getNotes(),
+ ctx.getUser(),
+ ctx.getWhen(),
fileName,
patchSetForComment.id(),
(short) side.ordinal(),
@@ -450,7 +445,7 @@
comment.range = mailComment.getInReplyTo().range;
comment.unresolved = mailComment.getInReplyTo().unresolved;
}
- CommentsUtil.setCommentCommitId(comment, patchListCache, ctx.getChange(), patchSetForComment);
+ commentsUtil.setCommentCommitId(comment, ctx.getChange(), patchSetForComment);
return comment;
}
}
diff --git a/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
new file mode 100644
index 0000000..b13bcf6
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AddToAttentionSetSender.java
@@ -0,0 +1,43 @@
+// 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.mail.send;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Let users know of a new user in the attention set. */
+public class AddToAttentionSetSender extends AttentionSetSender {
+
+ public interface Factory extends ReplyToChangeSender.Factory<AddToAttentionSetSender> {
+ @Override
+ AddToAttentionSetSender create(Project.NameKey project, Change.Id changeId);
+ }
+
+ @Inject
+ public AddToAttentionSetSender(
+ EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+ super(args, project, changeId);
+ }
+
+ @Override
+ protected void formatChange() throws EmailException {
+ appendText(textTemplate("AddToAttentionSet"));
+ if (useHtml()) {
+ appendHtml(soyHtmlTemplate("AddToAttentionSetHtml"));
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/mail/send/AttentionSetSender.java b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
new file mode 100644
index 0000000..8f898a8
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/AttentionSetSender.java
@@ -0,0 +1,54 @@
+// 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.mail.send;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.EmailException;
+
+/** Base class for Attention Set email senders */
+public abstract class AttentionSetSender extends ReplyToChangeSender {
+ private Account.Id attentionSetUser;
+ private String reason;
+
+ public AttentionSetSender(EmailArguments args, Project.NameKey project, Change.Id changeId) {
+ super(args, "addToAttentionSet", ChangeEmail.newChangeData(args, project, changeId));
+ }
+
+ @Override
+ protected void init() throws EmailException {
+ super.init();
+
+ ccAllApprovals();
+ bccStarredBy();
+ ccExistingReviewers();
+ removeUsersThatIgnoredTheChange();
+ }
+
+ public void setAttentionSetUser(Account.Id attentionSetUser) {
+ this.attentionSetUser = attentionSetUser;
+ }
+
+ public void setReason(String reason) {
+ this.reason = reason;
+ }
+
+ @Override
+ protected void setupSoyContext() {
+ super.setupSoyContext();
+ soyContext.put("attentionSetUser", getNameFor(attentionSetUser));
+ soyContext.put("reason", reason);
+ }
+}
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 1e984c1..a10021a 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,6 +14,9 @@
package com.google.gerrit.server.mail.send;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
+
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
@@ -31,9 +34,11 @@
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.mail.MailHeader;
import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.patch.PatchList;
@@ -54,8 +59,10 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
+import java.util.stream.Collectors;
import org.apache.james.mime4j.dom.field.FieldName;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.internal.JGitText;
@@ -72,6 +79,7 @@
return ea.changeDataFactory.create(project, id);
}
+ private final Set<Account.Id> currentAttentionSet;
protected final Change change;
protected final ChangeData changeData;
protected ListMultimap<Account.Id, String> stars;
@@ -83,12 +91,15 @@
protected ProjectState projectState;
protected Set<Account.Id> authors;
protected boolean emailOnlyAuthors;
+ protected boolean emailOnlyAttentionSetIfEnabled;
protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
super(args, messageClass, changeData.change().getDest());
this.changeData = changeData;
this.change = changeData.change();
this.emailOnlyAuthors = false;
+ this.emailOnlyAttentionSetIfEnabled = true;
+ this.currentAttentionSet = getAttentionSet();
}
@Override
@@ -120,6 +131,10 @@
/** Format the message body by calling {@link #appendText(String)}. */
@Override
protected void format() throws EmailException {
+ if (useHtml()) {
+ appendHtml(soyHtmlTemplate("ChangeHeaderHtml"));
+ }
+ appendText(textTemplate("ChangeHeader"));
formatChange();
appendText(textTemplate("ChangeFooter"));
if (useHtml()) {
@@ -384,9 +399,20 @@
@Override
protected void add(RecipientType rt, Account.Id to) {
- if (!emailOnlyAuthors || authors.contains(to)) {
- super.add(rt, to);
+ Optional<AccountState> accountState = args.accountCache.get(to);
+ if (!accountState.isPresent()) {
+ return;
}
+ if (emailOnlyAttentionSetIfEnabled
+ && accountState.get().generalPreferences().getEmailStrategy()
+ == EmailStrategy.ATTENTION_SET_ONLY
+ && !currentAttentionSet.contains(to)) {
+ return;
+ }
+ if (emailOnlyAuthors && !authors.contains(to)) {
+ return;
+ }
+ super.add(rt, to);
}
@Override
@@ -484,6 +510,16 @@
for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
footers.add(MailHeader.CC.withDelimiter() + reviewer);
}
+ for (Account.Id attentionUser : currentAttentionSet) {
+ footers.add(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
+ }
+ // Since this would be user visible, only show it if attention set is enabled
+ if (args.settings.isAttentionSetEnabled && !currentAttentionSet.isEmpty()) {
+ // We need names rather than account ids / emails to make it user readable.
+ soyContext.put(
+ "attentionSet",
+ currentAttentionSet.stream().map(this::getNameFor).collect(toImmutableSet()));
+ }
}
/**
@@ -509,6 +545,19 @@
return reviewers;
}
+ private Set<Account.Id> getAttentionSet() {
+ Set<Account.Id> attentionSet = new TreeSet<>();
+ try {
+ attentionSet =
+ additionsOnly(changeData.attentionSet()).stream()
+ .map(a -> a.account())
+ .collect(Collectors.toSet());
+ } catch (StorageException e) {
+ logger.atWarning().withCause(e).log("Cannot get change attention set");
+ }
+ return attentionSet;
+ }
+
public boolean getIncludeDiff() {
return args.settings.includeDiff;
}
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index 7d5f3fa..ac6c2f3 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -23,7 +23,6 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.KeyUtil;
import com.google.gerrit.entities.NotifyConfig.NotifyType;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.Project;
@@ -73,20 +72,9 @@
public PatchFile fileData;
public List<Comment> comments = new ArrayList<>();
- /** @return a web link to the given patch set and file. */
- public String getFileLink() {
- return args.urlFormatter
- .get()
- .getPatchFileView(change, patchSetId, KeyUtil.encode(filename))
- .orElse(null);
- }
-
- /** @return a web link to a comment within a given patch set and file. */
- public String getCommentLink(short side, int startLine) {
- return args.urlFormatter
- .get()
- .getInlineCommentView(change, patchSetId, KeyUtil.encode(filename), side, startLine)
- .orElse(null);
+ /** @return a web link to a comment for a change. */
+ public String getCommentLink(String uuid) {
+ return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
}
/** @return a web link to the comment tab view of a change. */
@@ -389,9 +377,6 @@
for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
Map<String, Object> groupData = new HashMap<>();
- if (!group.filename.equals(Patch.PATCHSET_LEVEL)) {
- groupData.put("link", group.getFileLink());
- }
groupData.put("title", group.getTitle());
groupData.put("patchSetId", group.patchSetId);
@@ -426,10 +411,8 @@
} else {
commentData.put("link", group.getCommentsTabLink());
}
- } else if (comment.lineNbr == 0) {
- commentData.put("link", group.getFileLink());
} else {
- commentData.put("link", group.getCommentLink(comment.side, startLine));
+ commentData.put("link", group.getCommentLink(comment.key.uuid));
}
// Set robot comment data.
diff --git a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index d5863a6..0de0dbe 100644
--- a/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -61,7 +61,7 @@
bccStarredBy();
ccExistingReviewers();
includeWatchers(NotifyType.ALL_COMMENTS);
- add(RecipientType.TO, reviewers);
+ reviewers.stream().forEach(r -> add(RecipientType.TO, r));
addByEmail(RecipientType.TO, reviewersByEmail);
removeUsersThatIgnoredTheChange();
}
diff --git a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
index 07ca254..1b58057 100644
--- a/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
+++ b/java/com/google/gerrit/server/mail/send/MailSoySauceProvider.java
@@ -41,8 +41,12 @@
"AbandonedHtml.soy",
"AddKey.soy",
"AddKeyHtml.soy",
+ "AddToAttentionSet.soy",
+ "AddToAttentionSetHtml.soy",
"ChangeFooter.soy",
"ChangeFooterHtml.soy",
+ "ChangeHeader.soy",
+ "ChangeHeaderHtml.soy",
"ChangeSubject.soy",
"Comment.soy",
"CommentHtml.soy",
@@ -58,7 +62,6 @@
"InboundEmailRejectionHtml.soy",
"Footer.soy",
"FooterHtml.soy",
- "HeaderHtml.soy",
"HttpPasswordUpdate.soy",
"HttpPasswordUpdateHtml.soy",
"Merged.soy",
@@ -70,6 +73,8 @@
"Private.soy",
"RegisterNewEmail.soy",
"RegisterNewEmailHtml.soy",
+ "RemoveFromAttentionSet.soy",
+ "RemoveFromAttentionSetHtml.soy",
"ReplacePatchSet.soy",
"ReplacePatchSetHtml.soy",
"Restored.soy",
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index 6ee6c68..9c2d6ff 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -16,10 +16,10 @@
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.Table;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.NotifyConfig.NotifyType;
import com.google.gerrit.entities.PatchSetApproval;
@@ -46,6 +46,9 @@
@Override
protected void init() throws EmailException {
+ // We want to send the submit email even if the "send only when in attention set" is enabled.
+ emailOnlyAttentionSetIfEnabled = false;
+
super.init();
ccAllApprovals();
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
index 3a411dc..aa683f6 100644
--- a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
@@ -17,6 +17,7 @@
import static com.google.common.base.Preconditions.checkState;
import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
@@ -62,6 +63,12 @@
* @return MessageId that depends on the patchset.
*/
public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
+ return fromChangeUpdateAndReason(repoView, patchsetId, null);
+ }
+
+ public MessageId fromChangeUpdateAndReason(
+ RepoView repoView, PatchSet.Id patchsetId, @Nullable String reason) {
+ String suffix = (reason != null) ? ("-" + reason) : "";
String metaRef = patchsetId.changeId().toRefPrefix() + "meta";
Optional<ObjectId> metaSha1;
try {
@@ -70,7 +77,7 @@
throw new StorageException("unable to extract info for Message-Id", ex);
}
return metaSha1
- .map(optional -> new AutoValue_MessageIdGenerator_MessageId(optional.getName()))
+ .map(optional -> new AutoValue_MessageIdGenerator_MessageId(optional.getName() + suffix))
.orElseThrow(() -> new IllegalStateException(metaRef + " doesn't exist"));
}
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 0e97f7e..ee9a328 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -65,11 +65,11 @@
break;
case ALL:
default:
- add(RecipientType.CC, extraCC);
+ extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
// $FALL-THROUGH$
case OWNER_REVIEWERS:
- add(RecipientType.TO, reviewers, true);
+ reviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
addByEmail(RecipientType.TO, reviewersByEmail, true);
break;
}
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 1eb274b..44453d5 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -25,7 +25,6 @@
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.EmailHeader;
import com.google.gerrit.entities.EmailHeader.AddressList;
-import com.google.gerrit.entities.UserIdentity;
import com.google.gerrit.exceptions.EmailException;
import com.google.gerrit.extensions.api.changes.RecipientType;
import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -116,9 +115,6 @@
if (messageId == null) {
throw new IllegalStateException("All emails must have a messageId");
}
- if (useHtml()) {
- appendHtml(soyHtmlTemplate("HeaderHtml"));
- }
format();
appendText(textTemplate("Footer"));
if (useHtml()) {
@@ -287,7 +283,7 @@
setHeader(MailHeader.AUTO_SUBMITTED.fieldName(), "auto-generated");
for (RecipientType recipientType : notify.accounts().keySet()) {
- add(recipientType, notify.accounts().get(recipientType));
+ notify.accounts().get(recipientType).stream().forEach(a -> add(recipientType, a));
}
setHeader(MailHeader.MESSAGE_TYPE.fieldName(), messageClass);
@@ -474,40 +470,18 @@
return true;
}
- /** Schedule this message for delivery to the listed accounts. */
- protected void add(RecipientType rt, Collection<Account.Id> list) {
- add(rt, list, false);
- }
-
- /** Schedule this message for delivery to the listed accounts. */
- protected void add(RecipientType rt, Collection<Account.Id> list, boolean override) {
- for (final Account.Id id : list) {
- add(rt, id, override);
- }
- }
-
/** Schedule this message for delivery to the listed address. */
- protected void addByEmail(RecipientType rt, Collection<Address> list) {
+ protected final void addByEmail(RecipientType rt, Collection<Address> list) {
addByEmail(rt, list, false);
}
/** Schedule this message for delivery to the listed address. */
- protected void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
+ protected final void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
for (final Address id : list) {
add(rt, id, override);
}
}
- protected void add(RecipientType rt, UserIdentity who) {
- add(rt, who, false);
- }
-
- protected void add(RecipientType rt, UserIdentity who, boolean override) {
- if (who != null && who.getAccount() != null) {
- add(rt, who.getAccount(), override);
- }
- }
-
/** Schedule delivery of this message to the given account. */
protected void add(RecipientType rt, Account.Id to) {
add(rt, to, false);
@@ -534,11 +508,11 @@
}
/** Schedule delivery of this message to the given account. */
- protected void add(RecipientType rt, Address addr) {
+ protected final void add(RecipientType rt, Address addr) {
add(rt, addr, false);
}
- protected void add(RecipientType rt, Address addr, boolean override) {
+ protected final void add(RecipientType rt, Address addr, boolean override) {
if (addr != null && addr.email() != null && addr.email().length() > 0) {
if (!args.validator.isValid(addr.email())) {
logger.atWarning().log("Not emailing %s (invalid email address)", addr.email());
diff --git a/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index 0514337..173b121 100644
--- a/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -33,7 +33,7 @@
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.SingleGroupUser;
+import com.google.gerrit.server.query.change.GroupBackedUser;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -150,7 +150,7 @@
throws QueryParseException {
logger.atFine().log("Checking watchers for notify config %s from project %s", nc, projectName);
for (GroupReference groupRef : nc.getGroups()) {
- CurrentUser user = new SingleGroupUser(groupRef.getUUID());
+ CurrentUser user = new GroupBackedUser(ImmutableSet.of(groupRef.getUUID()));
if (filterMatch(user, nc.getFilter())) {
deliverToMembers(matching.list(nc.getHeader()), groupRef.getUUID());
logger.atFine().log("Added watchers for group %s", groupRef);
diff --git a/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
new file mode 100644
index 0000000..6762b7d
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/RemoveFromAttentionSetSender.java
@@ -0,0 +1,43 @@
+// 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.mail.send;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.EmailException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/** Let users know of a user removed from the attention set. */
+public class RemoveFromAttentionSetSender extends AttentionSetSender {
+
+ public interface Factory extends ReplyToChangeSender.Factory<RemoveFromAttentionSetSender> {
+ @Override
+ RemoveFromAttentionSetSender create(Project.NameKey project, Change.Id changeId);
+ }
+
+ @Inject
+ public RemoveFromAttentionSetSender(
+ EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
+ super(args, project, changeId);
+ }
+
+ @Override
+ protected void formatChange() throws EmailException {
+ appendText(textTemplate("RemoveFromAttentionSet"));
+ if (useHtml()) {
+ appendHtml(soyHtmlTemplate("RemoveFromAttentionSetHtml"));
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index 274e664..9516b9f 100644
--- a/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -61,12 +61,14 @@
//
reviewers.remove(fromId);
}
- if (notify.handling() == NotifyHandling.ALL
- || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
- add(RecipientType.TO, reviewers);
- add(RecipientType.CC, extraCC);
+ if (args.settings.sendNewPatchsetEmails) {
+ if (notify.handling() == NotifyHandling.ALL
+ || notify.handling() == NotifyHandling.OWNER_REVIEWERS) {
+ reviewers.stream().forEach(r -> add(RecipientType.TO, r));
+ extraCC.stream().forEach(cc -> add(RecipientType.CC, cc));
+ }
+ rcptToAuthors(RecipientType.CC);
}
- rcptToAuthors(RecipientType.CC);
bccStarredBy();
includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
removeUsersThatIgnoredTheChange();
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index cf854c7..41263a4 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -38,7 +38,6 @@
import com.google.common.collect.Sets.SetView;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.BranchNameKey;
@@ -51,7 +50,7 @@
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.RobotComment;
-import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.server.AssigneeStatusUpdate;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
@@ -78,6 +77,9 @@
import org.eclipse.jgit.lib.Repository;
/** View of a single {@link Change} based on the log of its notes branch. */
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
public class ChangeNotes extends AbstractChangeNotes<ChangeNotes> {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -139,17 +141,6 @@
return new ChangeNotes(args, newChange(project, changeId), true, null).load();
}
- /**
- * Create change notes for a change that was loaded from index. This method should only be used
- * when database access is harmful and potentially stale data from the index is acceptable.
- *
- * @param change change loaded from secondary index
- * @return change notes
- */
- public ChangeNotes createFromIndexedChange(Change change) {
- return new ChangeNotes(args, change, true, null);
- }
-
public ChangeNotes createForBatchUpdate(Change change, boolean shouldExist) {
return new ChangeNotes(args, change, shouldExist, null).load();
}
@@ -248,7 +239,7 @@
ChangeNotes n = new ChangeNotes(args, rawChangeFromNoteDb, true, null);
try {
n.load();
- } catch (StorageException e) {
+ } catch (Exception e) {
return ChangeNotesResult.error(n.getChangeId(), e);
}
return ChangeNotesResult.notes(n);
@@ -257,7 +248,7 @@
/** Result of {@link #scan(Repository,Project.NameKey)}. */
@AutoValue
public abstract static class ChangeNotesResult {
- static ChangeNotesResult error(Change.Id id, StorageException e) {
+ static ChangeNotesResult error(Change.Id id, Throwable e) {
return new AutoValue_ChangeNotes_Factory_ChangeNotesResult(id, Optional.of(e), null);
}
@@ -270,7 +261,7 @@
public abstract Change.Id id();
/** Error encountered while loading this change, if any. */
- public abstract Optional<StorageException> error();
+ public abstract Optional<Throwable> error();
/**
* Notes loaded for this change.
@@ -392,6 +383,11 @@
return state.attentionSet();
}
+ /** Returns all updates for the attention set. */
+ public ImmutableList<AttentionSetUpdate> getAttentionSetUpdates() {
+ return state.allAttentionSetUpdates();
+ }
+
/**
* @return an ImmutableSet of Account.Ids of all users that have been assigned to this change. The
* order of the set is the order in which they were assigned.
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 7fde297..1650421 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -168,6 +168,10 @@
+ P
+ list(state.assigneeUpdates(), 4 * O + K + K)
+ P
+ + set(state.attentionSet(), 4 * O + K + I + str(15))
+ + P
+ + list(state.allAttentionSetUpdates(), 4 * O + K + I + str(15))
+ + P
+ list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
+ P
+ list(state.changeMessages(), changeMessage())
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 10a8d8b..fae29f8 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -55,8 +55,6 @@
import com.google.common.collect.Tables;
import com.google.common.flogger.FluentLogger;
import com.google.common.primitives.Ints;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
@@ -64,9 +62,11 @@
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.metrics.Timer0;
import com.google.gerrit.server.AssigneeStatusUpdate;
import com.google.gerrit.server.ReviewerByEmailSet;
@@ -118,6 +118,8 @@
private final List<ReviewerStatusUpdate> reviewerUpdates;
/** Holds only the most recent update per user. Older updates are discarded. */
private final Map<Account.Id, AttentionSetUpdate> latestAttentionStatus;
+ /** Holds all updates to attention set. */
+ private final List<AttentionSetUpdate> allAttentionSetUpdates;
private final List<AssigneeStatusUpdate> assigneeUpdates;
private final List<SubmitRecord> submitRecords;
@@ -175,6 +177,7 @@
allPastReviewers = new ArrayList<>();
reviewerUpdates = new ArrayList<>();
latestAttentionStatus = new HashMap<>();
+ allAttentionSetUpdates = new ArrayList<>();
assigneeUpdates = new ArrayList<>();
submitRecords = Lists.newArrayListWithExpectedSize(1);
allChangeMessages = new ArrayList<>();
@@ -246,6 +249,7 @@
allPastReviewers,
buildReviewerUpdates(),
ImmutableSet.copyOf(latestAttentionStatus.values()),
+ allAttentionSetUpdates,
assigneeUpdates,
submitRecords,
buildAllMessages(),
@@ -589,6 +593,9 @@
}
// Processing is in reverse chronological order. Keep only the latest update.
latestAttentionStatus.putIfAbsent(attentionStatus.get().account(), attentionStatus.get());
+
+ // Keep all updates as well.
+ allAttentionSetUpdates.add(attentionStatus.get());
}
}
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 0b03a07..fa32686 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -33,7 +33,6 @@
import com.google.common.collect.Maps;
import com.google.common.collect.Table;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
@@ -44,6 +43,7 @@
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -84,6 +84,9 @@
* <p>Note that {@link ChangeNotes} contains more than just a single {@code ChangeNoteState}, such
* as per-draft information, so that class is not cached directly.
*/
+// TODO(paiking): This class should be refactored to get rid of potentially duplicate or unneeded
+// variables, such as allAttentionSetUpdates, reviewerUpdates, and others.
+
@AutoValue
public abstract class ChangeNotesState {
@@ -120,6 +123,7 @@
List<Account.Id> allPastReviewers,
List<ReviewerStatusUpdate> reviewerUpdates,
Set<AttentionSetUpdate> attentionSetUpdates,
+ List<AttentionSetUpdate> allAttentionSetUpdates,
List<AssigneeStatusUpdate> assigneeUpdates,
List<SubmitRecord> submitRecords,
List<ChangeMessage> changeMessages,
@@ -171,6 +175,7 @@
.allPastReviewers(allPastReviewers)
.reviewerUpdates(reviewerUpdates)
.attentionSet(attentionSetUpdates)
+ .allAttentionSetUpdates(allAttentionSetUpdates)
.assigneeUpdates(assigneeUpdates)
.submitRecords(submitRecords)
.changeMessages(changeMessages)
@@ -305,9 +310,12 @@
abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
- /** Returns the most recent update (i.e. current status status) per user. */
+ /** Returns the most recent update (i.e. current status) per user. */
abstract ImmutableSet<AttentionSetUpdate> attentionSet();
+ /** Returns all attention set updates. */
+ abstract ImmutableList<AttentionSetUpdate> allAttentionSetUpdates();
+
abstract ImmutableList<AssigneeStatusUpdate> assigneeUpdates();
abstract ImmutableList<SubmitRecord> submitRecords();
@@ -386,6 +394,7 @@
.allPastReviewers(ImmutableList.of())
.reviewerUpdates(ImmutableList.of())
.attentionSet(ImmutableSet.of())
+ .allAttentionSetUpdates(ImmutableList.of())
.assigneeUpdates(ImmutableList.of())
.submitRecords(ImmutableList.of())
.changeMessages(ImmutableList.of())
@@ -421,6 +430,8 @@
abstract Builder attentionSet(Set<AttentionSetUpdate> attentionSetUpdates);
+ abstract Builder allAttentionSetUpdates(List<AttentionSetUpdate> attentionSetUpdates);
+
abstract Builder assigneeUpdates(List<AssigneeStatusUpdate> assigneeUpdates);
abstract Builder submitRecords(List<SubmitRecord> submitRecords);
@@ -489,6 +500,9 @@
object.allPastReviewers().forEach(a -> b.addPastReviewer(a.get()));
object.reviewerUpdates().forEach(u -> b.addReviewerUpdate(toReviewerStatusUpdateProto(u)));
object.attentionSet().forEach(u -> b.addAttentionSetUpdate(toAttentionSetUpdateProto(u)));
+ object
+ .allAttentionSetUpdates()
+ .forEach(u -> b.addAllAttentionSetUpdate(toAttentionSetUpdateProto(u)));
object.assigneeUpdates().forEach(u -> b.addAssigneeUpdate(toAssigneeStatusUpdateProto(u)));
object
.submitRecords()
@@ -623,6 +637,8 @@
proto.getPastReviewerList().stream().map(Account::id).collect(toImmutableList()))
.reviewerUpdates(toReviewerStatusUpdateList(proto.getReviewerUpdateList()))
.attentionSet(toAttentionSetUpdates(proto.getAttentionSetUpdateList()))
+ .allAttentionSetUpdates(
+ toAllAttentionSetUpdates(proto.getAllAttentionSetUpdateList()))
.assigneeUpdates(toAssigneeStatusUpdateList(proto.getAssigneeUpdateList()))
.submitRecords(
proto.getSubmitRecordList().stream()
@@ -735,6 +751,20 @@
return b.build();
}
+ private static ImmutableList<AttentionSetUpdate> toAllAttentionSetUpdates(
+ List<AttentionSetUpdateProto> protos) {
+ ImmutableList.Builder<AttentionSetUpdate> b = ImmutableList.builder();
+ for (AttentionSetUpdateProto proto : protos) {
+ b.add(
+ AttentionSetUpdate.createFromRead(
+ Instant.ofEpochMilli(proto.getTimestampMillis()),
+ Account.id(proto.getAccount()),
+ AttentionSetUpdate.Operation.valueOf(proto.getOperation()),
+ proto.getReason()));
+ }
+ return b.build();
+ }
+
private static ImmutableList<AssigneeStatusUpdate> toAssigneeStatusUpdateList(
List<AssigneeStatusUpdateProto> protos) {
ImmutableList.Builder<AssigneeStatusUpdate> b = ImmutableList.builder();
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 1956154..6d75bd2 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -53,19 +53,22 @@
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Table;
import com.google.common.collect.TreeBasedTable;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelId;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RobotComment;
import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.gerrit.server.util.LabelVote;
@@ -85,6 +88,7 @@
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.ObjectId;
@@ -119,6 +123,7 @@
private final ChangeDraftUpdate.Factory draftUpdateFactory;
private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
+ private final ServiceUserClassifier serviceUserClassifier;
private final Table<String, Account.Id, Optional<Short>> approvals;
private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
@@ -135,7 +140,7 @@
private String topic;
private String commit;
private Map<Account.Id, AttentionSetUpdate> plannedAttentionSetUpdates;
- private boolean ignoreDefaultAttentionSetRules;
+ private boolean ignoreFurtherAttentionSetUpdates;
private Optional<Account.Id> assignee;
private Set<String> hashtags;
private String changeMessage;
@@ -164,6 +169,7 @@
RobotCommentUpdate.Factory robotCommentUpdateFactory,
DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
ProjectCache projectCache,
+ ServiceUserClassifier serviceUserClassifier,
@Assisted ChangeNotes notes,
@Assisted CurrentUser user,
@Assisted Date when,
@@ -174,6 +180,7 @@
draftUpdateFactory,
robotCommentUpdateFactory,
deleteCommentRewriterFactory,
+ serviceUserClassifier,
notes,
user,
when,
@@ -197,6 +204,7 @@
ChangeDraftUpdate.Factory draftUpdateFactory,
RobotCommentUpdate.Factory robotCommentUpdateFactory,
DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
+ ServiceUserClassifier serviceUserClassifier,
@Assisted ChangeNotes notes,
@Assisted CurrentUser user,
@Assisted Date when,
@@ -207,6 +215,7 @@
this.draftUpdateFactory = draftUpdateFactory;
this.robotCommentUpdateFactory = robotCommentUpdateFactory;
this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
+ this.serviceUserClassifier = serviceUserClassifier;
this.approvals = approvals(labelNameComparator);
}
@@ -385,7 +394,8 @@
* must first create the removal, and the addition will not take effect.
*/
public void addToPlannedAttentionSetUpdates(Set<AttentionSetUpdate> updates) {
- if (updates == null || updates.isEmpty()) {
+ if (updates == null || updates.isEmpty() || ignoreFurtherAttentionSetUpdates) {
+ // No updates to do. Robots don't change attention set.
return;
}
checkArgument(
@@ -409,13 +419,7 @@
.forEach(u -> plannedAttentionSetUpdates.putIfAbsent(u.account(), u));
}
- /**
- * If we need to ignore default attention set rules, no need to add any new updates in this class.
- */
public void addToPlannedAttentionSetUpdates(AttentionSetUpdate update) {
- if (ignoreDefaultAttentionSetRules) {
- return;
- }
addToPlannedAttentionSetUpdates(ImmutableSet.of(update));
}
@@ -771,7 +775,7 @@
|| getNotes().getChange().isWorkInProgress()
|| status == Change.Status.MERGED) {
// Attention set shouldn't change here for changes that are work in progress or are about to
- // be submitted.
+ // be submitted or when the caller is a robot.
return;
}
Set<Account.Id> currentReviewers =
@@ -785,12 +789,14 @@
AttentionSetUpdate.createForWrite(
reviewer.getKey(), AttentionSetUpdate.Operation.ADD, "Reviewer was added"));
}
- // Treat both REMOVED and CC as "removed reviewers".
- if (!reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
- && currentReviewers.contains(reviewer.getKey())) {
+ boolean reviewerRemoved =
+ !reviewer.getValue().equals(ReviewerStateInternal.REVIEWER)
+ && currentReviewers.contains(reviewer.getKey());
+ boolean ccRemoved = reviewer.getValue().equals(ReviewerStateInternal.REMOVED);
+ if (reviewerRemoved || ccRemoved) {
updates.add(
AttentionSetUpdate.createForWrite(
- reviewer.getKey(), AttentionSetUpdate.Operation.REMOVE, "Reviewer was removed"));
+ reviewer.getKey(), AttentionSetUpdate.Operation.REMOVE, "Reviewer/Cc was removed"));
}
}
addToPlannedAttentionSetUpdates(updates);
@@ -817,6 +823,22 @@
AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
.map(AttentionSetUpdate::account)
.collect(Collectors.toSet());
+
+ // Current reviewers/ccs are the reviewers/ccs before the update + the new reviewers/ccs - the
+ // deleted reviewers/ccs.
+ Set<Account.Id> currentReviewers =
+ Stream.concat(
+ getNotes().getReviewers().all().stream(),
+ reviewers.entrySet().stream()
+ .filter(r -> r.getValue().asReviewerState() != ReviewerState.REMOVED)
+ .map(r -> r.getKey()))
+ .collect(Collectors.toSet());
+ currentReviewers.removeAll(
+ reviewers.entrySet().stream()
+ .filter(r -> r.getValue().asReviewerState() == ReviewerState.REMOVED)
+ .map(r -> r.getKey())
+ .collect(ImmutableSet.toImmutableSet()));
+
for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
&& currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -829,16 +851,47 @@
// Skip users that are not in the attention set: no need to remove them.
continue;
}
+
+ if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+ && serviceUserClassifier.isServiceUser(attentionSetUpdate.account())) {
+ // Skip adding robots to the attention set.
+ continue;
+ }
+
+ if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+ && approvals.rowKeySet().contains(LabelId.legacySubmit().get())) {
+ // On submit, we sometimes can add the person who submitted the change as a reviewer, and in
+ // turn it will add that person to the attention set.
+ // This ensures we don't add users to the attention set on submit.
+ continue;
+ }
+
+ // Don't add accounts that are not active in the change to the attention set.
+ if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
+ && !isActiveOnChange(currentReviewers, attentionSetUpdate.account())) {
+ continue;
+ }
+
addFooter(msg, FOOTER_ATTENTION, noteUtil.attentionSetUpdateToJson(attentionSetUpdate));
}
}
/**
+ * Returns whether {@code accountId} is active on a change based on the {@code currentReviewers}.
+ * Activity is defined as being a part of the reviewers, an uploader, or an owner of a change.
+ */
+ private boolean isActiveOnChange(Set<Account.Id> currentReviewers, Account.Id accountId) {
+ return currentReviewers.contains(accountId)
+ || getChange().getOwner().equals(accountId)
+ || getNotes().getCurrentPatchSet().uploader().equals(accountId);
+ }
+
+ /**
* When set, default attention set rules are ignored (E.g, adding reviewers -> adds to attention
* set, etc).
*/
- public void ignoreDefaultAttentionSetRules() {
- ignoreDefaultAttentionSetRules = true;
+ public void ignoreFurtherAttentionSetUpdates() {
+ ignoreFurtherAttentionSetUpdates = true;
}
private void addPatchSetFooter(StringBuilder sb, int ps) {
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index ca97a1a..65758f9 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -16,9 +16,11 @@
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.gerrit.server.logging.TraceContext.newTimer;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import com.google.gerrit.common.Nullable;
@@ -34,6 +36,7 @@
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.update.BatchUpdateListener;
import com.google.gerrit.server.update.ChainedReceiveCommands;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -94,6 +97,7 @@
private String refLogMessage;
private PersonIdent refLogIdent;
private PushCertificate pushCert;
+ private ImmutableList<BatchUpdateListener> batchUpdateListeners;
@Inject
NoteDbUpdateManager(
@@ -117,6 +121,7 @@
robotCommentUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
rewriters = MultimapBuilder.hashKeys().arrayListValues().build();
changesToDelete = new HashSet<>();
+ batchUpdateListeners = ImmutableList.of();
}
@Override
@@ -172,6 +177,17 @@
return this;
}
+ public NoteDbUpdateManager setBatchUpdateListeners(
+ ImmutableList<BatchUpdateListener> batchUpdateListeners) {
+ checkNotNull(batchUpdateListeners);
+ this.batchUpdateListeners = batchUpdateListeners;
+ return this;
+ }
+
+ public boolean isExecuted() {
+ return executed;
+ }
+
private void initChangeRepo() throws IOException {
if (changeRepo == null) {
changeRepo = OpenRepo.open(repoManager, projectName);
@@ -358,6 +374,9 @@
bru.setAtomic(true);
or.cmds.addTo(bru);
bru.setAllowNonFastForwards(true);
+ for (BatchUpdateListener listener : batchUpdateListeners) {
+ bru = listener.beforeUpdateRefs(bru);
+ }
if (!dryrun) {
RefUpdateUtil.executeChecked(bru, or.rw);
diff --git a/java/com/google/gerrit/server/patch/DiffContentCalculator.java b/java/com/google/gerrit/server/patch/DiffContentCalculator.java
index 53f7ca6..a387da6 100644
--- a/java/com/google/gerrit/server/patch/DiffContentCalculator.java
+++ b/java/com/google/gerrit/server/patch/DiffContentCalculator.java
@@ -14,28 +14,19 @@
package com.google.gerrit.server.patch;
-import static java.util.Comparator.comparing;
-
import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.CommentDetail;
-import com.google.gerrit.entities.Comment;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
import com.google.gerrit.jgit.diff.ReplaceEdit;
-import com.google.gerrit.prettify.common.EditList;
+import com.google.gerrit.prettify.common.EditHunk;
import com.google.gerrit.prettify.common.SparseFileContent;
import com.google.gerrit.prettify.common.SparseFileContentBuilder;
-import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import org.eclipse.jgit.diff.Edit;
/** Collects all lines and their content to be displayed in diff view. */
class DiffContentCalculator {
- private static final int MAX_CONTEXT = 5000000;
-
- private static final Comparator<Edit> EDIT_SORT = comparing(Edit::getBeginA);
-
private final DiffPreferencesInfo diffPrefs;
DiffContentCalculator(DiffPreferencesInfo diffPrefs) {
@@ -60,13 +51,11 @@
* @param srcA Original text content
* @param srcB New text content
* @param edits List of edits which was applied to srcA to produce srcB
- * @param comments Existing comments for srcA and srcB
* @return an instance of {@link DiffCalculatorResult}.
*/
DiffCalculatorResult calculateDiffContent(
- TextSource srcA, TextSource srcB, ImmutableList<Edit> edits, CommentDetail comments) {
- int context = getContext();
- if (srcA.src == srcB.src && srcA.size() <= context && edits.isEmpty()) {
+ TextSource srcA, TextSource srcB, ImmutableList<Edit> edits) {
+ if (srcA.src == srcB.src && edits.isEmpty()) {
// Odd special case; the files are identical (100% rename or copy)
// and the user has asked for context that is larger than the file.
// Send them the entire file, with an empty edit after the last line.
@@ -80,43 +69,13 @@
Edit emptyEdit = new Edit(srcA.size(), srcA.size());
return new DiffCalculatorResult(diffContent, ImmutableList.of(emptyEdit));
}
- ImmutableList.Builder<Edit> builder = ImmutableList.builder();
+ ImmutableList<Edit> sortedEdits = correctForDifferencesInNewlineAtEnd(srcA, srcB, edits);
- builder.addAll(correctForDifferencesInNewlineAtEnd(srcA, srcB, edits));
-
- boolean nonsortedEdits = false;
- if (comments != null) {
- ImmutableList<Edit> commentEdits = ensureCommentsVisible(comments, edits);
- builder.addAll(commentEdits);
- nonsortedEdits = !commentEdits.isEmpty();
- }
-
- ImmutableList<Edit> sortedEdits = builder.build();
- if (nonsortedEdits) {
- sortedEdits = ImmutableList.sortedCopyOf(EDIT_SORT, sortedEdits);
- }
-
- // In order to expand the skipped common lines or syntax highlight the
- // file properly we need to give the client the complete file contents.
- // So force our context temporarily to the complete file size.
- //
DiffContent diffContent =
- packContent(
- srcA,
- srcB,
- diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE,
- sortedEdits,
- MAX_CONTEXT);
+ packContent(srcA, srcB, diffPrefs.ignoreWhitespace != Whitespace.IGNORE_NONE, sortedEdits);
return new DiffCalculatorResult(diffContent, sortedEdits);
}
- private int getContext() {
- if (diffPrefs.context == DiffPreferencesInfo.WHOLE_FILE_CONTEXT) {
- return MAX_CONTEXT;
- }
- return Math.min(diffPrefs.context, MAX_CONTEXT);
- }
-
private ImmutableList<Edit> correctForDifferencesInNewlineAtEnd(
TextSource a, TextSource b, ImmutableList<Edit> edits) {
// a.src.size() is the size ignoring a newline at the end whereas a.size() considers it.
@@ -205,128 +164,14 @@
return a.src.isMissingNewlineAtEnd() && !b.src.isMissingNewlineAtEnd();
}
- private ImmutableList<Edit> ensureCommentsVisible(
- CommentDetail comments, ImmutableList<Edit> edits) {
- if (comments.getCommentsA().isEmpty() && comments.getCommentsB().isEmpty()) {
- // No comments, no additional dummy edits are required.
- //
- return ImmutableList.of();
- }
-
- // Construct empty Edit blocks around each location where a comment is.
- // This will force the later packContent method to include the regions
- // containing comments, potentially combining those regions together if
- // they have overlapping contexts. UI renders will also be able to make
- // correct hunks from this, but because the Edit is empty they will not
- // style it specially.
- //
- final ImmutableList.Builder<Edit> commmentEdits = ImmutableList.builder();
- int lastLine;
-
- lastLine = -1;
- for (Comment c : comments.getCommentsA()) {
- final int a = c.lineNbr;
- if (lastLine != a) {
- final int b = mapA2B(a - 1, edits);
- if (0 <= b) {
- getNewEditForComment(edits, new Edit(a - 1, b)).ifPresent(commmentEdits::add);
- }
- lastLine = a;
- }
- }
-
- lastLine = -1;
- for (Comment c : comments.getCommentsB()) {
- int b = c.lineNbr;
- if (lastLine != b) {
- final int a = mapB2A(b - 1, edits);
- if (0 <= a) {
- getNewEditForComment(edits, new Edit(a, b - 1)).ifPresent(commmentEdits::add);
- }
- lastLine = b;
- }
- }
- return commmentEdits.build();
- }
-
- private Optional<Edit> getNewEditForComment(ImmutableList<Edit> edits, Edit toAdd) {
- final int a = toAdd.getBeginA();
- final int b = toAdd.getBeginB();
- for (Edit e : edits) {
- if (e.getBeginA() <= a && a <= e.getEndA()) {
- return Optional.empty();
- }
- if (e.getBeginB() <= b && b <= e.getEndB()) {
- return Optional.empty();
- }
- }
- return Optional.of(toAdd);
- }
-
- private int mapA2B(int a, ImmutableList<Edit> edits) {
- if (edits.isEmpty()) {
- // Magic special case of an unmodified file.
- //
- return a;
- }
-
- for (int i = 0; i < edits.size(); i++) {
- final Edit e = edits.get(i);
- if (a < e.getBeginA()) {
- if (i == 0) {
- // Special case of context at start of file.
- //
- return a;
- }
- return e.getBeginB() - (e.getBeginA() - a);
- }
- if (e.getBeginA() <= a && a <= e.getEndA()) {
- return -1;
- }
- }
-
- final Edit last = edits.get(edits.size() - 1);
- return last.getEndB() + (a - last.getEndA());
- }
-
- private int mapB2A(int b, ImmutableList<Edit> edits) {
- if (edits.isEmpty()) {
- // Magic special case of an unmodified file.
- //
- return b;
- }
-
- for (int i = 0; i < edits.size(); i++) {
- final Edit e = edits.get(i);
- if (b < e.getBeginB()) {
- if (i == 0) {
- // Special case of context at start of file.
- //
- return b;
- }
- return e.getBeginA() - (e.getBeginB() - b);
- }
- if (e.getBeginB() <= b && b <= e.getEndB()) {
- return -1;
- }
- }
-
- final Edit last = edits.get(edits.size() - 1);
- return last.getEndA() + (b - last.getEndB());
- }
-
private DiffContent packContent(
- TextSource a,
- TextSource b,
- boolean ignoredWhitespace,
- ImmutableList<Edit> edits,
- int context) {
+ TextSource a, TextSource b, boolean ignoredWhitespace, ImmutableList<Edit> edits) {
SparseFileContentBuilder diffA = new SparseFileContentBuilder(a.size());
SparseFileContentBuilder diffB = new SparseFileContentBuilder(b.size());
- EditList list = new EditList(edits, context, a.size(), b.size());
- for (EditList.Hunk hunk : list.getHunks()) {
+ if (!edits.isEmpty()) {
+ EditHunk hunk = new EditHunk(edits, a.size(), b.size());
while (hunk.next()) {
- if (hunk.isContextLine()) {
+ if (hunk.isUnmodifiedLine()) {
String lineA = a.getSourceLine(hunk.getCurA());
diffA.addLine(hunk.getCurA(), lineA);
diff --git a/java/com/google/gerrit/server/patch/DiffMappings.java b/java/com/google/gerrit/server/patch/DiffMappings.java
new file mode 100644
index 0000000..921d66e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffMappings.java
@@ -0,0 +1,63 @@
+// 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.patch;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
+import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
+import com.google.gerrit.server.patch.GitPositionTransformer.Range;
+import com.google.gerrit.server.patch.GitPositionTransformer.RangeMapping;
+
+/** Mappings derived from diffs. */
+public class DiffMappings {
+
+ private DiffMappings() {}
+
+ public static Mapping toMapping(PatchListEntry patchListEntry) {
+ FileMapping fileMapping = toFileMapping(patchListEntry);
+ ImmutableSet<RangeMapping> rangeMappings = toRangeMappings(patchListEntry);
+ return Mapping.create(fileMapping, rangeMappings);
+ }
+
+ private static FileMapping toFileMapping(PatchListEntry patchListEntry) {
+ switch (patchListEntry.getChangeType()) {
+ case ADDED:
+ return FileMapping.forAddedFile(patchListEntry.getNewName());
+ case MODIFIED:
+ case REWRITE:
+ return FileMapping.forModifiedFile(patchListEntry.getNewName());
+ case DELETED:
+ // Name of deleted file is mentioned as newName.
+ return FileMapping.forDeletedFile(patchListEntry.getNewName());
+ case RENAMED:
+ case COPIED:
+ return FileMapping.forRenamedFile(patchListEntry.getOldName(), patchListEntry.getNewName());
+ default:
+ throw new IllegalStateException("Unmapped diff type: " + patchListEntry.getChangeType());
+ }
+ }
+
+ private static ImmutableSet<RangeMapping> toRangeMappings(PatchListEntry patchListEntry) {
+ return patchListEntry.getEdits().stream()
+ .map(
+ edit ->
+ RangeMapping.create(
+ Range.create(edit.getBeginA(), edit.getEndA()),
+ Range.create(edit.getBeginB(), edit.getEndB())))
+ .collect(toImmutableSet());
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffNotAvailableException.java b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
new file mode 100644
index 0000000..34e1577
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffNotAvailableException.java
@@ -0,0 +1,28 @@
+// 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.patch;
+
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+
+/**
+ * Thrown by the diff caches - the {@link GitModifiedFilesCache} and the {@link ModifiedFilesCache},
+ * if the implementations failed to retrieve the modified files between the 2 commits.
+ */
+public class DiffNotAvailableException extends Exception {
+ public DiffNotAvailableException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/DiffUtil.java b/java/com/google/gerrit/server/patch/DiffUtil.java
new file mode 100644
index 0000000..9198666
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/DiffUtil.java
@@ -0,0 +1,79 @@
+// 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.patch;
+
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A utility class used by the diff cache interfaces {@link GitModifiedFilesCache} and {@link
+ * ModifiedFilesCache}.
+ */
+public class DiffUtil {
+
+ /**
+ * Returns the Git tree object ID pointed to by the commitId parameter.
+ *
+ * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+ * @param commitId 20 bytes commitId SHA-1 hash.
+ * @return Git tree object ID pointed to by the commitId.
+ */
+ public static ObjectId getTreeId(RevWalk rw, ObjectId commitId) throws IOException {
+ RevCommit current = rw.parseCommit(commitId);
+ return current.getTree().getId();
+ }
+
+ /**
+ * Returns the RevCommit object given the 20 bytes commitId SHA-1 hash.
+ *
+ * @param rw a {@link RevWalk} of an opened repository that is used to walk the commit graph.
+ * @param commitId 20 bytes commitId SHA-1 hash
+ * @return The RevCommit representing the commit in Git
+ * @throws IOException a pack file or loose object could not be read while parsing the commits.
+ */
+ public static RevCommit getRevCommit(RevWalk rw, ObjectId commitId) throws IOException {
+ return rw.parseCommit(commitId);
+ }
+
+ /**
+ * Returns true if the commitA and commitB parameters are parent/child, if they have a common
+ * parent, or if any of them is a root or merge commit.
+ */
+ public static boolean areRelated(RevCommit commitA, RevCommit commitB) {
+ return commitA == null
+ || isRootOrMergeCommit(commitA)
+ || isRootOrMergeCommit(commitB)
+ || areParentAndChild(commitA, commitB)
+ || haveCommonParent(commitA, commitB);
+ }
+
+ private static boolean isRootOrMergeCommit(RevCommit commit) {
+ return commit.getParentCount() != 1;
+ }
+
+ private static boolean areParentAndChild(RevCommit commitA, RevCommit commitB) {
+ return ObjectId.isEqual(commitA.getParent(0), commitB)
+ || ObjectId.isEqual(commitB.getParent(0), commitA);
+ }
+
+ private static boolean haveCommonParent(RevCommit commitA, RevCommit commitB) {
+ return ObjectId.isEqual(commitA.getParent(0), commitB.getParent(0));
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/EditTransformer.java b/java/com/google/gerrit/server/patch/EditTransformer.java
index 90f442e..6288270 100644
--- a/java/com/google/gerrit/server/patch/EditTransformer.java
+++ b/java/com/google/gerrit/server/patch/EditTransformer.java
@@ -15,19 +15,23 @@
package com.google.gerrit.server.patch;
import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.collect.Multimaps.toMultimap;
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.groupingBy;
-import static java.util.stream.Collectors.toList;
import com.google.auto.value.AutoValue;
import com.google.common.base.MoreObjects;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Multimap;
-import java.util.ArrayList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
+import com.google.gerrit.server.patch.GitPositionTransformer.OmitPositionOnConflict;
+import com.google.gerrit.server.patch.GitPositionTransformer.Position;
+import com.google.gerrit.server.patch.GitPositionTransformer.PositionedEntity;
+import com.google.gerrit.server.patch.GitPositionTransformer.Range;
import java.util.List;
-import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
@@ -42,7 +46,10 @@
* transformation are omitted.
*/
class EditTransformer {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+ private final GitPositionTransformer positionTransformer =
+ new GitPositionTransformer(OmitPositionOnConflict.INSTANCE);
private List<ContextAwareEdit> edits;
/**
@@ -105,76 +112,23 @@
}
private void transformEdits(List<PatchListEntry> transformingEntries, SideStrategy sideStrategy) {
- Map<String, List<ContextAwareEdit>> editsPerFilePath =
- edits.stream().collect(groupingBy(sideStrategy::getFilePath));
- Map<String, List<PatchListEntry>> transEntriesPerPath =
- transformingEntries.stream().collect(groupingBy(EditTransformer::getOldFilePath));
+ ImmutableList<PositionedEntity<ContextAwareEdit>> positionedEdits =
+ edits.stream()
+ .map(edit -> toPositionedEntity(edit, sideStrategy))
+ .collect(toImmutableList());
+ ImmutableSet<Mapping> mappings =
+ transformingEntries.stream().map(DiffMappings::toMapping).collect(toImmutableSet());
edits =
- editsPerFilePath.entrySet().stream()
- .flatMap(
- pathAndEdits -> {
- List<PatchListEntry> transEntries =
- transEntriesPerPath.getOrDefault(pathAndEdits.getKey(), ImmutableList.of());
- return transformEdits(sideStrategy, pathAndEdits.getValue(), transEntries);
- })
- .collect(toList());
+ positionTransformer.transform(positionedEdits, mappings).stream()
+ .map(PositionedEntity::getEntityAtUpdatedPosition)
+ .collect(toImmutableList());
}
- private static String getOldFilePath(PatchListEntry patchListEntry) {
- return MoreObjects.firstNonNull(patchListEntry.getOldName(), patchListEntry.getNewName());
- }
-
- private static Stream<ContextAwareEdit> transformEdits(
- SideStrategy sideStrategy,
- List<ContextAwareEdit> originalEdits,
- List<PatchListEntry> transformingEntries) {
- if (transformingEntries.isEmpty()) {
- return originalEdits.stream();
- }
-
- // TODO(aliceks): Find a way to prevent an explosion of the number of entries.
- return transformingEntries.stream()
- .flatMap(
- transEntry ->
- transformEdits(
- sideStrategy, originalEdits, transEntry.getEdits(), transEntry.getNewName())
- .stream());
- }
-
- private static List<ContextAwareEdit> transformEdits(
- SideStrategy sideStrategy,
- List<ContextAwareEdit> unorderedOriginalEdits,
- List<Edit> unorderedTransformingEdits,
- String adjustedFilePath) {
- List<ContextAwareEdit> originalEdits = new ArrayList<>(unorderedOriginalEdits);
- originalEdits.sort(comparing(sideStrategy::getBegin).thenComparing(sideStrategy::getEnd));
- List<Edit> transformingEdits = new ArrayList<>(unorderedTransformingEdits);
- transformingEdits.sort(comparing(Edit::getBeginA).thenComparing(Edit::getEndA));
-
- int shiftedAmount = 0;
- int transIndex = 0;
- int origIndex = 0;
- List<ContextAwareEdit> resultingEdits = new ArrayList<>(originalEdits.size());
- while (origIndex < originalEdits.size() && transIndex < transformingEdits.size()) {
- ContextAwareEdit originalEdit = originalEdits.get(origIndex);
- Edit transformingEdit = transformingEdits.get(transIndex);
- if (transformingEdit.getEndA() <= sideStrategy.getBegin(originalEdit)) {
- shiftedAmount = transformingEdit.getEndB() - transformingEdit.getEndA();
- transIndex++;
- } else if (sideStrategy.getEnd(originalEdit) <= transformingEdit.getBeginA()) {
- resultingEdits.add(sideStrategy.create(originalEdit, shiftedAmount, adjustedFilePath));
- origIndex++;
- } else {
- // Overlapping -> ignore.
- origIndex++;
- }
- }
- for (int i = origIndex; i < originalEdits.size(); i++) {
- resultingEdits.add(
- sideStrategy.create(originalEdits.get(i), shiftedAmount, adjustedFilePath));
- }
- return resultingEdits;
+ private static PositionedEntity<ContextAwareEdit> toPositionedEntity(
+ ContextAwareEdit edit, SideStrategy sideStrategy) {
+ return PositionedEntity.create(
+ edit, sideStrategy::extractPosition, sideStrategy::createEditAtNewPosition);
}
@AutoValue
@@ -191,6 +145,8 @@
}
static ContextAwareEdit createForNoContentEdit(PatchListEntry patchListEntry) {
+ // Remove the warning in createEditAtNewPosition() if we switch to an empty range instead of
+ // (-1:-1, -1:-1) in the future.
return create(
patchListEntry.getOldName(), patchListEntry.getNewName(), -1, -1, -1, -1, false);
}
@@ -234,44 +190,50 @@
}
private interface SideStrategy {
- String getFilePath(ContextAwareEdit edit);
+ Position extractPosition(ContextAwareEdit edit);
- int getBegin(ContextAwareEdit edit);
-
- int getEnd(ContextAwareEdit edit);
-
- ContextAwareEdit create(ContextAwareEdit edit, int shiftedAmount, String adjustedFilePath);
+ ContextAwareEdit createEditAtNewPosition(ContextAwareEdit edit, Position newPosition);
}
private enum SideAStrategy implements SideStrategy {
INSTANCE;
@Override
- public String getFilePath(ContextAwareEdit edit) {
- return edit.getOldFilePath();
+ public Position extractPosition(ContextAwareEdit edit) {
+ return Position.builder()
+ .filePath(edit.getOldFilePath())
+ .lineRange(Range.create(edit.getBeginA(), edit.getEndA()))
+ .build();
}
@Override
- public int getBegin(ContextAwareEdit edit) {
- return edit.getBeginA();
- }
-
- @Override
- public int getEnd(ContextAwareEdit edit) {
- return edit.getEndA();
- }
-
- @Override
- public ContextAwareEdit create(
- ContextAwareEdit edit, int shiftedAmount, String adjustedFilePath) {
+ public ContextAwareEdit createEditAtNewPosition(ContextAwareEdit edit, Position newPosition) {
+ // Use an empty range at Gerrit "file level" if no target range is available. Such an empty
+ // range should not occur right now but this should be a safe fallback if something changes
+ // in the future.
+ Range updatedRange = newPosition.lineRange().orElseGet(() -> Range.create(-1, -1));
+ if (!newPosition.lineRange().isPresent()) {
+ logger.atWarning().log(
+ "Position %s has an empty range which is unexpected for the edits-due-to-rebase"
+ + " computation. This is likely a regression!",
+ newPosition);
+ }
+ // Same as for the range above. PATCHSET_LEVEL is a safe fallback.
+ String updatedFilePath = newPosition.filePath().orElse(Patch.PATCHSET_LEVEL);
+ if (!newPosition.filePath().isPresent()) {
+ logger.atWarning().log(
+ "Position %s has an empty file path which is unexpected for the edits-due-to-rebase"
+ + " computation. This is likely a regression!",
+ newPosition);
+ }
return ContextAwareEdit.create(
- adjustedFilePath,
+ updatedFilePath,
edit.getNewFilePath(),
- edit.getBeginA() + shiftedAmount,
- edit.getEndA() + shiftedAmount,
+ updatedRange.start(),
+ updatedRange.end(),
edit.getBeginB(),
edit.getEndB(),
- !Objects.equals(edit.getOldFilePath(), adjustedFilePath));
+ !Objects.equals(edit.getOldFilePath(), updatedFilePath));
}
}
@@ -279,31 +241,29 @@
INSTANCE;
@Override
- public String getFilePath(ContextAwareEdit edit) {
- return edit.getNewFilePath();
+ public Position extractPosition(ContextAwareEdit edit) {
+ return Position.builder()
+ .filePath(edit.getNewFilePath())
+ .lineRange(Range.create(edit.getBeginB(), edit.getEndB()))
+ .build();
}
@Override
- public int getBegin(ContextAwareEdit edit) {
- return edit.getBeginB();
- }
-
- @Override
- public int getEnd(ContextAwareEdit edit) {
- return edit.getEndB();
- }
-
- @Override
- public ContextAwareEdit create(
- ContextAwareEdit edit, int shiftedAmount, String adjustedFilePath) {
+ public ContextAwareEdit createEditAtNewPosition(ContextAwareEdit edit, Position newPosition) {
+ // Use an empty range at Gerrit "file level" if no target range is available. Such an empty
+ // range should not occur right now but this should be a safe fallback if something changes
+ // in the future.
+ Range updatedRange = newPosition.lineRange().orElseGet(() -> Range.create(-1, -1));
+ // Same as far the range above. PATCHSET_LEVEL is a safe fallback.
+ String updatedFilePath = newPosition.filePath().orElse(Patch.PATCHSET_LEVEL);
return ContextAwareEdit.create(
edit.getOldFilePath(),
- adjustedFilePath,
+ updatedFilePath,
edit.getBeginA(),
edit.getEndA(),
- edit.getBeginB() + shiftedAmount,
- edit.getEndB() + shiftedAmount,
- !Objects.equals(edit.getNewFilePath(), adjustedFilePath));
+ updatedRange.start(),
+ updatedRange.end(),
+ !Objects.equals(edit.getNewFilePath(), updatedFilePath));
}
}
}
diff --git a/java/com/google/gerrit/server/patch/GitPositionTransformer.java b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
new file mode 100644
index 0000000..d890bc2
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/GitPositionTransformer.java
@@ -0,0 +1,643 @@
+// 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.patch;
+
+import static com.google.common.collect.Comparators.emptiesFirst;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.collectingAndThen;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+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.function.BiFunction;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * Transformer of {@link Position}s in one Git tree to {@link Position}s in another Git tree given
+ * the {@link Mapping}s between the trees.
+ *
+ * <p>The base idea is that a {@link Position} in the source tree can be translated/mapped to a
+ * corresponding {@link Position} in the target tree when we know how the target tree changed
+ * compared to the source tree. As long as {@link Position}s are only defined via file path and line
+ * range, we only need to know which file path in the source tree corresponds to which file path in
+ * the target tree and how the lines within that file changed from the source to the target tree.
+ *
+ * <p>The algorithm is roughly:
+ *
+ * <ol>
+ * <li>Go over all positions and replace the file path for each of them with the corresponding one
+ * in the target tree. If a file path maps to two file paths in the target tree (copied file),
+ * duplicate the position entry and use each of the new file paths with it. If a file path
+ * maps to no file in the target tree (deleted file), apply the specified conflict strategy
+ * (e.g. drop position completely or map to next best guess).
+ * <li>Per file path, go through the file from top to bottom and keep track of how the range
+ * mappings for that file shift the lines. Derive the shifted amount by comparing the number
+ * of lines between source and target in the range mapping. While going through the file,
+ * shift each encountered position by the currently tracked amount. If a position overlaps
+ * with the lines of a range mapping, apply the specified conflict strategy (e.g. drop
+ * position completely or map to next best guess).
+ * </ol>
+ */
+public class GitPositionTransformer {
+ private final PositionConflictStrategy positionConflictStrategy;
+
+ /**
+ * Creates a new {@code GitPositionTransformer} which uses the specified strategy for conflicts.
+ */
+ public GitPositionTransformer(PositionConflictStrategy positionConflictStrategy) {
+ this.positionConflictStrategy = positionConflictStrategy;
+ }
+
+ /**
+ * Transforms the {@link Position}s of the specified entities as indicated via the {@link
+ * Mapping}s.
+ *
+ * <p>This is typically used to transform the {@link Position}s in one Git tree (source) to the
+ * corresponding {@link Position}s in another Git tree (target). The {@link Mapping}s need to
+ * indicate all relevant changes between the source and target tree. {@link Mapping}s for files
+ * not referenced by the given {@link Position}s need not be specified. They can be included,
+ * though, as they aren't harmful.
+ *
+ * @param entities the entities whose {@link Position} should be mapped to the target tree
+ * @param mappings the mappings describing all relevant changes between the source and the target
+ * tree
+ * @param <T> an entity which has a {@link Position}
+ * @return a list of entities with transformed positions. There are no guarantees about the order
+ * of the returned elements.
+ */
+ public <T> ImmutableList<PositionedEntity<T>> transform(
+ Collection<PositionedEntity<T>> entities, Set<Mapping> mappings) {
+ // Update the file paths first as copied files might exist. For copied files, this operation
+ // will duplicate the PositionedEntity instances of the original file.
+ List<PositionedEntity<T>> filePathUpdatedEntities = updateFilePaths(entities, mappings);
+
+ return shiftRanges(filePathUpdatedEntities, mappings);
+ }
+
+ private <T> ImmutableList<PositionedEntity<T>> updateFilePaths(
+ Collection<PositionedEntity<T>> entities, Set<Mapping> mappings) {
+ Map<String, ImmutableSet<String>> newFilesPerOldFile = groupNewFilesByOldFiles(mappings);
+ return entities.stream()
+ .flatMap(entity -> mapToNewFileIfChanged(newFilesPerOldFile, entity))
+ .collect(toImmutableList());
+ }
+
+ private static Map<String, ImmutableSet<String>> groupNewFilesByOldFiles(Set<Mapping> mappings) {
+ return mappings.stream()
+ .map(Mapping::file)
+ // Ignore file additions (irrelevant for mappings).
+ .filter(mapping -> mapping.oldPath().isPresent())
+ .collect(
+ groupingBy(
+ mapping -> mapping.oldPath().orElse(""),
+ collectingAndThen(
+ Collectors.mapping(FileMapping::newPath, toImmutableSet()),
+ // File deletion (empty Optional) -> empty set.
+ GitPositionTransformer::unwrapOptionals)));
+ }
+
+ private static ImmutableSet<String> unwrapOptionals(ImmutableSet<Optional<String>> optionals) {
+ return optionals.stream().flatMap(Streams::stream).collect(toImmutableSet());
+ }
+
+ private <T> Stream<PositionedEntity<T>> mapToNewFileIfChanged(
+ Map<String, ? extends Set<String>> newFilesPerOldFile, PositionedEntity<T> entity) {
+ if (!entity.position().filePath().isPresent()) {
+ // No mapping of file paths necessary if no file path is set. -> Keep existing entry.
+ return Stream.of(entity);
+ }
+ String oldFilePath = entity.position().filePath().get();
+ if (!newFilesPerOldFile.containsKey(oldFilePath)) {
+ // Unchanged files don't have a mapping. -> Keep existing entries.
+ return Stream.of(entity);
+ }
+ Set<String> newFiles = newFilesPerOldFile.get(oldFilePath);
+ if (newFiles.isEmpty()) {
+ // File was deleted.
+ return Streams.stream(
+ positionConflictStrategy.getOnFileConflict(entity.position()).map(entity::withPosition));
+ }
+ return newFiles.stream().map(entity::withFilePath);
+ }
+
+ private <T> ImmutableList<PositionedEntity<T>> shiftRanges(
+ List<PositionedEntity<T>> filePathUpdatedEntities, Set<Mapping> mappings) {
+ Map<String, ImmutableSet<RangeMapping>> mappingsPerNewFilePath =
+ groupRangeMappingsByNewFilePath(mappings);
+ return Stream.concat(
+ // Keep positions without a file.
+ filePathUpdatedEntities.stream()
+ .filter(entity -> !entity.position().filePath().isPresent()),
+ // Shift ranges per file.
+ groupByFilePath(filePathUpdatedEntities).entrySet().stream()
+ .flatMap(
+ newFilePathAndEntities ->
+ shiftRangesInOneFileIfChanged(
+ mappingsPerNewFilePath,
+ newFilePathAndEntities.getKey(),
+ newFilePathAndEntities.getValue())
+ .stream()))
+ .collect(toImmutableList());
+ }
+
+ private static Map<String, ImmutableSet<RangeMapping>> groupRangeMappingsByNewFilePath(
+ Set<Mapping> mappings) {
+ return mappings.stream()
+ // Ignore range mappings of deleted files.
+ .filter(mapping -> mapping.file().newPath().isPresent())
+ .collect(
+ groupingBy(
+ mapping -> mapping.file().newPath().orElse(""),
+ collectingAndThen(
+ Collectors.<Mapping, Set<RangeMapping>>reducing(
+ new HashSet<>(), Mapping::ranges, Sets::union),
+ ImmutableSet::copyOf)));
+ }
+
+ private static <T> Map<String, ImmutableList<PositionedEntity<T>>> groupByFilePath(
+ List<PositionedEntity<T>> fileUpdatedEntities) {
+ return fileUpdatedEntities.stream()
+ .filter(entity -> entity.position().filePath().isPresent())
+ .collect(groupingBy(entity -> entity.position().filePath().orElse(""), toImmutableList()));
+ }
+
+ private <T> ImmutableList<PositionedEntity<T>> shiftRangesInOneFileIfChanged(
+ Map<String, ImmutableSet<RangeMapping>> mappingsPerNewFilePath,
+ String newFilePath,
+ ImmutableList<PositionedEntity<T>> sameFileEntities) {
+ ImmutableSet<RangeMapping> sameFileRangeMappings =
+ mappingsPerNewFilePath.getOrDefault(newFilePath, ImmutableSet.of());
+ if (sameFileRangeMappings.isEmpty()) {
+ // Unchanged files and pure renames/copies don't have range mappings. -> Keep existing
+ // entries.
+ return sameFileEntities;
+ }
+ return shiftRangesInOneFile(sameFileEntities, sameFileRangeMappings);
+ }
+
+ private <T> ImmutableList<PositionedEntity<T>> shiftRangesInOneFile(
+ List<PositionedEntity<T>> sameFileEntities, Set<RangeMapping> sameFileRangeMappings) {
+ ImmutableList<PositionedEntity<T>> sortedEntities = sortByStartEnd(sameFileEntities);
+ ImmutableList<RangeMapping> sortedMappings = sortByOldStartEnd(sameFileRangeMappings);
+
+ int shiftedAmount = 0;
+ int mappingIndex = 0;
+ int entityIndex = 0;
+ ImmutableList.Builder<PositionedEntity<T>> resultingEntities =
+ ImmutableList.builderWithExpectedSize(sortedEntities.size());
+ while (entityIndex < sortedEntities.size() && mappingIndex < sortedMappings.size()) {
+ PositionedEntity<T> entity = sortedEntities.get(entityIndex);
+ if (entity.position().lineRange().isPresent()) {
+ Range range = entity.position().lineRange().get();
+ RangeMapping mapping = sortedMappings.get(mappingIndex);
+ if (mapping.oldLineRange().end() <= range.start()) {
+ shiftedAmount = mapping.newLineRange().end() - mapping.oldLineRange().end();
+ mappingIndex++;
+ } else if (range.end() <= mapping.oldLineRange().start()) {
+ resultingEntities.add(entity.shiftPositionBy(shiftedAmount));
+ entityIndex++;
+ } else {
+ positionConflictStrategy
+ .getOnRangeConflict(entity.position())
+ .map(entity::withPosition)
+ .ifPresent(resultingEntities::add);
+ entityIndex++;
+ }
+ } else {
+ // No range -> no need to shift.
+ resultingEntities.add(entity);
+ entityIndex++;
+ }
+ }
+ for (int i = entityIndex; i < sortedEntities.size(); i++) {
+ resultingEntities.add(sortedEntities.get(i).shiftPositionBy(shiftedAmount));
+ }
+ return resultingEntities.build();
+ }
+
+ private static <T> ImmutableList<PositionedEntity<T>> sortByStartEnd(
+ List<PositionedEntity<T>> entities) {
+ return entities.stream()
+ .sorted(
+ comparing(
+ entity -> entity.position().lineRange(),
+ emptiesFirst(comparing(Range::start).thenComparing(Range::end))))
+ .collect(toImmutableList());
+ }
+
+ private static ImmutableList<RangeMapping> sortByOldStartEnd(Set<RangeMapping> mappings) {
+ return mappings.stream()
+ .sorted(
+ comparing(
+ RangeMapping::oldLineRange, comparing(Range::start).thenComparing(Range::end)))
+ .collect(toImmutableList());
+ }
+
+ /**
+ * A mapping from a {@link Position} in one Git commit/tree (source) to a {@link Position} in
+ * another Git commit/tree (target).
+ */
+ @AutoValue
+ public abstract static class Mapping {
+
+ /** A mapping describing how the attributes of one file are mapped from source to target. */
+ public abstract FileMapping file();
+
+ /**
+ * Mappings describing how line ranges within the file indicated by {@link #file()} are mapped
+ * from source to target.
+ */
+ public abstract ImmutableSet<RangeMapping> ranges();
+
+ public static Mapping create(FileMapping fileMapping, Iterable<RangeMapping> rangeMappings) {
+ return new AutoValue_GitPositionTransformer_Mapping(
+ fileMapping, ImmutableSet.copyOf(rangeMappings));
+ }
+ }
+
+ /**
+ * A mapping of attributes from a file in one Git tree (source) to a file in another Git tree
+ * (target).
+ *
+ * <p>At the moment, only the file path is considered. Other attributes like file mode would be
+ * imaginable too but are currently not supported.
+ */
+ @AutoValue
+ public abstract static class FileMapping {
+
+ /** File path in the source tree. For file additions, this is an empty {@link Optional}. */
+ public abstract Optional<String> oldPath();
+
+ /**
+ * File path in the target tree. Can be the same as {@link #oldPath()} if unchanged. For file
+ * deletions, this is an empty {@link Optional}.
+ */
+ public abstract Optional<String> newPath();
+
+ /**
+ * Creates a {@link FileMapping} for a file addition.
+ *
+ * <p>In the context of {@link GitPositionTransformer}, file additions are irrelevant as no
+ * given position in the source tree can refer to such a new file in the target tree. We still
+ * provide this factory method so that code outside of {@link GitPositionTransformer} doesn't
+ * have to care about such details and can simply create {@link FileMapping}s for any
+ * modifications between the trees.
+ */
+ public static FileMapping forAddedFile(String filePath) {
+ return new AutoValue_GitPositionTransformer_FileMapping(
+ Optional.empty(), Optional.of(filePath));
+ }
+
+ /** Creates a {@link FileMapping} for a file deletion. */
+ public static FileMapping forDeletedFile(String filePath) {
+ return new AutoValue_GitPositionTransformer_FileMapping(
+ Optional.of(filePath), Optional.empty());
+ }
+
+ /** Creates a {@link FileMapping} for a file modification. */
+ public static FileMapping forModifiedFile(String filePath) {
+ return new AutoValue_GitPositionTransformer_FileMapping(
+ Optional.of(filePath), Optional.of(filePath));
+ }
+
+ /** Creates a {@link FileMapping} for a file renaming. */
+ public static FileMapping forRenamedFile(String oldPath, String newPath) {
+ return new AutoValue_GitPositionTransformer_FileMapping(
+ Optional.of(oldPath), Optional.of(newPath));
+ }
+ }
+
+ /**
+ * A mapping of a line range in one Git tree (source) to the corresponding line range in another
+ * Git tree (target).
+ */
+ @AutoValue
+ public abstract static class RangeMapping {
+
+ /** Range in the source tree. */
+ public abstract Range oldLineRange();
+
+ /** Range in the target tree. */
+ public abstract Range newLineRange();
+
+ /**
+ * Creates a new {@code RangeMapping}.
+ *
+ * @param oldRange see {@link #oldLineRange()}
+ * @param newRange see {@link #newLineRange()}
+ */
+ public static RangeMapping create(Range oldRange, Range newRange) {
+ return new AutoValue_GitPositionTransformer_RangeMapping(oldRange, newRange);
+ }
+ }
+
+ /**
+ * A position within the tree of a Git commit.
+ *
+ * <p>The term 'position' is our own invention. The underlying idea is that a Gerrit comment is at
+ * a specific position within the commit of a patchset. That position is defined by the attributes
+ * defined in this class.
+ *
+ * <p>The same thinking can be applied to diff hunks (= JGit edits). Each diff hunk maps a
+ * position in one commit (e.g. in the parent of the patchset) to a position in another commit
+ * (e.g. in the commit of the patchset).
+ *
+ * <p>We only refer to lines and not character offsets within the lines here as Git only works
+ * with line precision. In theory, we could do better in Gerrit as we also have intraline diffs.
+ * Incorporating those requires careful considerations, though.
+ */
+ @AutoValue
+ public abstract static class Position {
+
+ /** Absolute file path. */
+ public abstract Optional<String> filePath();
+
+ /**
+ * Affected lines. An empty {@link Optional} indicates that this position does not refer to any
+ * specific lines (e.g. used for a file comment).
+ */
+ public abstract Optional<Range> lineRange();
+
+ /**
+ * Creates a copy of this {@code Position} whose range is shifted by the indicated amount.
+ *
+ * <p><strong>Note:</strong> There's no guarantee that this method returns a new instance.
+ *
+ * @param amount number of lines to shift. Negative values mean moving the range up, positive
+ * values mean moving the range down.
+ * @return a {@code Position} instance with the updated range
+ */
+ public Position shiftBy(int amount) {
+ return lineRange()
+ .map(range -> toBuilder().lineRange(range.shiftBy(amount)).build())
+ .orElse(this);
+ }
+
+ /**
+ * Creates a copy of this {@code Position} which doesn't refer to any specific lines.
+ *
+ * <p><strong>Note:</strong> There's no guarantee that this method returns a new instance.
+ *
+ * @return a {@code Position} instance without a line range
+ */
+ public Position withoutLineRange() {
+ return toBuilder().lineRange(Optional.empty()).build();
+ }
+
+ /**
+ * Creates a copy of this {@code Position} whose file path is adjusted to the indicated value.
+ *
+ * <p><strong>Note:</strong> There's no guarantee that this method returns a new instance.
+ *
+ * @param filePath the new file path to use
+ * @return a {@code Position} instance with the indicated file path
+ */
+ public Position withFilePath(String filePath) {
+ return toBuilder().filePath(filePath).build();
+ }
+
+ abstract Builder toBuilder();
+
+ public static Builder builder() {
+ return new AutoValue_GitPositionTransformer_Position.Builder();
+ }
+
+ /** Builder of a {@link Position}. */
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ /** See {@link #filePath()}. */
+ public abstract Builder filePath(String filePath);
+
+ /** See {@link #lineRange()}. */
+ public abstract Builder lineRange(Range lineRange);
+
+ /** See {@link #lineRange()}. */
+ public abstract Builder lineRange(Optional<Range> lineRange);
+
+ public abstract Position build();
+ }
+ }
+
+ /** A range. In the context of {@link GitPositionTransformer}, this is a line range. */
+ @AutoValue
+ public abstract static class Range {
+
+ /** Start of the range. (inclusive) */
+ public abstract int start();
+
+ /** End of the range. (exclusive) */
+ public abstract int end();
+
+ /**
+ * Creates a copy of this {@code Range} which is shifted by the indicated amount. A shift
+ * equally applies to both {@link #start()} end {@link #end()}.
+ *
+ * <p><strong>Note:</strong> There's no guarantee that this method returns a new instance.
+ *
+ * @param amount amount to shift. Negative values mean moving the range up, positive values mean
+ * moving the range down.
+ * @return a {@code Range} instance with updated start/end
+ */
+ public Range shiftBy(int amount) {
+ return create(start() + amount, end() + amount);
+ }
+
+ public static Range create(int start, int end) {
+ return new AutoValue_GitPositionTransformer_Range(start, end);
+ }
+ }
+
+ /**
+ * Wrapper around an instance of {@code T} which annotates it with a {@link Position}. Methods
+ * such as {@link #shiftPositionBy(int)} and {@link #withFilePath(String)} allow to update the
+ * associated {@link Position}. Afterwards, use {@link #getEntityAtUpdatedPosition()} to get an
+ * updated version of the {@code T} instance.
+ *
+ * @param <T> an object/entity type which has a {@link Position}
+ */
+ public static class PositionedEntity<T> {
+
+ private final T entity;
+ private final Position position;
+ private final BiFunction<T, Position, T> updatedEntityCreator;
+
+ /**
+ * Creates a new {@code PositionedEntity}.
+ *
+ * @param entity an instance which should be annotated with a {@link Position}
+ * @param positionExtractor a function describing how a {@link Position} can be derived from the
+ * given entity
+ * @param updatedEntityCreator a function to create a new entity of type {@code T} from an
+ * existing entity and a given {@link Position}. This must return a new instance of type
+ * {@code T}! The existing instance must not be modified!
+ * @param <T> an object/entity type which has a {@link Position}
+ */
+ public static <T> PositionedEntity<T> create(
+ T entity,
+ Function<T, Position> positionExtractor,
+ BiFunction<T, Position, T> updatedEntityCreator) {
+ Position position = positionExtractor.apply(entity);
+ return new PositionedEntity<>(entity, position, updatedEntityCreator);
+ }
+
+ private PositionedEntity(
+ T entity, Position position, BiFunction<T, Position, T> updatedEntityCreator) {
+ this.entity = entity;
+ this.position = position;
+ this.updatedEntityCreator = updatedEntityCreator;
+ }
+
+ /**
+ * Returns an updated version of the entity to which the internally stored {@link Position} was
+ * written back to.
+ *
+ * @return an updated instance of {@code T}
+ */
+ public T getEntityAtUpdatedPosition() {
+ return updatedEntityCreator.apply(entity, position);
+ }
+
+ Position position() {
+ return position;
+ }
+
+ /**
+ * Shifts the tracked {@link Position} by the specified amount.
+ *
+ * @param amount number of lines to shift. Negative values mean moving the range up, positive
+ * values mean moving the range down.
+ * @return a {@code PositionedEntity} with updated {@link Position}
+ */
+ public PositionedEntity<T> shiftPositionBy(int amount) {
+ return new PositionedEntity<>(entity, position.shiftBy(amount), updatedEntityCreator);
+ }
+
+ /**
+ * Updates the file path of the tracked {@link Position}.
+ *
+ * @param filePath the new file path to use
+ * @return a {@code PositionedEntity} with updated {@link Position}
+ */
+ public PositionedEntity<T> withFilePath(String filePath) {
+ return new PositionedEntity<>(entity, position.withFilePath(filePath), updatedEntityCreator);
+ }
+
+ /**
+ * Updates the tracked {@link Position}.
+ *
+ * @return a {@code PositionedEntity} with updated {@link Position}
+ */
+ public PositionedEntity<T> withPosition(Position newPosition) {
+ return new PositionedEntity<>(entity, newPosition, updatedEntityCreator);
+ }
+ }
+
+ /**
+ * Strategy indicating how to handle {@link Position}s for which mapping conflicts exist. A
+ * mapping conflict means that a {@link Position} can't be transformed such that it still refers
+ * to exactly the same commit content afterwards.
+ *
+ * <p>Example: A {@link Position} refers to file foo.txt and lines 5-6 which contain the text
+ * "Line 5\nLine 6". One of the {@link Mapping}s given to {@link #transform(Collection, Set)}
+ * indicates that line 5 of foo.txt was modified to "Line five\nLine 5.1\n". We could derive a
+ * transformed {@link Position} (foo.txt, lines 5-7) but that {@link Position} would then refer to
+ * the content "Line five\nLine 5.1\nLine 6". If the modification started already in line 4, we
+ * could even only guess what the transformed {@link Position} would be.
+ */
+ public interface PositionConflictStrategy {
+ /**
+ * Determines an alternate {@link Position} when the range of the position can't be mapped
+ * without a conflict.
+ *
+ * @param oldPosition position in the source tree
+ * @return the new {@link Position} or an empty {@link Optional} if the position should be
+ * dropped
+ */
+ Optional<Position> getOnRangeConflict(Position oldPosition);
+
+ /**
+ * Determines an alternate {@link Position} when there is no file for the position (= file
+ * deletion) in the target tree.
+ *
+ * @param oldPosition position in the source tree
+ * @return the new {@link Position} or an empty {@link Optional} if the position should be *
+ * dropped
+ */
+ Optional<Position> getOnFileConflict(Position oldPosition);
+ }
+
+ /**
+ * A strategy which drops any {@link Position}s on a conflicting mapping. Such a strategy is
+ * useful if it's important that any mapped {@link Position} still refers to exactly the same
+ * commit content as before. See more details at {@link PositionConflictStrategy}.
+ *
+ * <p>We need this strategy for computing edits due to rebase.
+ */
+ public enum OmitPositionOnConflict implements PositionConflictStrategy {
+ INSTANCE;
+
+ @Override
+ public Optional<Position> getOnRangeConflict(Position oldPosition) {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<Position> getOnFileConflict(Position oldPosition) {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * A strategy which tries to select the next suitable {@link Position} on a conflicting mapping.
+ * At the moment, this strategy is very basic and only defers to the next higher level (e.g. range
+ * unclear -> drop range but keep file reference). This could be improved in the future.
+ *
+ * <p>We need this strategy for ported comments.
+ *
+ * <p><strong>Warning:</strong> With this strategy, mapped {@link Position}s are not guaranteed to
+ * refer to exactly the same commit content as before. See more details at {@link
+ * PositionConflictStrategy}.
+ *
+ * <p>Contract: This strategy will never drop any {@link Position}.
+ */
+ public enum BestPositionOnConflict implements PositionConflictStrategy {
+ INSTANCE;
+
+ @Override
+ public Optional<Position> getOnRangeConflict(Position oldPosition) {
+ return Optional.of(oldPosition.withoutLineRange());
+ }
+
+ @Override
+ public Optional<Position> getOnFileConflict(Position oldPosition) {
+ // If there isn't a target file, we can also drop any ranges.
+ return Optional.of(Position.builder().build());
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/MagicFile.java b/java/com/google/gerrit/server/patch/MagicFile.java
new file mode 100644
index 0000000..aa6b11f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/MagicFile.java
@@ -0,0 +1,189 @@
+// 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.patch;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.CharMatcher;
+import com.google.gerrit.git.ObjectIds;
+import java.io.IOException;
+import java.text.SimpleDateFormat;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Representation of a magic file which appears as a file with content to Gerrit users. */
+@AutoValue
+public abstract class MagicFile {
+
+ public static MagicFile forCommitMessage(ObjectReader reader, AnyObjectId commitId)
+ throws IOException {
+ try (RevWalk rw = new RevWalk(reader)) {
+ RevCommit c;
+ if (commitId instanceof RevCommit) {
+ c = (RevCommit) commitId;
+ } else {
+ c = rw.parseCommit(commitId);
+ }
+
+ String header = createCommitMessageHeader(reader, rw, c);
+ String message = c.getFullMessage();
+ return MagicFile.builder().generatedContent(header).modifiableContent(message).build();
+ }
+ }
+
+ private static String createCommitMessageHeader(ObjectReader reader, RevWalk rw, RevCommit c)
+ throws IOException {
+ StringBuilder b = new StringBuilder();
+ switch (c.getParentCount()) {
+ case 0:
+ break;
+ case 1:
+ {
+ RevCommit p = c.getParent(0);
+ rw.parseBody(p);
+ b.append("Parent: ");
+ b.append(abbreviateName(p, reader));
+ b.append(" (");
+ b.append(p.getShortMessage());
+ b.append(")\n");
+ break;
+ }
+ default:
+ for (int i = 0; i < c.getParentCount(); i++) {
+ RevCommit p = c.getParent(i);
+ rw.parseBody(p);
+ b.append(i == 0 ? "Merge Of: " : " ");
+ b.append(abbreviateName(p, reader));
+ b.append(" (");
+ b.append(p.getShortMessage());
+ b.append(")\n");
+ }
+ }
+ appendPersonIdent(b, "Author", c.getAuthorIdent());
+ appendPersonIdent(b, "Commit", c.getCommitterIdent());
+ b.append("\n");
+ return b.toString();
+ }
+
+ public static MagicFile forMergeList(
+ ComparisonType comparisonType, ObjectReader reader, AnyObjectId commitId) throws IOException {
+ try (RevWalk rw = new RevWalk(reader)) {
+ RevCommit c = rw.parseCommit(commitId);
+ StringBuilder b = new StringBuilder();
+ switch (c.getParentCount()) {
+ case 0:
+ break;
+ case 1:
+ {
+ break;
+ }
+ default:
+ int uninterestingParent =
+ comparisonType.isAgainstParent() ? comparisonType.getParentNum() : 1;
+
+ b.append("Merge List:\n\n");
+ for (RevCommit commit : MergeListBuilder.build(rw, c, uninterestingParent)) {
+ b.append("* ");
+ b.append(abbreviateName(commit, reader));
+ b.append(" ");
+ b.append(commit.getShortMessage());
+ b.append("\n");
+ }
+ }
+ return MagicFile.builder().generatedContent(b.toString()).build();
+ }
+ }
+
+ private static String abbreviateName(RevCommit p, ObjectReader reader) throws IOException {
+ return ObjectIds.abbreviateName(p, 8, reader);
+ }
+
+ private static void appendPersonIdent(StringBuilder b, String field, PersonIdent person) {
+ if (person != null) {
+ b.append(field).append(": ");
+ if (person.getName() != null) {
+ b.append(" ");
+ b.append(person.getName());
+ }
+ if (person.getEmailAddress() != null) {
+ b.append(" <");
+ b.append(person.getEmailAddress());
+ b.append(">");
+ }
+ b.append("\n");
+
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss ZZZ");
+ sdf.setTimeZone(person.getTimeZone());
+ b.append(field).append("Date: ");
+ b.append(sdf.format(person.getWhen()));
+ b.append("\n");
+ }
+ }
+
+ /** Generated part of the file. Any generated contents should go here. Can be empty. */
+ public abstract String generatedContent();
+
+ /**
+ * Non-generated part of the file. This should correspond to some actual content derived from
+ * somewhere else which can also be modified (e.g. by suggested fixes). Can be empty.
+ */
+ public abstract String modifiableContent();
+
+ /** Whole content of the file as it appears to users. */
+ public String getFileContent() {
+ return generatedContent() + modifiableContent();
+ }
+
+ /** Returns the start line of the modifiable content. Assumes that line counting starts at 1. */
+ public int getStartLineOfModifiableContent() {
+ int numHeaderLines = CharMatcher.is('\n').countIn(generatedContent());
+ // Lines start at 1 and not 0. -> Add 1.
+ return 1 + numHeaderLines;
+ }
+
+ static Builder builder() {
+ return new AutoValue_MagicFile.Builder().generatedContent("").modifiableContent("");
+ }
+
+ @AutoValue.Builder
+ abstract static class Builder {
+
+ /** See {@link #generatedContent()}. Use an empty string to denote no such content. */
+ public abstract Builder generatedContent(String content);
+
+ /** See {@link #modifiableContent()}. Use an empty string to denote no such content. */
+ public abstract Builder modifiableContent(String content);
+
+ abstract String generatedContent();
+
+ abstract String modifiableContent();
+
+ abstract MagicFile autoBuild();
+
+ public MagicFile build() {
+ // Normalize each content part to end with a newline character, which simplifies further
+ // handling.
+ if (!generatedContent().isEmpty() && !generatedContent().endsWith("\n")) {
+ generatedContent(generatedContent() + "\n");
+ }
+ if (!modifiableContent().isEmpty() && !modifiableContent().endsWith("\n")) {
+ modifiableContent(modifiableContent() + "\n");
+ }
+ return autoBuild();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/PatchListLoader.java b/java/com/google/gerrit/server/patch/PatchListLoader.java
index be0895b..2e9d58c 100644
--- a/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -611,15 +611,16 @@
rw.parseBody(r);
return r;
}
- case 2:
+ default:
if (key.getParentNum() != null) {
RevCommit r = b.getParent(key.getParentNum() - 1);
rw.parseBody(r);
return r;
}
- return autoMerger.merge(repo, rw, ins, b, mergeStrategy);
- default:
- // TODO(sop) handle an octopus merge.
+ // Only support auto-merge for 2 parents, not octopus merges
+ if (b.getParentCount() == 2) {
+ return autoMerger.merge(repo, rw, ins, b, mergeStrategy);
+ }
return null;
}
}
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 9b8409d..c6f7acf 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -18,7 +18,6 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.CommentDetail;
import com.google.gerrit.common.data.PatchScript;
import com.google.gerrit.common.data.PatchScript.DisplayMethod;
import com.google.gerrit.entities.FixReplacement;
@@ -69,8 +68,7 @@
intralineDiffCalculator = calculator;
}
- PatchScript toPatchScript(
- Repository git, PatchList list, PatchListEntry content, CommentDetail comments)
+ PatchScript toPatchScript(Repository git, PatchList list, PatchListEntry content)
throws IOException {
PatchFileChange change =
@@ -86,7 +84,7 @@
ResolvedSides sides =
resolveSides(
git, sidesResolver, oldName(change), newName(change), list.getOldId(), list.getNewId());
- return build(sides.a, sides.b, change, comments);
+ return build(sides.a, sides.b, change);
}
private ResolvedSides resolveSides(
@@ -136,7 +134,7 @@
ChangeType.MODIFIED,
PatchType.UNIFIED);
- return build(a, b, change, null);
+ return build(a, b, change);
}
private PatchSide resolveSideA(
@@ -147,9 +145,7 @@
}
}
- private PatchScript build(
- PatchSide a, PatchSide b, PatchFileChange content, CommentDetail comments) {
-
+ private PatchScript build(PatchSide a, PatchSide b, PatchFileChange content) {
ImmutableList<Edit> contentEdits = content.getEdits();
ImmutableSet<Edit> editsDueToRebase = content.getEditsDueToRebase();
@@ -163,8 +159,7 @@
ImmutableList<Edit> finalEdits = intralineResult.edits.orElse(contentEdits);
DiffContentCalculator calculator = new DiffContentCalculator(diffPrefs);
DiffCalculatorResult diffCalculatorResult =
- calculator.calculateDiffContent(
- new TextSource(a.src), new TextSource(b.src), finalEdits, comments);
+ calculator.calculateDiffContent(new TextSource(a.src), new TextSource(b.src), finalEdits);
return new PatchScript(
content.getChangeType(),
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 30930ec..02f46df 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -20,19 +20,13 @@
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.CommentDetail;
import com.google.gerrit.common.data.PatchScript;
-import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.entities.Patch.ChangeType;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.CommentsUtil;
-import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.edit.ChangeEdit;
import com.google.gerrit.server.edit.ChangeEditUtil;
@@ -84,7 +78,6 @@
private final PatchSetUtil psUtil;
private final Provider<PatchScriptBuilder> builderFactory;
private final PatchListCache patchListCache;
- private final CommentsUtil commentsUtil;
private final String fileName;
@Nullable private final PatchSet.Id psa;
@@ -92,12 +85,10 @@
private final PatchSet.Id psb;
private final DiffPreferencesInfo diffPrefs;
private final ChangeEditUtil editReader;
- private final Provider<CurrentUser> userProvider;
private final PermissionBackend permissionBackend;
private final ProjectCache projectCache;
private final Change.Id changeId;
- private boolean loadComments = true;
private ChangeNotes notes;
@@ -107,9 +98,7 @@
PatchSetUtil psUtil,
Provider<PatchScriptBuilder> builderFactory,
PatchListCache patchListCache,
- CommentsUtil commentsUtil,
ChangeEditUtil editReader,
- Provider<CurrentUser> userProvider,
PermissionBackend permissionBackend,
ProjectCache projectCache,
@Assisted ChangeNotes notes,
@@ -122,9 +111,7 @@
this.builderFactory = builderFactory;
this.patchListCache = patchListCache;
this.notes = notes;
- this.commentsUtil = commentsUtil;
this.editReader = editReader;
- this.userProvider = userProvider;
this.permissionBackend = permissionBackend;
this.projectCache = projectCache;
@@ -143,9 +130,7 @@
PatchSetUtil psUtil,
Provider<PatchScriptBuilder> builderFactory,
PatchListCache patchListCache,
- CommentsUtil commentsUtil,
ChangeEditUtil editReader,
- Provider<CurrentUser> userProvider,
PermissionBackend permissionBackend,
ProjectCache projectCache,
@Assisted ChangeNotes notes,
@@ -158,9 +143,7 @@
this.builderFactory = builderFactory;
this.patchListCache = patchListCache;
this.notes = notes;
- this.commentsUtil = commentsUtil;
this.editReader = editReader;
- this.userProvider = userProvider;
this.permissionBackend = permissionBackend;
this.projectCache = projectCache;
@@ -174,10 +157,6 @@
checkArgument(parentNum >= 0, "parentNum must be >= 0");
}
- public void setLoadComments(boolean load) {
- loadComments = load;
- }
-
@Override
public PatchScript call()
throws LargeObjectException, AuthException, InvalidChangeOperationException, IOException,
@@ -203,7 +182,6 @@
ObjectId aId = getAId().orElse(null);
ObjectId bId = getBId().orElse(null);
- boolean changeEdit = false;
if (bId == null) {
// Change edit: create synthetic PatchSet corresponding to the edit.
Optional<ChangeEdit> edit = editReader.byChange(notes);
@@ -211,16 +189,13 @@
throw new NoSuchChangeException(notes.getChangeId());
}
bId = edit.get().getEditCommit();
- changeEdit = true;
}
final PatchList list = listFor(keyFor(aId, bId, diffPrefs.ignoreWhitespace));
final PatchScriptBuilder b = newBuilder();
final PatchListEntry content = list.get(fileName);
- Optional<CommentDetail> comments = loadComments(content, changeEdit);
-
- return b.toPatchScript(git, list, content, comments.orElse(null));
+ return b.toPatchScript(git, list, content);
} catch (PatchListNotAvailableException e) {
throw new NoSuchChangeException(changeId, e);
} catch (IOException e) {
@@ -238,14 +213,6 @@
}
}
- private Optional<CommentDetail> loadComments(PatchListEntry content, boolean changeEdit) {
- if (!loadComments) {
- return Optional.empty();
- }
- return new CommentsLoader(psa, psb, userProvider, notes, commentsUtil)
- .load(changeEdit, content.getChangeType(), content.getOldName(), content.getNewName());
- }
-
private Optional<ObjectId> getAId() {
if (psa == null) {
return Optional.empty();
@@ -300,99 +267,6 @@
}
}
- private static class CommentsLoader {
- private final PatchSet.Id psa;
- private final PatchSet.Id psb;
- private final Provider<CurrentUser> userProvider;
- private final ChangeNotes notes;
- private final CommentsUtil commentsUtil;
- private CommentDetail comments;
-
- CommentsLoader(
- PatchSet.Id psa,
- PatchSet.Id psb,
- Provider<CurrentUser> userProvider,
- ChangeNotes notes,
- CommentsUtil commentsUtil) {
- this.psa = psa;
- this.psb = psb;
- this.userProvider = userProvider;
- this.notes = notes;
- this.commentsUtil = commentsUtil;
- }
-
- private Optional<CommentDetail> load(
- boolean changeEdit, ChangeType changeType, String oldName, String newName) {
- // TODO: Implement this method with CommentDetailBuilder (this class doesn't exists yet).
- // This is a legacy code which create final object and populate it and then returns it.
- if (changeEdit) {
- return Optional.empty();
- }
-
- comments = new CommentDetail(psa, psb);
- switch (changeType) {
- case ADDED:
- case MODIFIED:
- loadPublished(newName);
- break;
-
- case DELETED:
- loadPublished(newName);
- break;
-
- case COPIED:
- case RENAMED:
- if (psa != null) {
- loadPublished(oldName);
- }
- loadPublished(newName);
- break;
-
- case REWRITE:
- break;
- }
-
- CurrentUser user = userProvider.get();
- if (user.isIdentifiedUser()) {
- Account.Id me = user.getAccountId();
- switch (changeType) {
- case ADDED:
- case MODIFIED:
- loadDrafts(me, newName);
- break;
-
- case DELETED:
- loadDrafts(me, newName);
- break;
-
- case COPIED:
- case RENAMED:
- if (psa != null) {
- loadDrafts(me, oldName);
- }
- loadDrafts(me, newName);
- break;
-
- case REWRITE:
- break;
- }
- }
- return Optional.of(comments);
- }
-
- private void loadPublished(String file) {
- for (HumanComment c : commentsUtil.publishedByChangeFile(notes, file)) {
- comments.include(notes.getChangeId(), c);
- }
- }
-
- private void loadDrafts(Account.Id me, String file) {
- for (HumanComment c : commentsUtil.draftByChangeFileAuthor(notes, file, me)) {
- comments.include(notes.getChangeId(), c);
- }
- }
- }
-
private static class IntraLineDiffCalculator
implements PatchScriptBuilder.IntraLineDiffCalculator {
diff --git a/java/com/google/gerrit/server/patch/Text.java b/java/com/google/gerrit/server/patch/Text.java
index cc0a5e4..0f69965 100644
--- a/java/com/google/gerrit/server/patch/Text.java
+++ b/java/com/google/gerrit/server/patch/Text.java
@@ -18,21 +18,16 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.git.ObjectIds;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.IllegalCharsetNameException;
import java.nio.charset.UnsupportedCharsetException;
-import java.text.SimpleDateFormat;
import org.eclipse.jgit.diff.RawText;
import org.eclipse.jgit.errors.LargeObjectException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.storage.pack.PackConfig;
import org.eclipse.jgit.util.RawParseUtils;
import org.mozilla.universalchardet.UniversalDetector;
@@ -46,101 +41,14 @@
public static final Text EMPTY = new Text(NO_BYTES);
public static Text forCommit(ObjectReader reader, AnyObjectId commitId) throws IOException {
- try (RevWalk rw = new RevWalk(reader)) {
- RevCommit c;
- if (commitId instanceof RevCommit) {
- c = (RevCommit) commitId;
- } else {
- c = rw.parseCommit(commitId);
- }
-
- StringBuilder b = new StringBuilder();
- switch (c.getParentCount()) {
- case 0:
- break;
- case 1:
- {
- RevCommit p = c.getParent(0);
- rw.parseBody(p);
- b.append("Parent: ");
- b.append(abbreviateName(p, reader));
- b.append(" (");
- b.append(p.getShortMessage());
- b.append(")\n");
- break;
- }
- default:
- for (int i = 0; i < c.getParentCount(); i++) {
- RevCommit p = c.getParent(i);
- rw.parseBody(p);
- b.append(i == 0 ? "Merge Of: " : " ");
- b.append(abbreviateName(p, reader));
- b.append(" (");
- b.append(p.getShortMessage());
- b.append(")\n");
- }
- }
- appendPersonIdent(b, "Author", c.getAuthorIdent());
- appendPersonIdent(b, "Commit", c.getCommitterIdent());
- b.append("\n");
- b.append(c.getFullMessage());
- return new Text(b.toString().getBytes(UTF_8));
- }
+ MagicFile commitMessageFile = MagicFile.forCommitMessage(reader, commitId);
+ return new Text(commitMessageFile.getFileContent().getBytes(UTF_8));
}
public static Text forMergeList(
ComparisonType comparisonType, ObjectReader reader, AnyObjectId commitId) throws IOException {
- try (RevWalk rw = new RevWalk(reader)) {
- RevCommit c = rw.parseCommit(commitId);
- StringBuilder b = new StringBuilder();
- switch (c.getParentCount()) {
- case 0:
- break;
- case 1:
- {
- break;
- }
- default:
- int uniterestingParent =
- comparisonType.isAgainstParent() ? comparisonType.getParentNum() : 1;
-
- b.append("Merge List:\n\n");
- for (RevCommit commit : MergeListBuilder.build(rw, c, uniterestingParent)) {
- b.append("* ");
- b.append(abbreviateName(commit, reader));
- b.append(" ");
- b.append(commit.getShortMessage());
- b.append("\n");
- }
- }
- return new Text(b.toString().getBytes(UTF_8));
- }
- }
-
- private static String abbreviateName(RevCommit p, ObjectReader reader) throws IOException {
- return ObjectIds.abbreviateName(p, 8, reader);
- }
-
- private static void appendPersonIdent(StringBuilder b, String field, PersonIdent person) {
- if (person != null) {
- b.append(field).append(": ");
- if (person.getName() != null) {
- b.append(" ");
- b.append(person.getName());
- }
- if (person.getEmailAddress() != null) {
- b.append(" <");
- b.append(person.getEmailAddress());
- b.append(">");
- }
- b.append("\n");
-
- SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss ZZZ");
- sdf.setTimeZone(person.getTimeZone());
- b.append(field).append("Date: ");
- b.append(sdf.format(person.getWhen()));
- b.append("\n");
- }
+ MagicFile mergeListFile = MagicFile.forMergeList(comparisonType, reader, commitId);
+ return new Text(mergeListFile.getFileContent().getBytes(UTF_8));
}
public static byte[] asByteArray(ObjectLoader ldr)
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
new file mode 100644
index 0000000..bcae238
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -0,0 +1,45 @@
+// 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.patch.diff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader uses the underlying {@link GitModifiedFilesCacheImpl} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ */
+public interface ModifiedFilesCache {
+
+ /**
+ * @param key used to identify two git commits and contains other attributes to control the diff
+ * calculation.
+ * @return the list of {@link ModifiedFile}s between the 2 git commits identified by the key.
+ * @throws DiffNotAvailableException the supplied commits IDs of the key do no exist, are not IDs
+ * of a commit, or an exception occurred while reading a pack file.
+ */
+ ImmutableList<ModifiedFile> get(ModifiedFilesCacheKey key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..6023c0e
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -0,0 +1,206 @@
+// 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.patch.diff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static org.eclipse.jgit.lib.Constants.EMPTY_TREE_ID;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffUtil;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCache;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * A cache for the list of Git modified files between 2 commits (patchsets) with extra Gerrit logic.
+ *
+ * <p>The loader of this cache wraps a {@link GitModifiedFilesCache} to retrieve the git modified
+ * files.
+ *
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link
+ * org.eclipse.jgit.lib.Constants#EMPTY_TREE_ID}, the diff will be evaluated against the empty tree,
+ * and the result will be exactly the same as the caller can get from {@link
+ * GitModifiedFilesCache#get(GitModifiedFilesCacheKey)}
+ */
+public class ModifiedFilesCacheImpl implements ModifiedFilesCache {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private static final String MODIFIED_FILES = "modified_files";
+
+ private final LoadingCache<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache;
+
+ public static Module module() {
+ return new CacheModule() {
+ @Override
+ protected void configure() {
+ bind(ModifiedFilesCache.class).to(ModifiedFilesCacheImpl.class);
+
+ // The documentation has some defaults and recommendations for setting the cache
+ // attributes:
+ // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+ // The cache is using the default disk limit as per section cache.<name>.diskLimit
+ // in the cache documentation link.
+ persist(
+ ModifiedFilesCacheImpl.MODIFIED_FILES,
+ ModifiedFilesCacheKey.class,
+ new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+ .keySerializer(ModifiedFilesCacheKey.Serializer.INSTANCE)
+ .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
+ .maximumWeight(10 << 20)
+ .weigher(ModifiedFilesWeigher.class)
+ .version(1)
+ .loader(ModifiedFilesLoader.class);
+ }
+ };
+ }
+
+ @Inject
+ public ModifiedFilesCacheImpl(
+ @Named(ModifiedFilesCacheImpl.MODIFIED_FILES)
+ LoadingCache<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache) {
+ this.cache = cache;
+ }
+
+ @Override
+ public ImmutableList<ModifiedFile> get(ModifiedFilesCacheKey key)
+ throws DiffNotAvailableException {
+ try {
+ return cache.get(key);
+ } catch (Exception e) {
+ throw new DiffNotAvailableException(e);
+ }
+ }
+
+ static class ModifiedFilesLoader
+ extends CacheLoader<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+ private final GitModifiedFilesCache gitCache;
+ private final GitRepositoryManager repoManager;
+
+ @Inject
+ ModifiedFilesLoader(GitModifiedFilesCache gitCache, GitRepositoryManager repoManager) {
+ this.gitCache = gitCache;
+ this.repoManager = repoManager;
+ }
+
+ @Override
+ public ImmutableList<ModifiedFile> load(ModifiedFilesCacheKey key)
+ throws IOException, DiffNotAvailableException {
+ try (Repository repo = repoManager.openRepository(key.project());
+ RevWalk rw = new RevWalk(repo.newObjectReader())) {
+ return loadModifiedFiles(key, rw);
+ }
+ }
+
+ private ImmutableList<ModifiedFile> loadModifiedFiles(ModifiedFilesCacheKey key, RevWalk rw)
+ throws IOException, DiffNotAvailableException {
+ ObjectId aTree =
+ key.aCommit().equals(EMPTY_TREE_ID)
+ ? key.aCommit()
+ : DiffUtil.getTreeId(rw, key.aCommit());
+ ObjectId bTree = DiffUtil.getTreeId(rw, key.bCommit());
+ GitModifiedFilesCacheKey gitKey =
+ GitModifiedFilesCacheKey.builder()
+ .project(key.project())
+ .aTree(aTree)
+ .bTree(bTree)
+ .renameScore(key.renameScore())
+ .build();
+ List<ModifiedFile> modifiedFiles = gitCache.get(gitKey);
+ if (key.aCommit().equals(EMPTY_TREE_ID)) {
+ return ImmutableList.copyOf(modifiedFiles);
+ }
+ RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
+ RevCommit revCommitB = DiffUtil.getRevCommit(rw, key.bCommit());
+ if (DiffUtil.areRelated(revCommitA, revCommitB)) {
+ return ImmutableList.copyOf(modifiedFiles);
+ }
+ Set<String> touchedFiles =
+ getTouchedFilesWithParents(
+ key, revCommitA.getParent(0).getId(), revCommitB.getParent(0).getId(), rw);
+ return modifiedFiles.stream()
+ .filter(f -> isTouched(touchedFiles, f))
+ .collect(toImmutableList());
+ }
+
+ /**
+ * Returns the paths of files that were modified between the old and new commits versus their
+ * parents (i.e. old commit vs. its parent, and new commit vs. its parent).
+ *
+ * @param key the {@link ModifiedFilesCacheKey} representing the commits we are diffing
+ * @param rw a {@link RevWalk} for the repository
+ * @return The list of modified files between the old/new commits and their parents
+ */
+ private Set<String> getTouchedFilesWithParents(
+ ModifiedFilesCacheKey key, ObjectId parentOfA, ObjectId parentOfB, RevWalk rw)
+ throws IOException {
+ try {
+ // TODO(ghareeb): as an enhancement: the 3 calls of the underlying git cache can be combined
+ GitModifiedFilesCacheKey oldVsBaseKey =
+ GitModifiedFilesCacheKey.create(
+ key.project(), parentOfA, key.aCommit(), key.renameScore(), rw);
+ List<ModifiedFile> oldVsBase = gitCache.get(oldVsBaseKey);
+
+ GitModifiedFilesCacheKey newVsBaseKey =
+ GitModifiedFilesCacheKey.create(
+ key.project(), parentOfB, key.bCommit(), key.renameScore(), rw);
+ List<ModifiedFile> newVsBase = gitCache.get(newVsBaseKey);
+
+ return Sets.union(getOldAndNewPaths(oldVsBase), getOldAndNewPaths(newVsBase));
+ } catch (DiffNotAvailableException e) {
+ logger.atWarning().log(
+ "Failed to retrieve the touched files' commits (%s, %s) and parents (%s, %s): %s",
+ key.aCommit(), key.bCommit(), parentOfA, parentOfB, e.getMessage());
+ return ImmutableSet.of();
+ }
+ }
+
+ private ImmutableSet<String> getOldAndNewPaths(List<ModifiedFile> files) {
+ return files.stream()
+ .flatMap(
+ file -> Stream.concat(Streams.stream(file.oldPath()), Streams.stream(file.newPath())))
+ .collect(ImmutableSet.toImmutableSet());
+ }
+
+ private static boolean isTouched(Set<String> touchedFilePaths, ModifiedFile modifiedFile) {
+ String oldFilePath = modifiedFile.oldPath().orElse(null);
+ String newFilePath = modifiedFile.newPath().orElse(null);
+ // One of the above file paths could be /dev/null but we need not explicitly check for this
+ // value as the set of file paths shouldn't contain it.
+ return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
new file mode 100644
index 0000000..5aa31ec
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
@@ -0,0 +1,116 @@
+// 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.patch.diff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFilesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Cache key for the {@link com.google.gerrit.server.patch.diff.ModifiedFilesCache} */
+@AutoValue
+public abstract class ModifiedFilesCacheKey {
+
+ /** A specific git project / repository. */
+ public abstract Project.NameKey project();
+
+ /** @return the old commit ID used in the git tree diff */
+ public abstract ObjectId aCommit();
+
+ /** @return the new commit ID used in the git tree diff */
+ public abstract ObjectId bCommit();
+
+ /**
+ * Percentage score used to identify a file as a "rename". A special value of -1 means that the
+ * computation will ignore renames and rename detection will be disabled.
+ */
+ public abstract int renameScore();
+
+ public boolean renameDetectionEnabled() {
+ return renameScore() != -1;
+ }
+
+ /** Returns the size of the object in bytes */
+ public int weight() {
+ return stringSize(project().get()) // project
+ + 20 * 2 // aCommit and bCommit
+ + 4; // renameScore
+ }
+
+ public static ModifiedFilesCacheKey.Builder builder() {
+ return new AutoValue_ModifiedFilesCacheKey.Builder();
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public abstract ModifiedFilesCacheKey.Builder project(NameKey value);
+
+ public abstract ModifiedFilesCacheKey.Builder aCommit(ObjectId value);
+
+ public abstract ModifiedFilesCacheKey.Builder bCommit(ObjectId value);
+
+ public ModifiedFilesCacheKey.Builder disableRenameDetection() {
+ renameScore(-1);
+ return this;
+ }
+
+ public abstract ModifiedFilesCacheKey.Builder renameScore(int value);
+
+ public abstract ModifiedFilesCacheKey build();
+ }
+
+ public enum Serializer implements CacheSerializer<ModifiedFilesCacheKey> {
+ INSTANCE;
+
+ @Override
+ public byte[] serialize(ModifiedFilesCacheKey key) {
+ ObjectIdConverter idConverter = ObjectIdConverter.create();
+ return Protos.toByteArray(
+ ModifiedFilesKeyProto.newBuilder()
+ .setProject(key.project().get())
+ .setACommit(idConverter.toByteString(key.aCommit()))
+ .setBCommit(idConverter.toByteString(key.bCommit()))
+ .setRenameScore(key.renameScore())
+ .build());
+ }
+
+ @Override
+ public ModifiedFilesCacheKey deserialize(byte[] in) {
+ ModifiedFilesKeyProto proto = Protos.parseUnchecked(ModifiedFilesKeyProto.parser(), in);
+ ObjectIdConverter idConverter = ObjectIdConverter.create();
+ return ModifiedFilesCacheKey.builder()
+ .project(NameKey.parse(proto.getProject()))
+ .aCommit(idConverter.fromByteString(proto.getACommit()))
+ .bCommit(idConverter.fromByteString(proto.getBCommit()))
+ .renameScore(proto.getRenameScore())
+ .build();
+ }
+ }
+
+ private static int stringSize(String str) {
+ if (str != null) {
+ // each character in the string occupies 2 bytes. Ignoring the fixed overhead for the string
+ // (length, offset and hash code) since they are negligible and do not
+ // affect the comparison of 2 strings
+ return str.length() * 2;
+ }
+ return 0;
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
new file mode 100644
index 0000000..512da6f
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesWeigher.java
@@ -0,0 +1,31 @@
+// 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.patch.diff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+
+public class ModifiedFilesWeigher
+ implements Weigher<ModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+ @Override
+ public int weigh(ModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+ int weight = key.weight();
+ for (ModifiedFile modifiedFile : modifiedFiles) {
+ weight += modifiedFile.weight();
+ }
+ return weight;
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
new file mode 100644
index 0000000..d178f22
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCache.java
@@ -0,0 +1,40 @@
+// 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.patch.gitdiff;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
+
+/**
+ * A cache interface for identifying the list of Git modified files between 2 different git trees.
+ * This cache does not read the actual file contents, nor does it include the edits (modified
+ * regions) of the file.
+ *
+ * <p>The other {@link ModifiedFilesCache} is similar to this cache, and includes other extra Gerrit
+ * logic that we need to add with the list of modified files.
+ */
+public interface GitModifiedFilesCache {
+
+ /**
+ * Computes the list of of {@link ModifiedFile}s between the 2 git trees.
+ *
+ * @param key used to identify two git trees and contains other attributes to control the diff
+ * calculation.
+ * @return the list of {@link ModifiedFile}s between the 2 git trees identified by the key.
+ * @throws DiffNotAvailableException trees cannot be read or file contents cannot be read.
+ */
+ ImmutableList<ModifiedFile> get(GitModifiedFilesCacheKey key) throws DiffNotAvailableException;
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
new file mode 100644
index 0000000..b3b82bb
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -0,0 +1,177 @@
+// 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.patch.gitdiff;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFilesProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.io.DisabledOutputStream;
+
+/** Implementation of the {@link GitModifiedFilesCache} */
+public class GitModifiedFilesCacheImpl implements GitModifiedFilesCache {
+ private static final String GIT_MODIFIED_FILES = "git_modified_files";
+ private static final ImmutableMap<ChangeType, Patch.ChangeType> changeTypeMap =
+ ImmutableMap.of(
+ DiffEntry.ChangeType.ADD,
+ Patch.ChangeType.ADDED,
+ DiffEntry.ChangeType.MODIFY,
+ Patch.ChangeType.MODIFIED,
+ DiffEntry.ChangeType.DELETE,
+ Patch.ChangeType.DELETED,
+ DiffEntry.ChangeType.RENAME,
+ Patch.ChangeType.RENAMED,
+ DiffEntry.ChangeType.COPY,
+ Patch.ChangeType.COPIED);
+
+ private LoadingCache<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache;
+
+ public static Module module() {
+ return new CacheModule() {
+ @Override
+ protected void configure() {
+ bind(GitModifiedFilesCache.class).to(GitModifiedFilesCacheImpl.class);
+
+ persist(
+ GIT_MODIFIED_FILES,
+ GitModifiedFilesCacheKey.class,
+ new TypeLiteral<ImmutableList<ModifiedFile>>() {})
+ .keySerializer(GitModifiedFilesCacheKey.Serializer.INSTANCE)
+ .valueSerializer(ValueSerializer.INSTANCE)
+ // The documentation has some defaults and recommendations for setting the cache
+ // attributes:
+ // https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.
+ .maximumWeight(10 << 20)
+ .weigher(GitModifiedFilesWeigher.class)
+ // The cache is using the default disk limit as per section cache.<name>.diskLimit
+ // in the cache documentation link.
+ .version(1)
+ .loader(GitModifiedFilesCacheImpl.Loader.class);
+ }
+ };
+ }
+
+ @Inject
+ public GitModifiedFilesCacheImpl(
+ @Named(GIT_MODIFIED_FILES)
+ LoadingCache<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> cache) {
+ this.cache = cache;
+ }
+
+ @Override
+ public ImmutableList<ModifiedFile> get(GitModifiedFilesCacheKey key)
+ throws DiffNotAvailableException {
+ try {
+ return cache.get(key);
+ } catch (ExecutionException e) {
+ throw new DiffNotAvailableException(e);
+ }
+ }
+
+ static class Loader extends CacheLoader<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+ private final GitRepositoryManager repoManager;
+
+ @Inject
+ Loader(GitRepositoryManager repoManager) {
+ this.repoManager = repoManager;
+ }
+
+ @Override
+ public ImmutableList<ModifiedFile> load(GitModifiedFilesCacheKey key) throws IOException {
+ try (Repository repo = repoManager.openRepository(key.project());
+ ObjectReader reader = repo.newObjectReader()) {
+ List<DiffEntry> entries = getGitTreeDiff(repo, reader, key);
+
+ return entries.stream().map(Loader::toModifiedFile).collect(toImmutableList());
+ }
+ }
+
+ private List<DiffEntry> getGitTreeDiff(
+ Repository repo, ObjectReader reader, GitModifiedFilesCacheKey key) throws IOException {
+ try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
+ df.setReader(reader, repo.getConfig());
+ if (key.renameDetection()) {
+ df.setDetectRenames(true);
+ df.getRenameDetector().setRenameScore(key.renameScore());
+ }
+ // The scan method only returns the file paths that are different. Callers may choose to
+ // format these paths themselves.
+ return df.scan(key.aTree(), key.bTree());
+ }
+ }
+
+ private static ModifiedFile toModifiedFile(DiffEntry entry) {
+ String oldPath = entry.getOldPath();
+ String newPath = entry.getNewPath();
+ return ModifiedFile.builder()
+ .changeType(toChangeType(entry.getChangeType()))
+ .oldPath(oldPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(oldPath))
+ .newPath(newPath.equals(DiffEntry.DEV_NULL) ? Optional.empty() : Optional.of(newPath))
+ .build();
+ }
+
+ private static Patch.ChangeType toChangeType(DiffEntry.ChangeType changeType) {
+ if (!changeTypeMap.containsKey(changeType)) {
+ throw new IllegalArgumentException("Unsupported type " + changeType);
+ }
+ return changeTypeMap.get(changeType);
+ }
+ }
+
+ public enum ValueSerializer implements CacheSerializer<ImmutableList<ModifiedFile>> {
+ INSTANCE;
+
+ @Override
+ public byte[] serialize(ImmutableList<ModifiedFile> modifiedFiles) {
+ ModifiedFilesProto.Builder builder = ModifiedFilesProto.newBuilder();
+ modifiedFiles.forEach(
+ f -> builder.addModifiedFile(ModifiedFile.Serializer.INSTANCE.toProto(f)));
+ return Protos.toByteArray(builder.build());
+ }
+
+ @Override
+ public ImmutableList<ModifiedFile> deserialize(byte[] in) {
+ ImmutableList.Builder<ModifiedFile> modifiedFiles = ImmutableList.builder();
+ ModifiedFilesProto modifiedFilesProto =
+ Protos.parseUnchecked(ModifiedFilesProto.parser(), in);
+ modifiedFilesProto
+ .getModifiedFileList()
+ .forEach(f -> modifiedFiles.add(ModifiedFile.Serializer.INSTANCE.fromProto(f)));
+ return modifiedFiles.build();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
new file mode 100644
index 0000000..f94f2c9
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.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.patch.gitdiff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.GitModifiedFilesKeyProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.patch.DiffUtil;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Cache key for the {@link GitModifiedFilesCache}. */
+@AutoValue
+public abstract class GitModifiedFilesCacheKey {
+
+ /** A specific git project / repository. */
+ public abstract Project.NameKey project();
+
+ /**
+ * The git SHA-1 {@link ObjectId} of the first git tree object for which the diff should be
+ * computed.
+ */
+ public abstract ObjectId aTree();
+
+ /**
+ * The git SHA-1 {@link ObjectId} of the second git tree object for which the diff should be
+ * computed.
+ */
+ public abstract ObjectId bTree();
+
+ /**
+ * Percentage score used to identify a file as a rename. This value is only available if {@link
+ * #renameDetection()} is true. Otherwise, this method will return -1.
+ *
+ * <p>This value will be used to set the rename score of {@link
+ * org.eclipse.jgit.diff.DiffFormatter#getRenameDetector()}.
+ */
+ public abstract int renameScore();
+
+ /** Returns true if rename detection was set for this key. */
+ public boolean renameDetection() {
+ return renameScore() != -1;
+ }
+
+ public static GitModifiedFilesCacheKey create(
+ Project.NameKey project, ObjectId aCommit, ObjectId bCommit, int renameScore, RevWalk rw)
+ throws IOException {
+ ObjectId aTree = DiffUtil.getTreeId(rw, aCommit);
+ ObjectId bTree = DiffUtil.getTreeId(rw, bCommit);
+ return builder().project(project).aTree(aTree).bTree(bTree).renameScore(renameScore).build();
+ }
+
+ public static Builder builder() {
+ return new AutoValue_GitModifiedFilesCacheKey.Builder();
+ }
+
+ /** Returns the size of the object in bytes */
+ public int weight() {
+ return stringSize(project().get())
+ + 20 * 2 // old and new tree IDs
+ + 4; // rename score
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public abstract Builder project(NameKey value);
+
+ public abstract Builder aTree(ObjectId value);
+
+ public abstract Builder bTree(ObjectId value);
+
+ public abstract Builder renameScore(int value);
+
+ public Builder disableRenameDetection() {
+ renameScore(-1);
+ return this;
+ }
+
+ public abstract GitModifiedFilesCacheKey build();
+ }
+
+ public enum Serializer implements CacheSerializer<GitModifiedFilesCacheKey> {
+ INSTANCE;
+
+ @Override
+ public byte[] serialize(GitModifiedFilesCacheKey key) {
+ ObjectIdConverter idConverter = ObjectIdConverter.create();
+ return Protos.toByteArray(
+ GitModifiedFilesKeyProto.newBuilder()
+ .setProject(key.project().get())
+ .setATree(idConverter.toByteString(key.aTree()))
+ .setBTree(idConverter.toByteString(key.bTree()))
+ .setRenameScore(key.renameScore())
+ .build());
+ }
+
+ @Override
+ public GitModifiedFilesCacheKey deserialize(byte[] in) {
+ GitModifiedFilesKeyProto proto = Protos.parseUnchecked(GitModifiedFilesKeyProto.parser(), in);
+ ObjectIdConverter idConverter = ObjectIdConverter.create();
+ return GitModifiedFilesCacheKey.builder()
+ .project(NameKey.parse(proto.getProject()))
+ .aTree(idConverter.fromByteString(proto.getATree()))
+ .bTree(idConverter.fromByteString(proto.getBTree()))
+ .renameScore(proto.getRenameScore())
+ .build();
+ }
+ }
+
+ private static int stringSize(String str) {
+ if (str != null) {
+ // each character in the string occupies 2 bytes. Ignoring the fixed overhead for the string
+ // (length, offset and hash code) since they are negligible and do not
+ // affect the comparison of 2 strings
+ return str.length() * 2;
+ }
+ return 0;
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
new file mode 100644
index 0000000..a678379
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesWeigher.java
@@ -0,0 +1,26 @@
+// 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.patch.gitdiff;
+
+import com.google.common.cache.Weigher;
+import com.google.common.collect.ImmutableList;
+
+public class GitModifiedFilesWeigher
+ implements Weigher<GitModifiedFilesCacheKey, ImmutableList<ModifiedFile>> {
+ @Override
+ public int weigh(GitModifiedFilesCacheKey key, ImmutableList<ModifiedFile> modifiedFiles) {
+ return key.weight() + modifiedFiles.stream().mapToInt(ModifiedFile::weight).sum();
+ }
+}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
new file mode 100644
index 0000000..800bd41
--- /dev/null
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -0,0 +1,123 @@
+// 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.patch.gitdiff;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.proto.Cache.ModifiedFileProto;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import java.util.Optional;
+
+/**
+ * An entity representing a Modified file due to a diff between 2 git trees. This entity contains
+ * the change type and the old & new paths, but does not include any actual content diff of the
+ * file.
+ */
+@AutoValue
+public abstract class ModifiedFile {
+ /**
+ * Returns the change type (i.e. add, delete, modify, rename, etc...) associated with this
+ * modified file.
+ */
+ public abstract ChangeType changeType();
+
+ /**
+ * Returns the old name associated with this file. An empty optional is returned if {@link
+ * #changeType()} is equal to {@link ChangeType#ADDED}.
+ */
+ public abstract Optional<String> oldPath();
+
+ /**
+ * Returns the new name associated with this file. An empty optional is returned if {@link
+ * #changeType()} is equal to {@link ChangeType#DELETED}
+ */
+ public abstract Optional<String> newPath();
+
+ public static Builder builder() {
+ return new AutoValue_ModifiedFile.Builder();
+ }
+
+ /** Computes this object's weight, which is its size in bytes. */
+ public int weight() {
+ int weight = 1; // the changeType field
+ if (oldPath().isPresent()) {
+ weight += oldPath().get().length();
+ }
+ if (newPath().isPresent()) {
+ weight += newPath().get().length();
+ }
+ return weight;
+ }
+
+ @AutoValue.Builder
+ public abstract static class Builder {
+
+ public abstract Builder changeType(ChangeType value);
+
+ public abstract Builder oldPath(Optional<String> value);
+
+ public abstract Builder newPath(Optional<String> value);
+
+ public abstract ModifiedFile build();
+ }
+
+ enum Serializer implements CacheSerializer<ModifiedFile> {
+ INSTANCE;
+
+ private static final FieldDescriptor oldPathDescriptor =
+ ModifiedFileProto.getDescriptor().findFieldByName("old_path");
+
+ private static final FieldDescriptor newPathDescriptor =
+ ModifiedFileProto.getDescriptor().findFieldByName("new_path");
+
+ @Override
+ public byte[] serialize(ModifiedFile modifiedFile) {
+ return Protos.toByteArray(toProto(modifiedFile));
+ }
+
+ public ModifiedFileProto toProto(ModifiedFile modifiedFile) {
+ ModifiedFileProto.Builder builder = ModifiedFileProto.newBuilder();
+ builder.setChangeType(modifiedFile.changeType().toString());
+ if (modifiedFile.oldPath().isPresent()) {
+ builder.setOldPath(modifiedFile.oldPath().get());
+ }
+ if (modifiedFile.newPath().isPresent()) {
+ builder.setNewPath(modifiedFile.newPath().get());
+ }
+ return builder.build();
+ }
+
+ @Override
+ public ModifiedFile deserialize(byte[] in) {
+ ModifiedFileProto modifiedFileProto = Protos.parseUnchecked(ModifiedFileProto.parser(), in);
+ return fromProto(modifiedFileProto);
+ }
+
+ public ModifiedFile fromProto(ModifiedFileProto modifiedFileProto) {
+ ModifiedFile.Builder builder = ModifiedFile.builder();
+ builder.changeType(ChangeType.valueOf(modifiedFileProto.getChangeType()));
+
+ if (modifiedFileProto.hasField(oldPathDescriptor)) {
+ builder.oldPath(Optional.of(modifiedFileProto.getOldPath()));
+ }
+ if (modifiedFileProto.hasField(newPathDescriptor)) {
+ builder.newPath(Optional.of(modifiedFileProto.getNewPath()));
+ }
+ return builder.build();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/permissions/ChangeControl.java b/java/com/google/gerrit/server/permissions/ChangeControl.java
index 9f216c0..0b4828b 100644
--- a/java/com/google/gerrit/server/permissions/ChangeControl.java
+++ b/java/com/google/gerrit/server/permissions/ChangeControl.java
@@ -19,10 +19,10 @@
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.conditions.BooleanCondition;
import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index cb0d48a..41db9ee 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -21,10 +21,10 @@
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
import com.google.gerrit.extensions.api.access.PluginPermission;
@@ -49,8 +49,6 @@
public class DefaultPermissionBackend extends PermissionBackend {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
-
private final Provider<CurrentUser> currentUser;
private final ProjectCache projectCache;
private final ProjectControl.Factory projectControlFactory;
@@ -84,8 +82,15 @@
@Override
public WithUser absentUser(Account.Id id) {
- IdentifiedUser identifiedUser = identifiedUserFactory.create(requireNonNull(id, "user"));
- return new WithUserImpl(identifiedUser);
+ requireNonNull(id, "user");
+ Optional<Account.Id> user = getAccountIdOfIdentifiedUser();
+ if (user.isPresent() && id.equals(user.get())) {
+ // What looked liked an absent user is actually the current caller. Use the per-request
+ // singleton IdentifiedUser instead of constructing a new object to leverage caching in member
+ // variables of IdentifiedUser.
+ return new WithUserImpl(currentUser.get().asIdentifiedUser());
+ }
+ return new WithUserImpl(identifiedUserFactory.create(requireNonNull(id, "user")));
}
@Override
@@ -93,6 +98,21 @@
return true;
}
+ /**
+ * Returns the {@link Account.Id} of the current user if a user is signed in. Catches exceptions
+ * so that background jobs don't get impacted.
+ */
+ private Optional<Account.Id> getAccountIdOfIdentifiedUser() {
+ try {
+ return currentUser.get().isIdentifiedUser()
+ ? Optional.of(currentUser.get().getAccountId())
+ : Optional.empty();
+ } catch (Exception e) {
+ logger.atFine().withCause(e).log("Unable to get current user");
+ return Optional.empty();
+ }
+ }
+
class WithUserImpl extends WithUser {
private final CurrentUser user;
private Boolean admin;
@@ -202,21 +222,13 @@
}
private Boolean computeAdmin() {
- Optional<Boolean> r = user.get(IS_ADMIN);
- if (r.isPresent()) {
- return r.get();
- }
-
- boolean isAdmin;
if (user.isImpersonating()) {
- isAdmin = false;
- } else if (user instanceof PeerDaemonUser) {
- isAdmin = true;
- } else {
- isAdmin = allow(capabilities().administrateServer);
+ return false;
}
- user.put(IS_ADMIN, isAdmin);
- return isAdmin;
+ if (user instanceof PeerDaemonUser) {
+ return true;
+ }
+ return allow(capabilities().administrateServer);
}
private boolean canEmailReviewers() {
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index 8479f02..dcaf485 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -20,7 +20,7 @@
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
import com.google.gerrit.extensions.api.access.PluginPermission;
import com.google.gerrit.extensions.api.access.PluginProjectPermission;
diff --git a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
index 37de0d1..defec4b 100644
--- a/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
+++ b/java/com/google/gerrit/server/permissions/DefaultRefFilter.java
@@ -16,6 +16,7 @@
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.flogger.LazyArgs.lazy;
import static com.google.gerrit.entities.RefNames.REFS_CACHE_AUTOMERGE;
import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
import static com.google.gerrit.entities.RefNames.REFS_USERS_SELF;
@@ -134,7 +135,7 @@
"Filter refs for repository %s by visibility (options = %s, refs = %s)",
projectState.getNameKey(), opts, refs);
logger.atFinest().log("Calling user: %s", user.getLoggableName());
- logger.atFinest().log("Groups: %s", user.getEffectiveGroups().getKnownGroups());
+ logger.atFinest().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
logger.atFinest().log(
"auth.skipFullRefEvaluationIfAllRefsAreVisible = %s",
skipFullRefEvaluationIfAllRefsAreVisible);
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index 64eecfe..268570c 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -18,7 +18,7 @@
import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
import static java.util.Objects.requireNonNull;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.server.util.LabelVote;
diff --git a/java/com/google/gerrit/server/permissions/PermissionBackend.java b/java/com/google/gerrit/server/permissions/PermissionBackend.java
index 23145ba..eceb970 100644
--- a/java/com/google/gerrit/server/permissions/PermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -21,9 +21,9 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.access.CoreOrPluginProjectPermission;
diff --git a/java/com/google/gerrit/server/permissions/PermissionCollection.java b/java/com/google/gerrit/server/permissions/PermissionCollection.java
index 1f0370b..ddba52b 100644
--- a/java/com/google/gerrit/server/permissions/PermissionCollection.java
+++ b/java/com/google/gerrit/server/permissions/PermissionCollection.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.permissions;
-import static com.google.gerrit.common.data.PermissionRule.Action.BLOCK;
+import static com.google.gerrit.entities.PermissionRule.Action.BLOCK;
import static com.google.gerrit.server.project.RefPattern.containsParameters;
import static com.google.gerrit.server.project.RefPattern.isRE;
import static java.util.stream.Collectors.mapping;
@@ -23,11 +23,11 @@
import com.google.auto.value.AutoValue;
import com.google.common.collect.Lists;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.entities.Project;
import com.google.gerrit.metrics.Description;
import com.google.gerrit.metrics.Description.Units;
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index e6d66ee..724017db 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -15,18 +15,18 @@
package com.google.gerrit.server.permissions;
import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.common.data.AccessSection.ALL;
-import static com.google.gerrit.common.data.AccessSection.REGEX_PREFIX;
+import static com.google.gerrit.entities.AccessSection.ALL;
+import static com.google.gerrit.entities.AccessSection.REGEX_PREFIX;
import static com.google.gerrit.entities.RefNames.REFS_TAGS;
import static com.google.gerrit.server.util.MagicBranch.NEW_CHANGE;
import com.google.common.collect.Sets;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.StorageException;
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index 5081116..e704a99 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -17,11 +17,11 @@
import static com.google.common.base.Preconditions.checkArgument;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRange;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.StorageException;
@@ -29,6 +29,7 @@
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.logging.CallerFinder;
+import com.google.gerrit.server.logging.LoggingContext;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
@@ -397,40 +398,52 @@
/** True if the user has this permission. */
private boolean canPerform(String permissionName, boolean isChangeOwner, boolean withForce) {
if (isBlocked(permissionName, isChangeOwner, withForce)) {
- logger.atFine().log(
- "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
- + " because this permission is blocked (caller: %s)",
- getUser().getLoggableName(),
- permissionName,
- withForce,
- projectControl.getProject().getName(),
- refName,
- callerFinder.findCallerLazy());
+ if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+ String logMessage =
+ String.format(
+ "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'"
+ + " because this permission is blocked",
+ getUser().getLoggableName(),
+ permissionName,
+ withForce,
+ projectControl.getProject().getName(),
+ refName);
+ LoggingContext.getInstance().addAclLogRecord(logMessage);
+ logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+ }
return false;
}
for (PermissionRule pr : relevant.getAllowRules(permissionName)) {
if (isAllow(pr, withForce) && projectControl.match(pr, isChangeOwner)) {
- logger.atFine().log(
- "'%s' can perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
- getUser().getLoggableName(),
- permissionName,
- withForce,
- projectControl.getProject().getName(),
- refName,
- callerFinder.findCallerLazy());
+ if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+ String logMessage =
+ String.format(
+ "'%s' can perform '%s' with force=%s on project '%s' for ref '%s'",
+ getUser().getLoggableName(),
+ permissionName,
+ withForce,
+ projectControl.getProject().getName(),
+ refName);
+ LoggingContext.getInstance().addAclLogRecord(logMessage);
+ logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+ }
return true;
}
}
- logger.atFine().log(
- "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s' (caller: %s)",
- getUser().getLoggableName(),
- permissionName,
- withForce,
- projectControl.getProject().getName(),
- refName,
- callerFinder.findCallerLazy());
+ if (logger.atFine().isEnabled() || LoggingContext.getInstance().isAclLogging()) {
+ String logMessage =
+ String.format(
+ "'%s' cannot perform '%s' with force=%s on project '%s' for ref '%s'",
+ getUser().getLoggableName(),
+ permissionName,
+ withForce,
+ projectControl.getProject().getName(),
+ refName);
+ LoggingContext.getInstance().addAclLogRecord(logMessage);
+ logger.atFine().log("%s (caller: %s)", logMessage, callerFinder.findCallerLazy());
+ }
return false;
}
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index 814a8d2..6081e9a 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -18,7 +18,7 @@
import com.google.common.cache.Cache;
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.server.cache.CacheModule;
import com.google.gerrit.server.util.MostSpecificComparator;
import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/project/AccountsSection.java b/java/com/google/gerrit/server/project/AccountsSection.java
deleted file mode 100644
index 04f63f7..0000000
--- a/java/com/google/gerrit/server/project/AccountsSection.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) 2010 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.project;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.PermissionRule;
-import java.util.List;
-
-@AutoValue
-public abstract class AccountsSection {
- public abstract ImmutableList<PermissionRule> getSameGroupVisibility();
-
- public static AccountsSection create(List<PermissionRule> sameGroupVisibility) {
- return new AutoValue_AccountsSection(ImmutableList.copyOf(sameGroupVisibility));
- }
-}
diff --git a/java/com/google/gerrit/server/project/CachedProjectConfig.java b/java/com/google/gerrit/server/project/CachedProjectConfig.java
deleted file mode 100644
index 8af2f80..0000000
--- a/java/com/google/gerrit/server/project/CachedProjectConfig.java
+++ /dev/null
@@ -1,186 +0,0 @@
-// 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.project;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubscribeSection;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.BranchOrderSection;
-import com.google.gerrit.entities.ConfiguredMimeTypes;
-import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.entities.NotifyConfig;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.StoredCommentLinkInfo;
-import java.util.Collection;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import org.eclipse.jgit.lib.ObjectId;
-
-/**
- * Cached representation of values parsed from {@link ProjectConfig}.
- *
- * <p>This class is immutable and thread-safe.
- */
-@AutoValue
-public abstract class CachedProjectConfig {
- public abstract Project getProject();
-
- public abstract ImmutableMap<AccountGroup.UUID, GroupReference> getGroups();
-
- /** Returns a set of all groups used by this configuration. */
- public ImmutableSet<AccountGroup.UUID> getAllGroupUUIDs() {
- return getGroups().keySet();
- }
-
- /**
- * Returns the group reference for a {@link com.google.gerrit.entities.AccountGroup.UUID}, if the
- * group is used by at least one rule.
- */
- public Optional<GroupReference> getGroup(AccountGroup.UUID uuid) {
- return Optional.ofNullable(getGroups().get(uuid));
- }
-
- /** Returns the account section containing visibility information about accounts. */
- public abstract AccountsSection getAccountsSection();
-
- /** Returns a map of {@link AccessSection}s keyed by their name. */
- public abstract ImmutableMap<String, AccessSection> getAccessSections();
-
- /** Returns the {@link AccessSection} with to the given name. */
- public Optional<AccessSection> getAccessSection(String refName) {
- return Optional.ofNullable(getAccessSections().get(refName));
- }
-
- /** Returns all {@link AccessSection} names. */
- public ImmutableSet<String> getAccessSectionNames() {
- return ImmutableSet.copyOf(getAccessSections().keySet());
- }
-
- /**
- * Returns the {@link BranchOrderSection} containing the order in which branches should be shown.
- */
- public abstract Optional<BranchOrderSection> getBranchOrderSection();
-
- /** Returns the {@link ContributorAgreement}s keyed by their name. */
- public abstract ImmutableMap<String, ContributorAgreement> getContributorAgreements();
-
- /** Returns the {@link NotifyConfig}s keyed by their name. */
- public abstract ImmutableMap<String, NotifyConfig> getNotifySections();
-
- /** Returns the {@link LabelType}s keyed by their name. */
- public abstract ImmutableMap<String, LabelType> getLabelSections();
-
- /** Returns configured {@link ConfiguredMimeTypes}s. */
- public abstract ConfiguredMimeTypes getMimeTypes();
-
- /**
- * Returns {@link SubscribeSection} keyed by the {@link
- * com.google.gerrit.entities.Project.NameKey} they reference.
- */
- public abstract ImmutableMap<Project.NameKey, SubscribeSection> getSubscribeSections();
-
- /** Returns {@link StoredCommentLinkInfo} keyed by their name. */
- public abstract ImmutableMap<String, StoredCommentLinkInfo> getCommentLinkSections();
-
- /** Returns the blob ID of the {@code rules.pl} file, if present. */
- public abstract Optional<ObjectId> getRulesId();
-
- // TODO(hiesel): This should not have to be an Optional.
- /** Returns the SHA1 of the {@code refs/meta/config} branch. */
- public abstract Optional<ObjectId> getRevision();
-
- /** Returns the maximum allowed object size. */
- public abstract long getMaxObjectSizeLimit();
-
- /** Returns {@code true} if received objects should be checked for validity. */
- public abstract boolean getCheckReceivedObjects();
-
- /** Returns a list of panel sections keyed by title. */
- public abstract ImmutableMap<String, ImmutableList<String>> getExtensionPanelSections();
-
- public ImmutableList<SubscribeSection> getSubscribeSections(BranchNameKey branch) {
- return filterSubscribeSectionsByBranch(getSubscribeSections().values(), branch);
- }
-
- public static Builder builder() {
- return new AutoValue_CachedProjectConfig.Builder();
- }
-
- @AutoValue.Builder
- public abstract static class Builder {
- public abstract Builder setProject(Project value);
-
- public abstract Builder setGroups(ImmutableMap<AccountGroup.UUID, GroupReference> value);
-
- public abstract Builder setAccountsSection(AccountsSection value);
-
- public abstract Builder setAccessSections(ImmutableMap<String, AccessSection> value);
-
- public abstract Builder setBranchOrderSection(Optional<BranchOrderSection> value);
-
- public abstract Builder setContributorAgreements(
- ImmutableMap<String, ContributorAgreement> value);
-
- public abstract Builder setNotifySections(ImmutableMap<String, NotifyConfig> value);
-
- public abstract Builder setLabelSections(ImmutableMap<String, LabelType> value);
-
- public abstract Builder setMimeTypes(ConfiguredMimeTypes value);
-
- public abstract Builder setSubscribeSections(
- ImmutableMap<Project.NameKey, SubscribeSection> value);
-
- public abstract Builder setCommentLinkSections(
- ImmutableMap<String, StoredCommentLinkInfo> value);
-
- public abstract Builder setRulesId(Optional<ObjectId> value);
-
- public abstract Builder setRevision(Optional<ObjectId> value);
-
- public abstract Builder setMaxObjectSizeLimit(long value);
-
- public abstract Builder setCheckReceivedObjects(boolean value);
-
- public abstract Builder setExtensionPanelSections(
- ImmutableMap<String, ImmutableList<String>> value);
-
- public Builder setExtensionPanelSections(Map<String, List<String>> value) {
- ImmutableMap.Builder<String, ImmutableList<String>> b = ImmutableMap.builder();
- value.entrySet().forEach(e -> b.put(e.getKey(), ImmutableList.copyOf(e.getValue())));
- return setExtensionPanelSections(b.build());
- }
-
- public abstract CachedProjectConfig build();
- }
-
- private static ImmutableList<SubscribeSection> filterSubscribeSectionsByBranch(
- Collection<SubscribeSection> allSubscribeSections, BranchNameKey branch) {
- ImmutableList.Builder<SubscribeSection> ret = ImmutableList.builder();
- for (SubscribeSection s : allSubscribeSections) {
- if (s.appliesTo(branch)) {
- ret.add(s);
- }
- }
- return ret.build();
- }
-}
diff --git a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
index 610ee64..f054e84 100644
--- a/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
+++ b/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -17,12 +17,12 @@
import static com.google.common.base.Preconditions.checkArgument;
import static java.util.Objects.requireNonNull;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.AccountGroup.UUID;
import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 569cb54..7aa4029 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -16,7 +16,7 @@
import static java.util.stream.Collectors.toMap;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
diff --git a/java/com/google/gerrit/server/project/LabelResource.java b/java/com/google/gerrit/server/project/LabelResource.java
index a7a2f07..2df9ff1 100644
--- a/java/com/google/gerrit/server/project/LabelResource.java
+++ b/java/com/google/gerrit/server/project/LabelResource.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.project;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.extensions.restapi.RestResource;
import com.google.gerrit.extensions.restapi.RestView;
import com.google.inject.TypeLiteral;
diff --git a/java/com/google/gerrit/server/project/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
index 3fba7d3..cd41ce5 100644
--- a/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -52,8 +52,8 @@
* Get the cached data for a project by its unique name.
*
* @param projectName name of the project.
- * @return an {@link Optional} wrapping the the cached data; {@code absent} if no such project
- * exists or the projectName is null
+ * @return an {@link Optional} wrapping the cached data; {@code absent} if no such project exists
+ * or the projectName is null
* @throws StorageException when there was an error.
*/
Optional<ProjectState> get(@Nullable Project.NameKey projectName) throws StorageException;
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 5f3fbeb..1b11ba2 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.project;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
+import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toSet;
import com.google.common.annotations.VisibleForTesting;
@@ -24,11 +25,13 @@
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Sets;
import com.google.common.flogger.FluentLogger;
+import com.google.common.hash.Hashing;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.CachedProjectConfig;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.StorageException;
@@ -40,10 +43,17 @@
import com.google.gerrit.metrics.Field;
import com.google.gerrit.metrics.MetricMaker;
import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.proto.Protos;
import com.google.gerrit.server.CacheRefreshExecutor;
import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
+import com.google.gerrit.server.cache.serialize.entities.CachedProjectConfigSerializer;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.SitePaths;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
@@ -54,6 +64,7 @@
import com.google.inject.Singleton;
import com.google.inject.TypeLiteral;
import com.google.inject.name.Named;
+import com.google.protobuf.ByteString;
import java.io.IOException;
import java.time.Duration;
import java.util.Objects;
@@ -62,9 +73,13 @@
import java.util.concurrent.ExecutionException;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
+import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
/** Cache of project information, including access rights. */
@Singleton
@@ -73,17 +88,46 @@
public static final String CACHE_NAME = "projects";
+ public static final String PERSISTED_CACHE_NAME = "persisted_projects";
+
private static final String CACHE_LIST = "project_list";
public static Module module() {
return new CacheModule() {
@Override
protected void configure() {
- cache(CACHE_NAME, Project.NameKey.class, ProjectState.class)
- .loader(Loader.class)
+ // We split the project cache into two parts for performance reasons:
+ // 1) An in-memory part that has only the project name as key.
+ // 2) A persisted part that has the name and revision as key.
+ //
+ // When loading dashboards or returning change query results we potentially
+ // need to access hundreds of projects because each change could originate in
+ // a different project and, through inheritance, require us to check even more
+ // projects when evaluating permissions. It's not feasible to read the revision
+ // of refs/meta/config from each of these repos as that would require opening
+ // them all and reading their ref list or table.
+ // At the same time, we want the persisted cache to be immutable and we want it
+ // to be impossible that a value for a given key is out of date. We therefore
+ // require a revision in the key. That is in line with the rest of the caches in
+ // Gerrit.
+ //
+ // Splitting the cache into two chunks internally in this class allows us to retain
+ // the existing performance guarantees of not requiring reads for the repo for values
+ // cached in-memory but also to persist the cache which leads to a much improved
+ // cold-start behavior and in-memory miss latency.
+ cache(CACHE_NAME, Project.NameKey.class, CachedProjectConfig.class)
+ .loader(InMemoryLoader.class)
.refreshAfterWrite(Duration.ofMinutes(15))
.expireAfterWrite(Duration.ofHours(1));
+ persist(PERSISTED_CACHE_NAME, Cache.ProjectCacheKeyProto.class, CachedProjectConfig.class)
+ .loader(PersistedLoader.class)
+ .keySerializer(new ProtobufSerializer<>(Cache.ProjectCacheKeyProto.parser()))
+ .valueSerializer(PersistedProjectConfigSerializer.INSTANCE)
+ .diskLimit(1 << 30) // 1 GiB
+ .version(2)
+ .maximumWeight(0);
+
cache(CACHE_LIST, ListKey.class, new TypeLiteral<ImmutableSortedSet<Project.NameKey>>() {})
.maximumWeight(1)
.loader(Lister.class);
@@ -104,26 +148,29 @@
private final AllProjectsName allProjectsName;
private final AllUsersName allUsersName;
- private final LoadingCache<Project.NameKey, ProjectState> byName;
+ private final LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache;
private final LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list;
private final Lock listLock;
private final Provider<ProjectIndexer> indexer;
private final Timer0 guessRelevantGroupsLatency;
+ private final ProjectState.Factory projectStateFactory;
@Inject
ProjectCacheImpl(
final AllProjectsName allProjectsName,
final AllUsersName allUsersName,
- @Named(CACHE_NAME) LoadingCache<Project.NameKey, ProjectState> byName,
+ @Named(CACHE_NAME) LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache,
@Named(CACHE_LIST) LoadingCache<ListKey, ImmutableSortedSet<Project.NameKey>> list,
Provider<ProjectIndexer> indexer,
- MetricMaker metricMaker) {
+ MetricMaker metricMaker,
+ ProjectState.Factory projectStateFactory) {
this.allProjectsName = allProjectsName;
this.allUsersName = allUsersName;
- this.byName = byName;
+ this.inMemoryProjectCache = inMemoryProjectCache;
this.list = list;
this.listLock = new ReentrantLock(true /* fair */);
this.indexer = indexer;
+ this.projectStateFactory = projectStateFactory;
this.guessRelevantGroupsLatency =
metricMaker.newTimer(
@@ -150,7 +197,7 @@
}
try {
- return Optional.of(byName.get(projectName));
+ return Optional.of(inMemoryProjectCache.get(projectName)).map(projectStateFactory::create);
} catch (ExecutionException e) {
if ((e.getCause() instanceof RepositoryNotFoundException)) {
logger.atFine().log("Cannot find project %s", projectName.get());
@@ -170,7 +217,7 @@
public void evict(Project.NameKey p) {
if (p != null) {
logger.atFine().log("Evict project '%s'", p.get());
- byName.invalidate(p);
+ inMemoryProjectCache.invalidate(p);
}
indexer.get().index(p);
}
@@ -225,9 +272,9 @@
public Set<AccountGroup.UUID> guessRelevantGroupUUIDs() {
try (Timer0.Context ignored = guessRelevantGroupsLatency.start()) {
return all().stream()
- .map(n -> byName.getIfPresent(n))
+ .map(n -> inMemoryProjectCache.getIfPresent(n))
.filter(Objects::nonNull)
- .flatMap(p -> p.getConfig().getAllGroupUUIDs().stream())
+ .flatMap(p -> p.getAllGroupUUIDs().stream())
// getAllGroupUUIDs shouldn't really return null UUIDs, but harden
// against them just in case there is a bug or corner case.
.filter(id -> id != null && id.get() != null)
@@ -248,24 +295,52 @@
}
}
+ /**
+ * Returns a {@code MurMur128} hash of the contents of {@code etc/All-Projects-project.config}.
+ */
+ public static byte[] allProjectsFileProjectConfigHash(
+ AllProjectsName allProjectsName, SitePaths sitePaths) {
+ // Hash the contents of All-Projects-project.config
+ // This is a way for administrators to orchestrate project.config changes across many Gerrit
+ // instances.
+ // When this file changes, we need to make sure we disregard persistently cached project
+ // state.
+ FileBasedConfig fileBasedConfig =
+ new FileBasedConfig(
+ sitePaths
+ .etc_dir
+ .resolve(allProjectsName.get())
+ .resolve(ProjectConfig.PROJECT_CONFIG)
+ .toFile(),
+ FS.DETECTED);
+ try {
+ fileBasedConfig.load();
+ } catch (IOException | ConfigInvalidException e) {
+ throw new IllegalStateException(e);
+ }
+ return Hashing.murmur3_128().hashString(fileBasedConfig.toText(), UTF_8).asBytes();
+ }
+
@Singleton
- static class Loader extends CacheLoader<Project.NameKey, ProjectState> {
- private final ProjectState.Factory projectStateFactory;
- private final GitRepositoryManager mgr;
- private final ProjectConfig.Factory projectConfigFactory;
+ static class InMemoryLoader extends CacheLoader<Project.NameKey, CachedProjectConfig> {
+ private final LoadingCache<Cache.ProjectCacheKeyProto, CachedProjectConfig> persistedCache;
+ private final GitRepositoryManager repoManager;
private final ListeningExecutorService cacheRefreshExecutor;
private final Counter2<String, Boolean> refreshCounter;
+ private final AllProjectsName allProjectsName;
+ private final SitePaths sitePaths;
@Inject
- Loader(
- ProjectState.Factory psf,
- GitRepositoryManager g,
- ProjectConfig.Factory projectConfigFactory,
+ InMemoryLoader(
+ @Named(PERSISTED_CACHE_NAME)
+ LoadingCache<Cache.ProjectCacheKeyProto, CachedProjectConfig> persistedCache,
+ GitRepositoryManager repoManager,
@CacheRefreshExecutor ListeningExecutorService cacheRefreshExecutor,
- MetricMaker metricMaker) {
- projectStateFactory = psf;
- mgr = g;
- this.projectConfigFactory = projectConfigFactory;
+ MetricMaker metricMaker,
+ AllProjectsName allProjectsName,
+ SitePaths sitePaths) {
+ this.persistedCache = persistedCache;
+ this.repoManager = repoManager;
this.cacheRefreshExecutor = cacheRefreshExecutor;
refreshCounter =
metricMaker.newCounter(
@@ -273,31 +348,40 @@
new Description("count").setRate(),
Field.ofString("cache", Metadata.Builder::className).build(),
Field.ofBoolean("outdated", Metadata.Builder::outdated).build());
+ this.allProjectsName = allProjectsName;
+ this.sitePaths = sitePaths;
}
@Override
- public ProjectState load(Project.NameKey key) throws Exception {
- try (TraceTimer timer =
- TraceContext.newTimer(
- "Loading project", Metadata.builder().projectName(key.get()).build())) {
- try (Repository git = mgr.openRepository(key)) {
- ProjectConfig cfg = projectConfigFactory.create(key);
- cfg.load(key, git);
- return projectStateFactory.create(cfg);
+ public CachedProjectConfig load(Project.NameKey key) throws IOException, ExecutionException {
+ try (TraceTimer ignored =
+ TraceContext.newTimer(
+ "Loading project from serialized cache",
+ Metadata.builder().projectName(key.get()).build());
+ Repository git = repoManager.openRepository(key)) {
+ Cache.ProjectCacheKeyProto.Builder keyProto =
+ Cache.ProjectCacheKeyProto.newBuilder().setProject(key.get());
+ Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
+ if (key.get().equals(allProjectsName.get())) {
+ byte[] fileHash = allProjectsFileProjectConfigHash(allProjectsName, sitePaths);
+ keyProto.setGlobalConfigRevision(ByteString.copyFrom(fileHash));
}
+ if (configRef != null) {
+ keyProto.setRevision(ObjectIdConverter.create().toByteString(configRef.getObjectId()));
+ }
+ return persistedCache.get(keyProto.build());
}
}
@Override
- public ListenableFuture<ProjectState> reload(Project.NameKey key, ProjectState oldState)
- throws Exception {
- try (TraceTimer timer =
+ public ListenableFuture<CachedProjectConfig> reload(
+ Project.NameKey key, CachedProjectConfig oldState) throws Exception {
+ try (TraceTimer ignored =
TraceContext.newTimer(
"Reload project", Metadata.builder().projectName(key.get()).build())) {
- try (Repository git = mgr.openRepository(key)) {
+ try (Repository git = repoManager.openRepository(key)) {
Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
- if (configRef != null
- && configRef.getObjectId().equals(oldState.getBareConfig().getRevision())) {
+ if (configRef != null && configRef.getObjectId().equals(oldState.getRevision().get())) {
refreshCounter.increment(CACHE_NAME, false);
return Futures.immediateFuture(oldState);
}
@@ -311,6 +395,52 @@
}
}
+ @Singleton
+ static class PersistedLoader
+ extends CacheLoader<Cache.ProjectCacheKeyProto, CachedProjectConfig> {
+ private final GitRepositoryManager repoManager;
+ private final ProjectConfig.Factory projectConfigFactory;
+
+ @Inject
+ PersistedLoader(GitRepositoryManager repoManager, ProjectConfig.Factory projectConfigFactory) {
+ this.repoManager = repoManager;
+ this.projectConfigFactory = projectConfigFactory;
+ }
+
+ @Override
+ public CachedProjectConfig load(Cache.ProjectCacheKeyProto key) throws Exception {
+ Project.NameKey nameKey = Project.nameKey(key.getProject());
+ ObjectId revision =
+ key.getRevision().isEmpty()
+ ? null
+ : ObjectIdConverter.create().fromByteString(key.getRevision());
+ try (TraceTimer ignored =
+ TraceContext.newTimer(
+ "Loading project from repo", Metadata.builder().projectName(nameKey.get()).build())) {
+ try (Repository git = repoManager.openRepository(nameKey)) {
+ ProjectConfig cfg = projectConfigFactory.create(nameKey);
+ cfg.load(git, revision);
+ return cfg.getCacheable();
+ }
+ }
+ }
+ }
+
+ private enum PersistedProjectConfigSerializer implements CacheSerializer<CachedProjectConfig> {
+ INSTANCE;
+
+ @Override
+ public byte[] serialize(CachedProjectConfig value) {
+ return Protos.toByteArray(CachedProjectConfigSerializer.serialize(value));
+ }
+
+ @Override
+ public CachedProjectConfig deserialize(byte[] in) {
+ return CachedProjectConfigSerializer.deserialize(
+ Protos.parseUnchecked(Cache.CachedProjectConfigProto.parser(), in));
+ }
+ }
+
static class ListKey {
static final ListKey ALL = new ListKey();
@@ -335,11 +465,11 @@
@VisibleForTesting
public void evictAllByName() {
- byName.invalidateAll();
+ inMemoryProjectCache.invalidateAll();
}
@VisibleForTesting
public long sizeAllByName() {
- return byName.size();
+ return inMemoryProjectCache.size();
}
}
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 0c69722..54d9176 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -17,7 +17,7 @@
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.common.data.Permission.isPermission;
+import static com.google.gerrit.entities.Permission.isPermission;
import static com.google.gerrit.entities.Project.DEFAULT_SUBMIT_TYPE;
import static com.google.gerrit.server.permissions.PluginPermissionsUtil.isValidPluginPermission;
import static java.util.Objects.requireNonNull;
@@ -28,34 +28,36 @@
import com.google.common.base.Splitter;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
+import com.google.common.flogger.FluentLogger;
import com.google.common.primitives.Shorts;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ContributorAgreement;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountsSection;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.entities.CachedProjectConfig;
import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.entities.ContributorAgreement;
import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.NotifyConfig;
import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubscribeSection;
import com.google.gerrit.exceptions.InvalidNameException;
import com.google.gerrit.extensions.client.InheritableBoolean;
import com.google.gerrit.extensions.client.ProjectState;
@@ -98,6 +100,8 @@
import org.eclipse.jgit.util.FS;
public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
public static final String COMMENTLINK = "commentlink";
public static final String LABEL = "label";
public static final String KEY_FUNCTION = "function";
@@ -249,6 +253,7 @@
private ObjectId rulesId;
private long maxObjectSizeLimit;
private Map<String, Config> pluginConfigs;
+ private Map<String, Config> projectLevelConfigs;
private boolean checkReceivedObjects;
private Set<String> sectionsWithUnknownPermissions;
private boolean hasLegacyPermissions;
@@ -256,24 +261,31 @@
/** Returns an immutable, thread-safe representation of this object that can be cached. */
public CachedProjectConfig getCacheable() {
- return CachedProjectConfig.builder()
- .setProject(project)
- .setAccountsSection(accountsSection)
- .setGroups(ImmutableMap.copyOf(groupList.byUUID()))
- .setAccessSections(ImmutableMap.copyOf(accessSections))
- .setBranchOrderSection(Optional.ofNullable(branchOrderSection))
- .setContributorAgreements(ImmutableMap.copyOf(contributorAgreements))
- .setNotifySections(ImmutableMap.copyOf(notifySections))
- .setLabelSections(ImmutableMap.copyOf(labelSections))
- .setMimeTypes(mimeTypes)
- .setSubscribeSections(ImmutableMap.copyOf(subscribeSections))
- .setCommentLinkSections(ImmutableMap.copyOf(commentLinkSections))
- .setRulesId(Optional.ofNullable(rulesId))
- .setRevision(Optional.ofNullable(getRevision()))
- .setMaxObjectSizeLimit(maxObjectSizeLimit)
- .setCheckReceivedObjects(checkReceivedObjects)
- .setExtensionPanelSections(extensionPanelSections)
- .build();
+ CachedProjectConfig.Builder builder =
+ CachedProjectConfig.builder()
+ .setProject(project)
+ .setAccountsSection(accountsSection)
+ .setBranchOrderSection(Optional.ofNullable(branchOrderSection))
+ .setMimeTypes(mimeTypes)
+ .setRulesId(Optional.ofNullable(rulesId))
+ .setRevision(Optional.ofNullable(getRevision()))
+ .setMaxObjectSizeLimit(maxObjectSizeLimit)
+ .setCheckReceivedObjects(checkReceivedObjects)
+ .setExtensionPanelSections(extensionPanelSections);
+ groupList.byUUID().values().forEach(g -> builder.addGroup(g));
+ accessSections.values().forEach(a -> builder.addAccessSection(a));
+ contributorAgreements.values().forEach(c -> builder.addContributorAgreement(c));
+ notifySections.values().forEach(n -> builder.addNotifySection(n));
+ subscribeSections.values().forEach(s -> builder.addSubscribeSection(s));
+ commentLinkSections.values().forEach(c -> builder.addCommentLinkSection(c));
+ labelSections.values().forEach(l -> builder.addLabelSection(l));
+ pluginConfigs
+ .entrySet()
+ .forEach(c -> builder.addPluginConfig(c.getKey(), c.getValue().toText()));
+ projectLevelConfigs
+ .entrySet()
+ .forEach(c -> builder.addProjectLevelConfig(c.getKey(), c.getValue().toText()));
+ return builder.build();
}
public static StoredCommentLinkInfo buildCommentLink(Config cfg, String name, boolean allowRaw)
@@ -650,6 +662,7 @@
loadSubscribeSections(rc);
mimeTypes = ConfiguredMimeTypes.create(projectName.get(), rc);
loadPluginSections(rc);
+ loadProjectLevelConfigs();
loadReceiveSection(rc);
loadExtensionPanelSections(rc);
}
@@ -1156,13 +1169,38 @@
}
}
- public PluginConfig getPluginConfig(String pluginName) {
+ public void updatePluginConfig(
+ String pluginName, Consumer<PluginConfig.Update> pluginConfigUpdate) {
Config pluginConfig = pluginConfigs.get(pluginName);
if (pluginConfig == null) {
pluginConfig = new Config();
pluginConfigs.put(pluginName, pluginConfig);
}
- return new PluginConfig(pluginName, pluginConfig, this);
+ pluginConfigUpdate.accept(new PluginConfig.Update(pluginName, pluginConfig, Optional.of(this)));
+ }
+
+ public PluginConfig getPluginConfig(String pluginName) {
+ Config pluginConfig = pluginConfigs.getOrDefault(pluginName, new Config());
+ return PluginConfig.create(pluginName, pluginConfig, getCacheable());
+ }
+
+ private void loadProjectLevelConfigs() throws IOException {
+ projectLevelConfigs = new HashMap<>();
+ if (revision == null) {
+ return;
+ }
+ for (PathInfo pathInfo : getPathInfos(true)) {
+ if (pathInfo.path.endsWith(".config") && !PROJECT_CONFIG.equals(pathInfo.path)) {
+ String cfg = readUTF8(pathInfo.path);
+ Config parsedConfig = new Config();
+ try {
+ parsedConfig.fromText(cfg);
+ projectLevelConfigs.put(pathInfo.path, parsedConfig);
+ } catch (ConfigInvalidException e) {
+ logger.atWarning().withCause(e).log("Unable to parse config");
+ }
+ }
+ }
}
private void readGroupList() throws IOException {
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index 19c3afb..c382f04 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -19,13 +19,13 @@
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.events.NewProjectCreatedListener;
diff --git a/java/com/google/gerrit/server/project/ProjectJson.java b/java/com/google/gerrit/server/project/ProjectJson.java
index de55a12..4eda1cc 100644
--- a/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/java/com/google/gerrit/server/project/ProjectJson.java
@@ -19,7 +19,7 @@
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.common.LabelTypeInfo;
diff --git a/java/com/google/gerrit/server/project/ProjectLevelConfig.java b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
index 4e0261c..4825233 100644
--- a/java/com/google/gerrit/server/project/ProjectLevelConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectLevelConfig.java
@@ -24,29 +24,61 @@
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Stream;
+import org.eclipse.jgit.annotations.Nullable;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Config;
/** Configuration file in the projects refs/meta/config branch. */
-public class ProjectLevelConfig extends VersionedMetaData {
+public class ProjectLevelConfig {
+ /**
+ * This class is a low-level API that allows callers to read the config directly from a repository
+ * and make updates to it.
+ */
+ public static class Bare extends VersionedMetaData {
+ private final String fileName;
+ @Nullable private Config cfg;
+
+ public Bare(String fileName) {
+ this.fileName = fileName;
+ this.cfg = null;
+ }
+
+ public Config getConfig() {
+ if (cfg == null) {
+ cfg = new Config();
+ }
+ return cfg;
+ }
+
+ @Override
+ protected String getRefName() {
+ return RefNames.REFS_CONFIG;
+ }
+
+ @Override
+ protected void onLoad() throws IOException, ConfigInvalidException {
+ cfg = readConfig(fileName);
+ }
+
+ @Override
+ protected boolean onSave(CommitBuilder commit) throws IOException {
+ if (commit.getMessage() == null || "".equals(commit.getMessage())) {
+ commit.setMessage("Updated configuration\n");
+ }
+ saveConfig(fileName, cfg);
+ return true;
+ }
+ }
+
private final String fileName;
private final ProjectState project;
private Config cfg;
- public ProjectLevelConfig(String fileName, ProjectState project) {
+ public ProjectLevelConfig(String fileName, ProjectState project, Config cfg) {
this.fileName = fileName;
this.project = project;
- }
-
- @Override
- protected String getRefName() {
- return RefNames.REFS_CONFIG;
- }
-
- @Override
- protected void onLoad() throws IOException, ConfigInvalidException {
- cfg = readConfig(fileName);
+ this.cfg = cfg;
}
public Config get() {
@@ -127,13 +159,4 @@
}
return cfgWithInheritance;
}
-
- @Override
- protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
- if (commit.getMessage() == null || "".equals(commit.getMessage())) {
- commit.setMessage("Updated configuration\n");
- }
- saveConfig(fileName, cfg);
- return true;
- }
}
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 42e09d3..eecf1fe 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -14,26 +14,27 @@
package com.google.gerrit.server.project;
-import static com.google.gerrit.common.data.PermissionRule.Action.ALLOW;
+import static com.google.gerrit.entities.PermissionRule.Action.ALLOW;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.entities.CachedProjectConfig;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubscribeSection;
import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -41,16 +42,14 @@
import com.google.gerrit.server.account.CapabilityCollection;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.config.PluginConfig;
import com.google.gerrit.server.git.TransferConfig;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.inject.Inject;
import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
-import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
@@ -58,7 +57,7 @@
import java.util.Optional;
import java.util.Set;
import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.Config;
/**
* Cached information on a project. Must not contain any data derived from parents other than it's
@@ -68,19 +67,16 @@
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public interface Factory {
- ProjectState create(ProjectConfig config);
+ ProjectState create(CachedProjectConfig config);
}
private final boolean isAllProjects;
private final boolean isAllUsers;
private final AllProjectsName allProjectsName;
private final ProjectCache projectCache;
- private final GitRepositoryManager gitMgr;
private final List<CommentLinkInfo> commentLinks;
- private final ProjectConfig config;
private final CachedProjectConfig cachedConfig;
- private final Map<String, ProjectLevelConfig> configs;
private final Set<AccountGroup.UUID> localOwners;
private final long globalMaxObjectSizeLimit;
private final boolean inheritProjectMaxObjectSizeLimit;
@@ -96,23 +92,22 @@
ProjectCache projectCache,
AllProjectsName allProjectsName,
AllUsersName allUsersName,
- GitRepositoryManager gitMgr,
List<CommentLinkInfo> commentLinks,
CapabilityCollection.Factory limitsFactory,
TransferConfig transferConfig,
- @Assisted ProjectConfig config) {
+ @Assisted CachedProjectConfig cachedProjectConfig) {
this.projectCache = projectCache;
- this.isAllProjects = config.getProject().getNameKey().equals(allProjectsName);
- this.isAllUsers = config.getProject().getNameKey().equals(allUsersName);
+ this.isAllProjects = cachedProjectConfig.getProject().getNameKey().equals(allProjectsName);
+ this.isAllUsers = cachedProjectConfig.getProject().getNameKey().equals(allUsersName);
this.allProjectsName = allProjectsName;
- this.gitMgr = gitMgr;
this.commentLinks = commentLinks;
- this.config = config;
- this.cachedConfig = config.getCacheable();
- this.configs = new HashMap<>();
+ this.cachedConfig = cachedProjectConfig;
this.capabilities =
isAllProjects
- ? limitsFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
+ ? limitsFactory.create(
+ cachedProjectConfig
+ .getAccessSection(AccessSection.GLOBAL_CAPABILITIES)
+ .orElse(null))
: null;
this.globalMaxObjectSizeLimit = transferConfig.getMaxObjectSizeLimit();
this.inheritProjectMaxObjectSizeLimit = transferConfig.inheritProjectMaxObjectSizeLimit();
@@ -121,9 +116,9 @@
localOwners = Collections.emptySet();
} else {
HashSet<AccountGroup.UUID> groups = new HashSet<>();
- AccessSection all = config.getAccessSection(AccessSection.ALL);
- if (all != null) {
- Permission owner = all.getPermission(Permission.OWNER);
+ Optional<AccessSection> all = cachedProjectConfig.getAccessSection(AccessSection.ALL);
+ if (all.isPresent()) {
+ Permission owner = all.get().getPermission(Permission.OWNER);
if (owner != null) {
for (PermissionRule rule : owner.getRules()) {
GroupReference ref = rule.getGroup();
@@ -163,7 +158,7 @@
}
public Project getProject() {
- return config.getProject();
+ return cachedConfig.getProject();
}
public Project.NameKey getNameKey() {
@@ -178,29 +173,13 @@
return cachedConfig;
}
- // TODO(hiesel): Remove this method.
- public ProjectConfig getBareConfig() {
- return config;
- }
-
public ProjectLevelConfig getConfig(String fileName) {
- if (configs.containsKey(fileName)) {
- return configs.get(fileName);
- }
-
- ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
- try (Repository git = gitMgr.openRepository(getNameKey())) {
- cfg.load(getNameKey(), git, config.getRevision());
- } catch (IOException | ConfigInvalidException e) {
- logger.atWarning().withCause(e).log("Failed to load %s for %s", fileName, getName());
- }
-
- configs.put(fileName, cfg);
- return cfg;
+ Optional<Config> rawConfig = cachedConfig.getProjectLevelConfig(fileName);
+ return new ProjectLevelConfig(fileName, this, rawConfig.orElse(new Config()));
}
public long getMaxObjectSizeLimit() {
- return config.getMaxObjectSizeLimit();
+ return cachedConfig.getMaxObjectSizeLimit();
}
public boolean statePermitsRead() {
@@ -249,19 +228,21 @@
public EffectiveMaxObjectSizeLimit getEffectiveMaxObjectSizeLimit() {
EffectiveMaxObjectSizeLimit result = new EffectiveMaxObjectSizeLimit();
- result.value = config.getMaxObjectSizeLimit();
+ result.value = cachedConfig.getMaxObjectSizeLimit();
if (inheritProjectMaxObjectSizeLimit) {
for (ProjectState parent : parents()) {
- long parentValue = parent.config.getMaxObjectSizeLimit();
+ long parentValue = parent.cachedConfig.getMaxObjectSizeLimit();
if (parentValue > 0 && result.value > 0) {
if (parentValue < result.value) {
result.value = parentValue;
- result.summary = String.format(OVERRIDDEN_BY_PARENT, parent.config.getName());
+ result.summary =
+ String.format(OVERRIDDEN_BY_PARENT, parent.cachedConfig.getProject().getNameKey());
}
} else if (parentValue > 0) {
result.value = parentValue;
- result.summary = String.format(INHERITED_FROM_PARENT, parent.config.getName());
+ result.summary =
+ String.format(INHERITED_FROM_PARENT, parent.cachedConfig.getProject().getNameKey());
}
}
}
@@ -283,7 +264,7 @@
List<SectionMatcher> getLocalAccessSections() {
List<SectionMatcher> sm = localAccessSections;
if (sm == null) {
- Collection<AccessSection> fromConfig = config.getAccessSections();
+ Collection<AccessSection> fromConfig = cachedConfig.getAccessSections().values();
sm = new ArrayList<>(fromConfig.size());
for (AccessSection section : fromConfig) {
if (isAllProjects) {
@@ -482,6 +463,28 @@
return ImmutableList.copyOf(cls.values());
}
+ /**
+ * Returns the {@link PluginConfig} that got parsed from the {@code plugins} section of {@code
+ * project.config}. The returned instance is a defensive copy of the cached value. Returns an
+ * empty config in case we find no config for the given plugin name. This is useful when calling
+ * {@code PluginConfig#withInheritance(ProjectState.Factory)}
+ */
+ public PluginConfig getPluginConfig(String pluginName) {
+ if (getConfig().getPluginConfigs().containsKey(pluginName)) {
+ Config config = new Config();
+ try {
+ config.fromText(getConfig().getPluginConfigs().get(pluginName));
+ } catch (ConfigInvalidException e) {
+ // This is OK to propagate as IllegalStateException because it's a programmer error.
+ // The config was converted to a String using Config#toText. So #fromText must not
+ // throw a ConfigInvalidException
+ throw new IllegalStateException("invalid plugin config for " + pluginName, e);
+ }
+ return PluginConfig.create(pluginName, config, getConfig());
+ }
+ return PluginConfig.create(pluginName, new Config(), getConfig());
+ }
+
public Optional<BranchOrderSection> getBranchOrderSection() {
for (ProjectState s : tree()) {
Optional<BranchOrderSection> section = s.getConfig().getBranchOrderSection();
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 7ed2491..812d98d 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -14,7 +14,6 @@
package com.google.gerrit.server.project;
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
import static com.google.gerrit.index.query.Predicate.and;
import static com.google.gerrit.index.query.Predicate.or;
import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
@@ -36,13 +35,16 @@
import com.google.gerrit.extensions.api.projects.CheckProjectResultInfo.AutoCloseableChangesCheckResult;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.index.IndexConfig;
import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.config.UrlFormatter;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.index.change.ChangeField;
import com.google.gerrit.server.query.change.ChangeData;
@@ -75,17 +77,20 @@
private final RetryHelper retryHelper;
private final ChangeJson.Factory changeJsonFactory;
private final IndexConfig indexConfig;
+ private final DynamicItem<UrlFormatter> urlFormatter;
@Inject
ProjectsConsistencyChecker(
GitRepositoryManager repoManager,
RetryHelper retryHelper,
ChangeJson.Factory changeJsonFactory,
- IndexConfig indexConfig) {
+ IndexConfig indexConfig,
+ DynamicItem<UrlFormatter> urlFormatter) {
this.repoManager = repoManager;
this.retryHelper = retryHelper;
this.changeJsonFactory = changeJsonFactory;
this.indexConfig = indexConfig;
+ this.urlFormatter = urlFormatter;
}
public CheckProjectResultInfo check(Project.NameKey projectName, CheckProjectInput input)
@@ -172,7 +177,7 @@
mergedSha1s.add(commitId);
// Consider all Change-Id lines since this is what ReceiveCommits#autoCloseChanges does.
- List<String> changeIds = commit.getFooterLines(CHANGE_ID);
+ List<String> changeIds = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter.get());
// Number of predicates that we need to add for this commit, 1 per Change-Id plus one for
// the commit.
diff --git a/java/com/google/gerrit/server/project/RefPattern.java b/java/com/google/gerrit/server/project/RefPattern.java
index c52914b..5bac950 100644
--- a/java/com/google/gerrit/server/project/RefPattern.java
+++ b/java/com/google/gerrit/server/project/RefPattern.java
@@ -19,8 +19,8 @@
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.exceptions.InvalidNameException;
import dk.brics.automaton.RegExp;
import java.util.Map;
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index 6de8eec..763957e 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.project;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.CurrentUser;
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index cf3819d..0e50bb0 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -18,9 +18,9 @@
import com.google.common.collect.Streams;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.metrics.Description;
import com.google.gerrit.metrics.Description.Units;
diff --git a/java/com/google/gerrit/server/project/testing/TestLabels.java b/java/com/google/gerrit/server/project/testing/TestLabels.java
index 8629757..157c746 100644
--- a/java/com/google/gerrit/server/project/testing/TestLabels.java
+++ b/java/com/google/gerrit/server/project/testing/TestLabels.java
@@ -14,8 +14,8 @@
package com.google.gerrit.server.project.testing;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import java.util.Arrays;
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 34c96dd..432b48a 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -33,20 +33,20 @@
import com.google.common.collect.Maps;
import com.google.common.primitives.Ints;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.RobotComment;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -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;
@@ -680,7 +682,7 @@
// from the index. However, we need these values for permission checks.
throw new IllegalStateException("reviewers not populated");
}
- reviewers = approvalsUtil.getReviewers(notes(), approvals().values());
+ reviewers = approvalsUtil.getReviewers(notes());
}
return reviewers;
}
@@ -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/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index c6bcd60..a66c43ae 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -88,7 +88,7 @@
? permissionBackend.absentUser(user.getAccountId())
: permissionBackend.user(
Optional.of(user)
- .filter(u -> u instanceof SingleGroupUser || u instanceof InternalUser)
+ .filter(u -> u instanceof GroupBackedUser || u instanceof InternalUser)
.orElseGet(anonymousUserProvider::get));
try {
withUser.change(cd).check(ChangePermission.READ);
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 2681b6d..ff90c3f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -28,7 +28,6 @@
import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
import com.google.common.primitives.Ints;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Address;
@@ -38,6 +37,7 @@
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.exceptions.NotSignedInException;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.registration.DynamicMap;
@@ -117,12 +117,14 @@
* <p>bind(ChangeHasOperandFactory.class) .annotatedWith(Exports.named("your has operand"))
* .to(YourClass.class);
*/
- private interface ChangeOperandFactory {
+ public interface ChangeOperandFactory {
Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException;
}
public interface ChangeHasOperandFactory extends ChangeOperandFactory {}
+ public interface ChangeIsOperandFactory extends ChangeOperandFactory {}
+
private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
private static final Pattern PAT_CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
private static final Pattern DEF_CHANGE =
@@ -197,6 +199,7 @@
public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
+ public static final String ARG_ID_NAME = "name";
public static final String ARG_ID_USER = "user";
public static final String ARG_ID_GROUP = "group";
public static final String ARG_ID_OWNER = "owner";
@@ -218,6 +221,7 @@
final CommentsUtil commentsUtil;
final ConflictsCache conflictsCache;
final DynamicMap<ChangeHasOperandFactory> hasOperands;
+ final DynamicMap<ChangeIsOperandFactory> isOperands;
final DynamicMap<ChangeOperatorFactory> opFactories;
final GitRepositoryManager repoManager;
final GroupBackend groupBackend;
@@ -244,6 +248,7 @@
ChangeIndexRewriter rewriter,
DynamicMap<ChangeOperatorFactory> opFactories,
DynamicMap<ChangeHasOperandFactory> hasOperands,
+ DynamicMap<ChangeIsOperandFactory> isOperands,
IdentifiedUser.GenericFactory userFactory,
Provider<CurrentUser> self,
PermissionBackend permissionBackend,
@@ -273,6 +278,7 @@
rewriter,
opFactories,
hasOperands,
+ isOperands,
userFactory,
self,
permissionBackend,
@@ -304,6 +310,7 @@
ChangeIndexRewriter rewriter,
DynamicMap<ChangeOperatorFactory> opFactories,
DynamicMap<ChangeHasOperandFactory> hasOperands,
+ DynamicMap<ChangeIsOperandFactory> isOperands,
IdentifiedUser.GenericFactory userFactory,
Provider<CurrentUser> self,
PermissionBackend permissionBackend,
@@ -351,6 +358,7 @@
this.starredChangesUtil = starredChangesUtil;
this.accountCache = accountCache;
this.hasOperands = hasOperands;
+ this.isOperands = isOperands;
this.groupMembers = groupMembers;
this.changeIsVisbleToPredicateFactory = changeIsVisbleToPredicateFactory;
this.operatorAliasConfig = operatorAliasConfig;
@@ -364,6 +372,7 @@
rewriter,
opFactories,
hasOperands,
+ isOperands,
userFactory,
Providers.of(otherUser),
permissionBackend,
@@ -643,6 +652,14 @@
throw new QueryParseException("'is:wip' operator is not supported by change index version");
}
+ // for plugins the value will be operandName_pluginName
+ List<String> names = Lists.newArrayList(Splitter.on('_').split(value));
+ if (names.size() == 2) {
+ ChangeIsOperandFactory op = args.isOperands.get(names.get(1), names.get(0));
+ if (op != null) {
+ return op.create(this);
+ }
+ }
return status(value);
}
@@ -1010,7 +1027,7 @@
for (GroupReference ref : suggestions) {
ids.add(ref.getUUID());
}
- return visibleto(new SingleGroupUser(ids));
+ return visibleto(new GroupBackedUser(ids));
}
throw error("No user or group matches \"" + who + "\".");
@@ -1208,9 +1225,36 @@
}
@Operator
- public Predicate<ChangeData> query(String name) throws QueryParseException {
+ public Predicate<ChangeData> query(String value) throws QueryParseException {
+ // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+ PredicateArgs inputArgs = new PredicateArgs(value);
+ String name = null;
+ Account.Id account = null;
+
try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
- VersionedAccountQueries q = VersionedAccountQueries.forUser(self());
+ // [name=]<name>
+ if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+ name = inputArgs.keyValue.get(ARG_ID_NAME);
+ } else if (inputArgs.positional.size() == 1) {
+ name = Iterables.getOnlyElement(inputArgs.positional);
+ } else if (inputArgs.positional.size() > 1) {
+ throw new QueryParseException("Error parsing named query: " + value);
+ }
+
+ // [,user=<user>]
+ if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
+ Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+ if (accounts != null && accounts.size() > 1) {
+ throw error(
+ String.format(
+ "\"%s\" resolves to multiple accounts", inputArgs.keyValue.get(ARG_ID_USER)));
+ }
+ account = (accounts == null ? self() : Iterables.getOnlyElement(accounts));
+ } else {
+ account = self();
+ }
+
+ VersionedAccountQueries q = VersionedAccountQueries.forUser(account);
q.load(args.allUsersName, git);
String query = q.getQueryList().getQuery(name);
if (query != null) {
@@ -1220,7 +1264,7 @@
throw new QueryParseException(
"Unknown named query (no " + args.allUsersName + " repo): " + name, e);
} catch (IOException | ConfigInvalidException e) {
- throw new QueryParseException("Error parsing named query: " + name, e);
+ throw new QueryParseException("Error parsing named query: " + value, e);
}
throw new QueryParseException("Unknown named query: " + name);
}
@@ -1232,19 +1276,46 @@
}
@Operator
- public Predicate<ChangeData> destination(String name) throws QueryParseException {
+ public Predicate<ChangeData> destination(String value) throws QueryParseException {
+ // [name=]<name>[,user=<user>] || [user=<user>,][name=]<name>
+ PredicateArgs inputArgs = new PredicateArgs(value);
+ String name = null;
+ Account.Id account = null;
+
try (Repository git = args.repoManager.openRepository(args.allUsersName)) {
- VersionedAccountDestinations d = VersionedAccountDestinations.forUser(self());
+ // [name=]<name>
+ if (inputArgs.keyValue.containsKey(ARG_ID_NAME)) {
+ name = inputArgs.keyValue.get(ARG_ID_NAME);
+ } else if (inputArgs.positional.size() == 1) {
+ name = Iterables.getOnlyElement(inputArgs.positional);
+ } else if (inputArgs.positional.size() > 1) {
+ throw new QueryParseException("Error parsing named destination: " + value);
+ }
+
+ // [,user=<user>]
+ if (inputArgs.keyValue.containsKey(ARG_ID_USER)) {
+ Set<Account.Id> accounts = parseAccount(inputArgs.keyValue.get(ARG_ID_USER));
+ if (accounts != null && accounts.size() > 1) {
+ throw error(
+ String.format(
+ "\"%s\" resolves to multiple accounts", inputArgs.keyValue.get(ARG_ID_USER)));
+ }
+ account = (accounts == null ? self() : Iterables.getOnlyElement(accounts));
+ } else {
+ account = self();
+ }
+
+ VersionedAccountDestinations d = VersionedAccountDestinations.forUser(account);
d.load(args.allUsersName, git);
Set<BranchNameKey> destinations = d.getDestinationList().getDestinations(name);
if (destinations != null && !destinations.isEmpty()) {
- return new DestinationPredicate(destinations, name);
+ return new DestinationPredicate(destinations, value);
}
} catch (RepositoryNotFoundException e) {
throw new QueryParseException(
"Unknown named destination (no " + args.allUsersName + " repo): " + name, e);
} catch (IOException | ConfigInvalidException e) {
- throw new QueryParseException("Error parsing named destination: " + name, e);
+ throw new QueryParseException("Error parsing named destination: " + value, e);
}
throw new QueryParseException("Unknown named destination: " + name);
}
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 40c0477..370bc75 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -19,6 +19,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.common.PluginDefinedInfo;
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.extensions.registration.Extension;
@@ -33,15 +34,20 @@
import com.google.gerrit.server.DynamicOptions.DynamicBean;
import com.google.gerrit.server.account.AccountLimits;
import com.google.gerrit.server.change.ChangeAttributeFactory;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
import com.google.gerrit.server.change.PluginDefinedAttributesFactory;
+import com.google.gerrit.server.change.PluginDefinedInfosFactory;
import com.google.gerrit.server.index.change.ChangeIndexCollection;
import com.google.gerrit.server.index.change.ChangeIndexRewriter;
import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
import com.google.gerrit.server.index.change.IndexedChangeQuery;
import com.google.inject.Inject;
import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.Collection;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -52,11 +58,13 @@
* holding on to a single instance.
*/
public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
- implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider {
+ implements DynamicOptions.BeanReceiver, DynamicOptions.BeanProvider, PluginDefinedInfosFactory {
private final Provider<CurrentUser> userProvider;
private final ImmutableListMultimap<String, ChangeAttributeFactory> attributeFactoriesByPlugin;
private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
+ private final List<Extension<ChangePluginDefinedInfoFactory>>
+ changePluginDefinedInfoFactoriesByPlugin = new ArrayList<>();
static {
// It is assumed that basic rewrites do not touch visibleto predicates.
@@ -74,7 +82,8 @@
ChangeIndexCollection indexes,
ChangeIndexRewriter rewriter,
DynamicSet<ChangeAttributeFactory> attributeFactories,
- ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory) {
+ ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory,
+ DynamicSet<ChangePluginDefinedInfoFactory> changePluginDefinedInfoFactories) {
super(
metricMaker,
ChangeSchemaDefinitions.INSTANCE,
@@ -88,10 +97,15 @@
ImmutableListMultimap.Builder<String, ChangeAttributeFactory> factoriesBuilder =
ImmutableListMultimap.builder();
+ ImmutableListMultimap.Builder<String, ChangePluginDefinedInfoFactory> infosFactoriesBuilder =
+ ImmutableListMultimap.builder();
// Eagerly call Extension#get() rather than storing Extensions, since that method invokes the
// Provider on every call, which could be expensive if we invoke it once for every change.
attributeFactories.entries().forEach(e -> factoriesBuilder.put(e.getPluginName(), e.get()));
attributeFactoriesByPlugin = factoriesBuilder.build();
+ changePluginDefinedInfoFactories
+ .entries()
+ .forEach(e -> changePluginDefinedInfoFactoriesByPlugin.add(e));
}
@Override
@@ -128,6 +142,17 @@
.map(e -> new Extension<>(e.getKey(), e::getValue)));
}
+ public PluginDefinedInfosFactory getInfosFactory() {
+ return this::createPluginDefinedInfos;
+ }
+
+ @Override
+ public ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+ Collection<ChangeData> cds) {
+ return PluginDefinedAttributesFactories.createAll(
+ cds, this, changePluginDefinedInfoFactoriesByPlugin.stream());
+ }
+
@Override
protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
return new AndChangeSource(
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 6eb6871d..16f85b1 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -20,11 +20,11 @@
import static java.util.concurrent.TimeUnit.MINUTES;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitTypeRecord;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.index.query.PostFilterPredicate;
import com.google.gerrit.index.query.Predicate;
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 569d7cb..4b6c964 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -14,11 +14,11 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.server.IdentifiedUser;
diff --git a/java/com/google/gerrit/server/query/change/GroupBackedUser.java b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
new file mode 100644
index 0000000..3960813
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/GroupBackedUser.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.ListGroupMembership;
+import java.util.Set;
+
+/**
+ * Representation of a user that does not have a Gerrit account.
+ *
+ * <p>This user representation is intended to be used for two purposes:
+ *
+ * <ol>
+ * <li>Checking permissions for groups: There are occasions where we need to check if a resource -
+ * such as a change - is accessible by a group. Our entire {@link
+ * com.google.gerrit.server.permissions.PermissionBackend} works solely with {@link
+ * CurrentUser}. This class can be used to check permissions on a synthetic user with the
+ * given group memberships. Any real Gerrit user with the same group memberships would receive
+ * the same permission check results.
+ * <li>Checking permissions for an external user: In installations with external group systems,
+ * one might want to check what Gerrit permissions a user has, before or even without creating
+ * a Gerrit account. Such an external user has external group memberships only as well as
+ * internal groups that contain the user's external groups as subgroups. This class can be
+ * used to represent such an external user.
+ * </ol>
+ */
+public final class GroupBackedUser extends CurrentUser {
+ private final GroupMembership groups;
+
+ /**
+ * Creates a new instance
+ *
+ * @param groups this set has to include all parent groups the user is contained in through
+ * subgroup membership. Given a set of groups that contains the user directly, callers can use
+ * {@link
+ * com.google.gerrit.server.account.GroupIncludeCache#parentGroupsOf(AccountGroup.UUID)} to
+ * resolve parent groups.
+ */
+ public GroupBackedUser(Set<AccountGroup.UUID> groups) {
+ this.groups = new ListGroupMembership(groups);
+ }
+
+ @Override
+ public GroupMembership getEffectiveGroups() {
+ return groups;
+ }
+
+ @Override
+ public String getLoggableName() {
+ return "GroupBackedUser with memberships: " + groups.getKnownGroups();
+ }
+
+ @Override
+ public Object getCacheKey() {
+ return groups.getKnownGroups();
+ }
+}
diff --git a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index e875499..b931457 100644
--- a/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -17,11 +17,14 @@
import static com.google.common.base.Preconditions.checkState;
import static java.nio.charset.StandardCharsets.UTF_8;
+import com.google.common.collect.ImmutableListMultimap;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.index.query.QueryResult;
import com.google.gerrit.server.DynamicOptions;
@@ -97,6 +100,8 @@
private OutputStream outputStream = DisabledOutputStream.INSTANCE;
private PrintWriter out;
+ private ImmutableListMultimap<Change.Id, PluginDefinedInfo> pluginInfosByChange =
+ ImmutableListMultimap.of();
@Inject
OutputStreamQuery(
@@ -207,6 +212,7 @@
Map<Project.NameKey, Repository> repos = new HashMap<>();
Map<Project.NameKey, RevWalk> revWalks = new HashMap<>();
QueryResult<ChangeData> results = queryProcessor.query(queryBuilder.parse(queryString));
+ pluginInfosByChange = queryProcessor.createPluginDefinedInfos(results.entities());
try {
for (ChangeData d : results.entities()) {
show(buildChangeAttribute(d, repos, revWalks));
@@ -325,6 +331,15 @@
}
c.plugins = queryProcessor.getAttributesFactory().create(d);
+ List<PluginDefinedInfo> pluginInfos = pluginInfosByChange.get(d.getId());
+ if (!pluginInfos.isEmpty()) {
+ if (c.plugins == null) {
+ c.plugins = pluginInfos;
+ } else {
+ c.plugins = new ArrayList<>(c.plugins);
+ c.plugins.addAll(pluginInfos);
+ }
+ }
return c;
}
diff --git a/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/java/com/google/gerrit/server/query/change/SingleGroupUser.java
deleted file mode 100644
index c451d46..0000000
--- a/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2009 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.account.ListGroupMembership;
-import java.util.Set;
-
-public final class SingleGroupUser extends CurrentUser {
- private final GroupMembership groups;
-
- public SingleGroupUser(AccountGroup.UUID groupId) {
- this(ImmutableSet.of(groupId));
- }
-
- public SingleGroupUser(Set<AccountGroup.UUID> groups) {
- this.groups = new ListGroupMembership(groups);
- }
-
- @Override
- public GroupMembership getEffectiveGroups() {
- return groups;
- }
-
- @Override
- public Object getCacheKey() {
- return groups.getKnownGroups();
- }
-}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index bc65422..4ca684a 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -16,8 +16,8 @@
import static java.util.stream.Collectors.toList;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.index.query.Predicate;
import com.google.gerrit.server.index.change.ChangeField;
import java.util.Set;
diff --git a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index c507f1c..2018fbc 100644
--- a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.query.change;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.server.index.change.ChangeField;
public class SubmittablePredicate extends ChangeIndexPredicate {
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index f29c6e6..ec82e1a 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -15,7 +15,6 @@
package com.google.gerrit.server.restapi.account;
import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
@@ -40,8 +39,6 @@
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.account.AccountResource;
import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -51,7 +48,6 @@
import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdate.Factory;
-import com.google.gerrit.server.update.BatchUpdateListener;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.UpdateException;
@@ -80,7 +76,6 @@
private final Provider<CommentJson> commentJsonProvider;
private final CommentsUtil commentsUtil;
private final PatchSetUtil psUtil;
- private final PatchListCache patchListCache;
@Inject
DeleteDraftComments(
@@ -92,8 +87,7 @@
ChangeJson.Factory changeJsonFactory,
Provider<CommentJson> commentJsonProvider,
CommentsUtil commentsUtil,
- PatchSetUtil psUtil,
- PatchListCache patchListCache) {
+ PatchSetUtil psUtil) {
this.userProvider = userProvider;
this.batchUpdateFactory = batchUpdateFactory;
this.queryBuilderProvider = queryBuilderProvider;
@@ -103,7 +97,6 @@
this.commentJsonProvider = commentJsonProvider;
this.commentsUtil = commentsUtil;
this.psUtil = psUtil;
- this.patchListCache = patchListCache;
}
@Override
@@ -146,7 +139,7 @@
// Currently there's no way to let some updates succeed even if others fail. Even if there were,
// all updates from this operation only happen in All-Users and thus are fully atomic, so
// allowing partial failure would have little value.
- BatchUpdate.execute(updates.values(), BatchUpdateListener.NONE, false);
+ BatchUpdate.execute(updates.values(), ImmutableList.of(), false);
return Response.ok(
ops.stream().map(Op::getResult).filter(Objects::nonNull).collect(toImmutableList()));
@@ -176,14 +169,13 @@
}
@Override
- public boolean updateChange(ChangeContext ctx)
- throws PatchListNotAvailableException, PermissionBackendException {
+ public boolean updateChange(ChangeContext ctx) throws PermissionBackendException {
ImmutableList.Builder<CommentInfo> comments = ImmutableList.builder();
boolean dirty = false;
for (HumanComment c : commentsUtil.draftByChangeAuthor(ctx.getNotes(), accountId)) {
dirty = true;
PatchSet.Id psId = PatchSet.id(ctx.getChange().getId(), c.key.patchSetId);
- setCommentCommitId(c, patchListCache, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
+ commentsUtil.setCommentCommitId(c, ctx.getChange(), psUtil.get(ctx.getNotes(), psId));
commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
comments.add(humanCommentFormatter.format(c));
}
diff --git a/java/com/google/gerrit/server/restapi/account/GetAgreements.java b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
index c11fc7e..db6ad48 100644
--- a/java/com/google/gerrit/server/restapi/account/GetAgreements.java
+++ b/java/com/google/gerrit/server/restapi/account/GetAgreements.java
@@ -15,10 +15,10 @@
package com.google.gerrit.server.restapi.account;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.ContributorAgreement;
+import com.google.gerrit.entities.PermissionRule;
+import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.extensions.common.AgreementInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
diff --git a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
index f3d9557..6ab2c44 100644
--- a/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
+++ b/java/com/google/gerrit/server/restapi/account/GetCapabilities.java
@@ -21,7 +21,7 @@
import com.google.common.collect.Iterables;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.entities.PermissionRange;
import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
import com.google.gerrit.extensions.api.access.PluginPermission;
import com.google.gerrit.extensions.config.CapabilityDefinition;
diff --git a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
index c80bf57..5979b2a 100644
--- a/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
+++ b/java/com/google/gerrit/server/restapi/account/PostWatchedProjects.java
@@ -91,7 +91,7 @@
throws RestApiException, IOException, PermissionBackendException {
Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
for (ProjectWatchInfo info : input) {
- if (info.project == null) {
+ if (info.project == null || info.project.trim().isEmpty()) {
throw new BadRequestException("project name must be specified");
}
diff --git a/java/com/google/gerrit/server/restapi/account/PutAgreement.java b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
index ce6f287..47c223c 100644
--- a/java/com/google/gerrit/server/restapi/account/PutAgreement.java
+++ b/java/com/google/gerrit/server/restapi/account/PutAgreement.java
@@ -16,8 +16,8 @@
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.ContributorAgreement;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.ContributorAgreement;
import com.google.gerrit.exceptions.NoSuchGroupException;
import com.google.gerrit.extensions.api.accounts.AgreementInput;
import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
index a8b65bd..cb1256c 100644
--- a/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AddToAttentionSet.java
@@ -16,15 +16,19 @@
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.change.AddToAttentionSetOp;
import com.google.gerrit.server.change.AttentionSetEntryResource;
import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.update.BatchUpdate;
@@ -38,11 +42,14 @@
public class AddToAttentionSet
implements RestCollectionModifyView<
ChangeResource, AttentionSetEntryResource, AttentionSetInput> {
+
private final BatchUpdate.Factory updateFactory;
private final AccountResolver accountResolver;
private final AddToAttentionSetOp.Factory opFactory;
private final AccountLoader.Factory accountLoaderFactory;
private final PermissionBackend permissionBackend;
+ private final NotifyResolver notifyResolver;
+ private final ServiceUserClassifier serviceUserClassifier;
@Inject
AddToAttentionSet(
@@ -50,20 +57,30 @@
AccountResolver accountResolver,
AddToAttentionSetOp.Factory opFactory,
AccountLoader.Factory accountLoaderFactory,
- PermissionBackend permissionBackend) {
+ PermissionBackend permissionBackend,
+ NotifyResolver notifyResolver,
+ ServiceUserClassifier serviceUserClassifier) {
this.updateFactory = updateFactory;
this.accountResolver = accountResolver;
this.opFactory = opFactory;
this.accountLoaderFactory = accountLoaderFactory;
this.permissionBackend = permissionBackend;
+ this.notifyResolver = notifyResolver;
+ this.serviceUserClassifier = serviceUserClassifier;
}
@Override
public Response<AccountInfo> apply(ChangeResource changeResource, AttentionSetInput input)
throws Exception {
AttentionSetUtil.validateInput(input);
+ Account.Id attentionUserId =
+ AttentionSetUtil.resolveAccount(accountResolver, changeResource.getNotes(), input.user);
- Account.Id attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
+ if (serviceUserClassifier.isServiceUser(attentionUserId)) {
+ throw new BadRequestException(
+ String.format(
+ "%s is a robot, and robots can't be added to the attention set.", input.user));
+ }
try {
permissionBackend
.absentUser(attentionUserId)
@@ -76,8 +93,11 @@
try (BatchUpdate bu =
updateFactory.create(
changeResource.getChange().getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
- AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason);
+ AddToAttentionSetOp op = opFactory.create(attentionUserId, input.reason, true);
bu.addOp(changeResource.getId(), op);
+ NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
+ NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
+ bu.setNotify(notifyResult);
bu.execute();
return Response.ok(accountLoaderFactory.create(true).fillOne(attentionUserId));
}
diff --git a/java/com/google/gerrit/server/restapi/change/ApplyFix.java b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
index 40e4fc2..9224154 100644
--- a/java/com/google/gerrit/server/restapi/change/ApplyFix.java
+++ b/java/com/google/gerrit/server/restapi/change/ApplyFix.java
@@ -30,7 +30,7 @@
import com.google.gerrit.server.edit.ChangeEdit;
import com.google.gerrit.server.edit.ChangeEditJson;
import com.google.gerrit.server.edit.ChangeEditModifier;
-import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.edit.CommitModification;
import com.google.gerrit.server.fixes.FixReplacementInterpreter;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -40,7 +40,6 @@
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
-import java.util.List;
import org.eclipse.jgit.lib.Repository;
@Singleton
@@ -76,12 +75,12 @@
PatchSet patchSet = revisionResource.getPatchSet();
try (Repository repository = gitRepositoryManager.openRepository(project)) {
- List<TreeModification> treeModifications =
- fixReplacementInterpreter.toTreeModifications(
+ CommitModification commitModification =
+ fixReplacementInterpreter.toCommitModification(
repository, projectState, patchSet.commitId(), fixResource.getFixReplacements());
ChangeEdit changeEdit =
changeEditModifier.combineWithModifiedPatchSetTree(
- repository, revisionResource.getNotes(), patchSet, treeModifications);
+ repository, revisionResource.getNotes(), patchSet, commitModification);
return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
} catch (InvalidChangeOperationException e) {
throw new ResourceConflictException(e.getMessage());
diff --git a/java/com/google/gerrit/server/restapi/change/AttentionSet.java b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
index 45d78dc..f72fe64ec 100644
--- a/java/com/google/gerrit/server/restapi/change/AttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/AttentionSet.java
@@ -17,14 +17,15 @@
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ChildCollection;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestView;
import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountResolver.UnresolvableAccountException;
import com.google.gerrit.server.change.AttentionSetEntryResource;
import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
@@ -58,12 +59,10 @@
@Override
public AttentionSetEntryResource parse(ChangeResource changeResource, IdString idString)
- throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException {
- try {
- Account.Id accountId = accountResolver.resolve(idString.get()).asUnique().account().id();
- return new AttentionSetEntryResource(changeResource, accountId);
- } catch (UnresolvableAccountException e) {
- throw new ResourceNotFoundException(idString, e);
- }
+ throws ResourceNotFoundException, AuthException, IOException, ConfigInvalidException,
+ BadRequestException {
+ Account.Id accountId =
+ AttentionSetUtil.resolveAccount(accountResolver, changeResource.getNotes(), idString.get());
+ return new AttentionSetEntryResource(changeResource, accountId);
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
index 9a25f52..7a15a1d 100644
--- a/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeEdits.java
@@ -56,7 +56,6 @@
import com.google.gerrit.server.edit.ChangeEditJson;
import com.google.gerrit.server.edit.ChangeEditModifier;
import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.edit.UnchangedCommitMessageException;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -482,7 +481,7 @@
Project.NameKey project = rsrc.getProject();
try (Repository repository = repositoryManager.openRepository(project)) {
editModifier.modifyMessage(repository, rsrc.getNotes(), input.message);
- } catch (UnchangedCommitMessageException e) {
+ } catch (InvalidChangeOperationException e) {
throw new ResourceConflictException(e.getMessage());
}
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index 44dc6e1..fdac552 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -20,7 +20,6 @@
import com.google.auto.value.AutoValue;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.FooterConstants;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
@@ -29,6 +28,7 @@
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.CherryPickInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.MergeConflictException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -43,6 +43,7 @@
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.PatchSetInserter;
import com.google.gerrit.server.change.SetCherryPickOp;
+import com.google.gerrit.server.config.UrlFormatter;
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
import com.google.gerrit.server.git.GitRepositoryManager;
@@ -112,6 +113,7 @@
private final ApprovalsUtil approvalsUtil;
private final NotifyResolver notifyResolver;
private final BatchUpdate.Factory batchUpdateFactory;
+ private final DynamicItem<UrlFormatter> urlFormatter;
@Inject
CherryPickChange(
@@ -128,7 +130,8 @@
ProjectCache projectCache,
ApprovalsUtil approvalsUtil,
NotifyResolver notifyResolver,
- BatchUpdate.Factory batchUpdateFactory) {
+ BatchUpdate.Factory batchUpdateFactory,
+ DynamicItem<UrlFormatter> urlFormatter) {
this.seq = seq;
this.queryProvider = queryProvider;
this.gitManager = gitManager;
@@ -143,6 +146,7 @@
this.approvalsUtil = approvalsUtil;
this.notifyResolver = notifyResolver;
this.batchUpdateFactory = batchUpdateFactory;
+ this.urlFormatter = urlFormatter;
}
/**
@@ -173,6 +177,7 @@
TimeUtil.nowTs(),
null,
null,
+ null,
null);
}
@@ -204,7 +209,7 @@
throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
ConfigInvalidException, NoSuchProjectException {
return cherryPick(
- sourceChange, project, sourceCommit, input, dest, TimeUtil.nowTs(), null, null, null);
+ sourceChange, project, sourceCommit, input, dest, TimeUtil.nowTs(), null, null, null, null);
}
/**
@@ -245,7 +250,8 @@
Timestamp timestamp,
@Nullable Change.Id revertedChange,
@Nullable ObjectId changeIdForNewChange,
- @Nullable Change.Id idForNewChange)
+ @Nullable Change.Id idForNewChange,
+ @Nullable Boolean workInProgress)
throws IOException, InvalidChangeOperationException, UpdateException, RestApiException,
ConfigInvalidException, NoSuchProjectException {
@@ -312,7 +318,8 @@
input.allowConflicts);
Change.Key changeKey;
- final List<String> idList = cherryPickCommit.getFooterLines(FooterConstants.CHANGE_ID);
+ final List<String> idList =
+ ChangeUtil.getChangeIdsFromFooter(cherryPickCommit, urlFormatter.get());
if (!idList.isEmpty()) {
final String idStr = idList.get(idList.size() - 1).trim();
changeKey = Change.key(idStr);
@@ -365,7 +372,8 @@
destChanges.get(0).notes(),
cherryPickCommit,
sourceChange.currentPatchSetId(),
- newTopic);
+ newTopic,
+ workInProgress);
} else {
// Change key not found on destination branch. We can create a new
// change.
@@ -380,7 +388,8 @@
sourceCommit,
input,
revertedChange,
- idForNewChange);
+ idForNewChange,
+ workInProgress);
}
bu.execute();
return Result.create(changeId, cherryPickCommit.getFilesWithGitConflicts());
@@ -448,13 +457,17 @@
ChangeNotes destNotes,
CodeReviewCommit cherryPickCommit,
PatchSet.Id sourcePatchSetId,
- String topic)
+ String topic,
+ @Nullable Boolean workInProgress)
throws IOException {
Change destChange = destNotes.getChange();
PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit);
inserter.setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".");
inserter.setTopic(topic);
+ if (workInProgress != null) {
+ inserter.setWorkInProgress(workInProgress);
+ }
bu.addOp(destChange.getId(), inserter);
if (destChange.getCherryPickOf() == null
|| !destChange.getCherryPickOf().equals(sourcePatchSetId)) {
@@ -474,11 +487,19 @@
@Nullable ObjectId sourceCommit,
CherryPickInput input,
@Nullable Change.Id revertOf,
- @Nullable Change.Id idForNewChange)
+ @Nullable Change.Id idForNewChange,
+ @Nullable Boolean workInProgress)
throws IOException, InvalidChangeOperationException {
Change.Id changeId = idForNewChange != null ? idForNewChange : Change.id(seq.nextChangeId());
ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
ins.setRevertOf(revertOf);
+ if (workInProgress != null) {
+ ins.setWorkInProgress(workInProgress);
+ } else {
+ ins.setWorkInProgress(
+ (sourceChange != null && sourceChange.isWorkInProgress())
+ || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
+ }
BranchNameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
ins.setMessage(
@@ -488,10 +509,7 @@
: "Uploaded patch set 1.") // For revert commits, the message should not include
// cherry-pick information.
.setTopic(topic)
- .setCherryPickOf(sourcePatchSetId)
- .setWorkInProgress(
- (sourceChange != null && sourceChange.isWorkInProgress())
- || !cherryPickCommit.getFilesWithGitConflicts().isEmpty());
+ .setCherryPickOf(sourcePatchSetId);
if (input.keepReviewers && sourceChange != null) {
ReviewerSet reviewerSet =
approvalsUtil.getReviewers(changeNotesFactory.createChecked(sourceChange));
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index 20c4b48..cc8ad47 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -20,24 +20,32 @@
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Streams;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.CommentContext;
import com.google.gerrit.entities.FixReplacement;
import com.google.gerrit.entities.FixSuggestion;
import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RobotComment;
import com.google.gerrit.extensions.client.Comment.Range;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
import com.google.gerrit.extensions.common.FixReplacementInfo;
import com.google.gerrit.extensions.common.FixSuggestionInfo;
import com.google.gerrit.extensions.common.RobotCommentInfo;
import com.google.gerrit.extensions.restapi.Url;
import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.comment.CommentContextCache;
+import com.google.gerrit.server.comment.CommentContextKey;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.inject.Inject;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
@@ -45,13 +53,19 @@
public class CommentJson {
private final AccountLoader.Factory accountLoaderFactory;
+ private final CommentContextCache commentContextCache;
+
+ private Project.NameKey project;
+ private Change.Id changeId;
private boolean fillAccounts = true;
private boolean fillPatchSet;
+ private boolean fillCommentContext;
@Inject
- CommentJson(AccountLoader.Factory accountLoaderFactory) {
+ CommentJson(AccountLoader.Factory accountLoaderFactory, CommentContextCache commentContextCache) {
this.accountLoaderFactory = accountLoaderFactory;
+ this.commentContextCache = commentContextCache;
}
CommentJson setFillAccounts(boolean fillAccounts) {
@@ -64,6 +78,21 @@
return this;
}
+ CommentJson setFillCommentContext(boolean fillCommentContext) {
+ this.fillCommentContext = fillCommentContext;
+ return this;
+ }
+
+ CommentJson setProjectKey(Project.NameKey project) {
+ this.project = project;
+ return this;
+ }
+
+ CommentJson setChangeId(Change.Id changeId) {
+ this.changeId = changeId;
+ return this;
+ }
+
public HumanCommentFormatter newHumanCommentFormatter() {
return new HumanCommentFormatter();
}
@@ -103,6 +132,11 @@
if (loader != null) {
loader.fill();
}
+
+ if (fillCommentContext) {
+ List<T> allComments = out.values().stream().flatMap(Collection::stream).collect(toList());
+ addCommentContext(allComments);
+ }
return out;
}
@@ -118,9 +152,41 @@
if (loader != null) {
loader.fill();
}
+
+ if (fillCommentContext) {
+ addCommentContext(out);
+ }
+
return out;
}
+ protected void addCommentContext(List<T> allComments) {
+ List<CommentContextKey> keys =
+ allComments.stream().map(this::createCommentContextKey).collect(toList());
+ ImmutableMap<CommentContextKey, CommentContext> allContext = commentContextCache.getAll(keys);
+ for (T c : allComments) {
+ c.contextLines = toContextLineInfoList(allContext.get(createCommentContextKey(c)));
+ }
+ }
+
+ protected List<ContextLineInfo> toContextLineInfoList(CommentContext commentContext) {
+ List<ContextLineInfo> result = new ArrayList<>();
+ for (Map.Entry<Integer, String> e : commentContext.lines().entrySet()) {
+ result.add(new ContextLineInfo(e.getKey(), e.getValue()));
+ }
+ return result;
+ }
+
+ protected CommentContextKey createCommentContextKey(T r) {
+ return CommentContextKey.builder()
+ .project(project)
+ .changeId(changeId)
+ .id(r.id)
+ .path(r.path)
+ .patchset(r.patchSet)
+ .build();
+ }
+
protected abstract T toInfo(F comment, AccountLoader loader);
protected void fillCommentInfo(Comment c, CommentInfo r, AccountLoader loader) {
@@ -143,7 +209,6 @@
r.updated = c.writtenOn;
r.range = toRange(c.range);
r.tag = c.tag;
- r.unresolved = c.unresolved;
if (loader != null) {
r.author = loader.get(c.author.getId());
}
@@ -168,6 +233,7 @@
protected CommentInfo toInfo(HumanComment c, AccountLoader loader) {
CommentInfo ci = new CommentInfo();
fillCommentInfo(c, ci, loader);
+ ci.unresolved = c.unresolved;
return ci;
}
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
new file mode 100644
index 0000000..681509c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -0,0 +1,335 @@
+// 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.restapi.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment.Range;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+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.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.DiffMappings;
+import com.google.gerrit.server.patch.GitPositionTransformer;
+import com.google.gerrit.server.patch.GitPositionTransformer.BestPositionOnConflict;
+import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
+import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
+import com.google.gerrit.server.patch.GitPositionTransformer.Position;
+import com.google.gerrit.server.patch.GitPositionTransformer.PositionedEntity;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Container for all logic necessary to port comments to a target patchset.
+ *
+ * <p>A ported comment is a comment which was left on an earlier patchset and is shown on a later
+ * patchset. If a comment eligible for porting (e.g. before target patchset) can't be matched to its
+ * exact position in the target patchset, we'll map it to its next best location. This can also
+ * include a transformation of a line comment into a file comment.
+ */
+@Singleton
+public class CommentPorter {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final GitPositionTransformer positionTransformer =
+ new GitPositionTransformer(BestPositionOnConflict.INSTANCE);
+ private final PatchListCache patchListCache;
+ private final CommentsUtil commentsUtil;
+
+ @Inject
+ public CommentPorter(PatchListCache patchListCache, CommentsUtil commentsUtil) {
+ this.patchListCache = patchListCache;
+ this.commentsUtil = commentsUtil;
+ }
+
+ /**
+ * Ports the given comments to the target patchset.
+ *
+ * <p>Not all given comments are ported. Only those fulfilling some criteria (e.g. before target
+ * patchset) are considered eligible for porting.
+ *
+ * <p>The returned comments represent the ported version. They don't bear any indication to which
+ * patchset they were ported. This is intentional as the target patchset should be obvious from
+ * the API or the used REST resources. The returned comments still have the patchset field filled.
+ * It contains the reference to the patchset on which the comment was originally left. That
+ * patchset number can vary among the returned comments as all comments before the target patchset
+ * are potentially eligible for porting.
+ *
+ * <p>The number of returned comments can be smaller (-> only eligible ones are ported!) or larger
+ * compared to the provided comments. The latter happens when files appear as copied in the target
+ * patchset. In such a situation, the same comment UUID will occur more than once in the returned
+ * comments.
+ *
+ * @param changeNotes the {@link ChangeNotes} of the change to which the comments belong
+ * @param targetPatchset the patchset to which the comments should be ported
+ * @param comments the original comments
+ * @param filters additional filters to apply to the comments before porting. Only the remaining
+ * comments will be ported.
+ * @return the ported comments, in no particular order
+ */
+ public ImmutableList<HumanComment> portComments(
+ ChangeNotes changeNotes,
+ PatchSet targetPatchset,
+ List<HumanComment> comments,
+ List<HumanCommentFilter> filters) {
+
+ ImmutableList<HumanCommentFilter> allFilters = addDefaultFilters(filters, targetPatchset);
+ ImmutableList<HumanComment> relevantComments = filter(comments, allFilters);
+ return port(changeNotes, targetPatchset, relevantComments);
+ }
+
+ private ImmutableList<HumanCommentFilter> addDefaultFilters(
+ List<HumanCommentFilter> filters, PatchSet targetPatchset) {
+ // Apply the EarlierPatchsetCommentFilter first as it reduces the amount of comments before
+ // more expensive filters are applied.
+ HumanCommentFilter earlierPatchsetFilter =
+ new EarlierPatchsetCommentFilter(targetPatchset.id());
+ return Stream.concat(Stream.of(earlierPatchsetFilter), filters.stream())
+ .collect(toImmutableList());
+ }
+
+ private ImmutableList<HumanComment> filter(
+ List<HumanComment> allComments, ImmutableList<HumanCommentFilter> filters) {
+ ImmutableList<HumanComment> filteredComments = ImmutableList.copyOf(allComments);
+ for (HumanCommentFilter filter : filters) {
+ filteredComments = filter.filter(filteredComments);
+ }
+ return filteredComments;
+ }
+
+ private ImmutableList<HumanComment> port(
+ ChangeNotes notes, PatchSet targetPatchset, List<HumanComment> comments) {
+ Map<Integer, ImmutableList<HumanComment>> commentsPerPatchset =
+ comments.stream().collect(groupingBy(comment -> comment.key.patchSetId, toImmutableList()));
+
+ ImmutableList.Builder<HumanComment> portedComments =
+ ImmutableList.builderWithExpectedSize(comments.size());
+ for (Integer originalPatchsetId : commentsPerPatchset.keySet()) {
+ ImmutableList<HumanComment> patchsetComments = commentsPerPatchset.get(originalPatchsetId);
+ PatchSet originalPatchset =
+ notes.getPatchSets().get(PatchSet.id(notes.getChangeId(), originalPatchsetId));
+ if (originalPatchset != null) {
+ portedComments.addAll(
+ portSamePatchset(
+ notes.getProjectName(),
+ notes.getChange(),
+ originalPatchset,
+ targetPatchset,
+ patchsetComments));
+ } else {
+ logger.atWarning().log(
+ String.format(
+ "Some comments which should be ported refer to the non-existent patchset %s of"
+ + " change %d. Omitting %d affected comments.",
+ originalPatchsetId, notes.getChangeId().get(), patchsetComments.size()));
+ }
+ }
+ return portedComments.build();
+ }
+
+ private ImmutableList<HumanComment> portSamePatchset(
+ Project.NameKey project,
+ Change change,
+ PatchSet originalPatchset,
+ PatchSet targetPatchset,
+ ImmutableList<HumanComment> comments) {
+ Map<Short, List<HumanComment>> commentsPerSide =
+ comments.stream().collect(groupingBy(comment -> comment.side));
+ ImmutableList.Builder<HumanComment> portedComments = ImmutableList.builder();
+ for (Entry<Short, List<HumanComment>> sideAndComments : commentsPerSide.entrySet()) {
+ portedComments.addAll(
+ portSamePatchsetAndSide(
+ project,
+ change,
+ originalPatchset,
+ targetPatchset,
+ sideAndComments.getValue(),
+ sideAndComments.getKey()));
+ }
+ return portedComments.build();
+ }
+
+ private ImmutableList<HumanComment> portSamePatchsetAndSide(
+ Project.NameKey project,
+ Change change,
+ PatchSet originalPatchset,
+ PatchSet targetPatchset,
+ List<HumanComment> comments,
+ short side) {
+ ImmutableSet<Mapping> mappings;
+ try {
+ mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
+ } catch (Exception e) {
+ logger.atWarning().withCause(e).log(
+ "Could not determine some necessary diff mappings for porting comments on change %s from"
+ + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
+ + " destination.",
+ change.getChangeId(),
+ originalPatchset.id().getId(),
+ targetPatchset.id().getId(),
+ comments.size());
+ mappings = getFallbackMappings(comments);
+ }
+
+ ImmutableList<PositionedEntity<HumanComment>> positionedComments =
+ comments.stream().map(this::toPositionedEntity).collect(toImmutableList());
+ return positionTransformer.transform(positionedComments, mappings).stream()
+ .map(PositionedEntity::getEntityAtUpdatedPosition)
+ .collect(toImmutableList());
+ }
+
+ private ImmutableSet<Mapping> loadMappings(
+ Project.NameKey project,
+ Change change,
+ PatchSet originalPatchset,
+ PatchSet targetPatchset,
+ short side)
+ throws PatchListNotAvailableException {
+ ObjectId originalCommit = determineCommitId(change, originalPatchset, side);
+ ObjectId targetCommit = determineCommitId(change, targetPatchset, side);
+ return loadCommitMappings(project, originalCommit, targetCommit);
+ }
+
+ private ObjectId determineCommitId(Change change, PatchSet patchset, short side) {
+ return commentsUtil
+ .determineCommitId(change, patchset, side)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format(
+ "Commit indicated by change %d, patchset %d, side %d doesn't exist.",
+ change.getId().get(), patchset.id().get(), side)));
+ }
+
+ private ImmutableSet<Mapping> loadCommitMappings(
+ Project.NameKey project, ObjectId originalCommit, ObjectId targetCommit)
+ throws PatchListNotAvailableException {
+ PatchList patchList =
+ patchListCache.get(
+ PatchListKey.againstCommit(originalCommit, targetCommit, Whitespace.IGNORE_NONE),
+ project);
+ return patchList.getPatches().stream().map(DiffMappings::toMapping).collect(toImmutableSet());
+ }
+
+ private ImmutableSet<Mapping> getFallbackMappings(List<HumanComment> comments) {
+ // Consider all files as deleted. -> Comments will be ported to the fallback destination, which
+ // currently are patchset-level comments.
+ return comments.stream()
+ .map(comment -> comment.key.filename)
+ .distinct()
+ .map(FileMapping::forDeletedFile)
+ .map(fileMapping -> Mapping.create(fileMapping, ImmutableSet.of()))
+ .collect(toImmutableSet());
+ }
+
+ private PositionedEntity<HumanComment> toPositionedEntity(HumanComment comment) {
+ return PositionedEntity.create(
+ comment, CommentPorter::extractPosition, CommentPorter::createCommentAtNewPosition);
+ }
+
+ private static Position extractPosition(HumanComment comment) {
+ Position.Builder positionBuilder = Position.builder();
+ // Patchset-level comments don't have a file path. The transformation logic still works when
+ // using the magic file path but it doesn't hurt to use the actual representation for "no file"
+ // internally.
+ if (!Patch.PATCHSET_LEVEL.equals(comment.key.filename)) {
+ positionBuilder.filePath(comment.key.filename);
+ }
+ return positionBuilder.lineRange(extractLineRange(comment)).build();
+ }
+
+ private static Optional<GitPositionTransformer.Range> extractLineRange(HumanComment comment) {
+ // Line specifications in comment are 1-based. Line specifications in Position are 0-based.
+ if (comment.range != null) {
+ // The combination of (line, charOffset) is exclusive and must be mapped to an exclusive line.
+ int exclusiveEndLine =
+ comment.range.endChar > 0 ? comment.range.endLine : comment.range.endLine - 1;
+ return Optional.of(
+ GitPositionTransformer.Range.create(comment.range.startLine - 1, exclusiveEndLine));
+ }
+ if (comment.lineNbr > 0) {
+ return Optional.of(GitPositionTransformer.Range.create(comment.lineNbr - 1, comment.lineNbr));
+ }
+ // File comment -> no range.
+ return Optional.empty();
+ }
+
+ private static HumanComment createCommentAtNewPosition(
+ HumanComment originalComment, Position newPosition) {
+ HumanComment portedComment = new HumanComment(originalComment);
+ portedComment.key.filename = newPosition.filePath().orElse(Patch.PATCHSET_LEVEL);
+ if (portedComment.range != null && newPosition.lineRange().isPresent()) {
+ // Comment was a range comment and also stayed one.
+ portedComment.range =
+ toRange(
+ newPosition.lineRange().get(),
+ portedComment.range.startChar,
+ portedComment.range.endChar);
+ portedComment.lineNbr = portedComment.range.endLine;
+ } else {
+ portedComment.range = null;
+ // No line -> use 0 = file comment or any other comment type without an explicit line.
+ portedComment.lineNbr = newPosition.lineRange().map(range -> range.start() + 1).orElse(0);
+ }
+ if (Patch.PATCHSET_LEVEL.equals(portedComment.key.filename)) {
+ // Correct the side of the comment to Side.REVISION (= 1) if the comment was changed to
+ // patchset level.
+ portedComment.side = 1;
+ }
+ return portedComment;
+ }
+
+ private static Range toRange(
+ GitPositionTransformer.Range lineRange, int originalStartChar, int originalEndChar) {
+ int adjustedEndLine = originalEndChar > 0 ? lineRange.end() : lineRange.end() + 1;
+ return new Range(lineRange.start() + 1, originalStartChar, adjustedEndLine, originalEndChar);
+ }
+
+ /** A filter which just keeps those comments which are before the given patchset. */
+ private static class EarlierPatchsetCommentFilter implements HumanCommentFilter {
+
+ private final PatchSet.Id patchsetId;
+
+ public EarlierPatchsetCommentFilter(PatchSet.Id patchsetId) {
+ this.patchsetId = patchsetId;
+ }
+
+ @Override
+ public ImmutableList<HumanComment> filter(ImmutableList<HumanComment> comments) {
+ return comments.stream()
+ .filter(comment -> comment.key.patchSetId < patchsetId.get())
+ .collect(toImmutableList());
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
index d99d7014..8476767 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateDraftComment.java
@@ -15,7 +15,6 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
import com.google.common.base.Strings;
import com.google.gerrit.entities.HumanComment;
@@ -32,8 +31,6 @@
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
@@ -51,20 +48,17 @@
private final Provider<CommentJson> commentJson;
private final CommentsUtil commentsUtil;
private final PatchSetUtil psUtil;
- private final PatchListCache patchListCache;
@Inject
CreateDraftComment(
BatchUpdate.Factory updateFactory,
Provider<CommentJson> commentJson,
CommentsUtil commentsUtil,
- PatchSetUtil psUtil,
- PatchListCache patchListCache) {
+ PatchSetUtil psUtil) {
this.updateFactory = updateFactory;
this.commentJson = commentJson;
this.commentsUtil = commentsUtil;
this.psUtil = psUtil;
- this.patchListCache = patchListCache;
}
@Override
@@ -81,6 +75,11 @@
throw new BadRequestException("line must be >= 0");
} else if (in.line != null && in.range != null && in.line != in.range.endLine) {
throw new BadRequestException("range endLine must be on the same line as the comment");
+ } else if (in.inReplyTo != null
+ && !commentsUtil.getPublishedHumanComment(rsrc.getNotes(), in.inReplyTo).isPresent()
+ && !commentsUtil.getRobotComment(rsrc.getNotes(), in.inReplyTo).isPresent()) {
+ throw new BadRequestException(
+ String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
}
try (BatchUpdate bu =
@@ -106,8 +105,7 @@
@Override
public boolean updateChange(ChangeContext ctx)
- throws ResourceNotFoundException, UnprocessableEntityException,
- PatchListNotAvailableException {
+ throws ResourceNotFoundException, UnprocessableEntityException {
PatchSet ps = psUtil.get(ctx.getNotes(), psId);
if (ps == null) {
throw new ResourceNotFoundException("patch set not found: " + psId);
@@ -116,11 +114,19 @@
comment =
commentsUtil.newHumanComment(
- ctx, in.path, ps.id(), in.side(), in.message.trim(), in.unresolved, parentUuid);
+ ctx.getNotes(),
+ ctx.getUser(),
+ ctx.getWhen(),
+ in.path,
+ ps.id(),
+ in.side(),
+ in.message.trim(),
+ in.unresolved,
+ parentUuid);
comment.setLineNbrAndRange(in.line, in.range);
comment.tag = in.tag;
- setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+ commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
commentsUtil.putHumanComments(
ctx.getUpdate(psId), HumanComment.Status.DRAFT, Collections.singleton(comment));
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index 8ac2140..af4bf69 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -55,6 +55,7 @@
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.restapi.project.CommitsCollection;
@@ -128,6 +129,13 @@
psUtil.checkPatchSetNotLocked(rsrc.getNotes());
rsrc.permissions().check(ChangePermission.ADD_PATCH_SET);
+ if (in.author != null) {
+ permissionBackend
+ .currentUser()
+ .project(rsrc.getProject())
+ .ref(rsrc.getChange().getDest().branch())
+ .check(RefPermission.FORGE_AUTHOR);
+ }
ProjectState projectState =
projectCache.get(rsrc.getProject()).orElseThrow(illegalState(rsrc.getProject()));
@@ -137,6 +145,10 @@
if (merge == null || Strings.isNullOrEmpty(merge.source)) {
throw new BadRequestException("merge.source must be non-empty");
}
+ if (in.author != null
+ && (Strings.isNullOrEmpty(in.author.email) || Strings.isNullOrEmpty(in.author.name))) {
+ throw new BadRequestException("Author must specify name and email");
+ }
in.baseChange = Strings.nullToEmpty(in.baseChange).trim();
PatchSet ps = psUtil.current(rsrc.getNotes());
@@ -166,7 +178,10 @@
Timestamp now = TimeUtil.nowTs();
IdentifiedUser me = user.get().asIdentifiedUser();
- PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
+ PersonIdent author =
+ in.author == null
+ ? me.newCommitterIdent(now, serverTimeZone)
+ : new PersonIdent(in.author.name, in.author.email, now, serverTimeZone);
CodeReviewCommit newCommit =
createMergeCommit(
in,
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
index 71fd4d2..51a0b8e 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteDraftComment.java
@@ -14,8 +14,6 @@
package com.google.gerrit.server.restapi.change;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
-
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.PatchSet;
@@ -28,8 +26,6 @@
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.DraftCommentResource;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
@@ -45,18 +41,13 @@
private final BatchUpdate.Factory updateFactory;
private final CommentsUtil commentsUtil;
private final PatchSetUtil psUtil;
- private final PatchListCache patchListCache;
@Inject
DeleteDraftComment(
- BatchUpdate.Factory updateFactory,
- CommentsUtil commentsUtil,
- PatchSetUtil psUtil,
- PatchListCache patchListCache) {
+ BatchUpdate.Factory updateFactory, CommentsUtil commentsUtil, PatchSetUtil psUtil) {
this.updateFactory = updateFactory;
this.commentsUtil = commentsUtil;
this.psUtil = psUtil;
- this.patchListCache = patchListCache;
}
@Override
@@ -79,8 +70,7 @@
}
@Override
- public boolean updateChange(ChangeContext ctx)
- throws ResourceNotFoundException, PatchListNotAvailableException {
+ public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException {
Optional<HumanComment> maybeComment =
commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
if (!maybeComment.isPresent()) {
@@ -92,7 +82,7 @@
throw new ResourceNotFoundException("patch set not found: " + psId);
}
HumanComment c = maybeComment.get();
- setCommentCommitId(c, patchListCache, ctx.getChange(), ps);
+ commentsUtil.setCommentCommitId(c, ctx.getChange(), ps);
commentsUtil.deleteHumanComments(ctx.getUpdate(psId), Collections.singleton(c));
return true;
}
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index f88be81..4b813df 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -19,10 +19,10 @@
import static java.util.Objects.requireNonNull;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index c28741b..1ef3c4b 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -15,7 +15,9 @@
package com.google.gerrit.server.restapi.change;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.client.ListOption;
import com.google.gerrit.extensions.common.ChangeInfo;
@@ -27,11 +29,13 @@
import com.google.gerrit.server.DynamicOptions.DynamicBean;
import com.google.gerrit.server.change.ChangeAttributeFactory;
import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.change.ChangePluginDefinedInfoFactory;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.PluginDefinedAttributesFactories;
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
+import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Map;
@@ -43,6 +47,7 @@
DynamicOptions.BeanProvider {
private final ChangeJson.Factory json;
private final DynamicSet<ChangeAttributeFactory> attrFactories;
+ private final DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories;
private final EnumSet<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
private final Map<String, DynamicBean> dynamicBeans = new HashMap<>();
@@ -57,9 +62,13 @@
}
@Inject
- GetChange(ChangeJson.Factory json, DynamicSet<ChangeAttributeFactory> attrFactories) {
+ GetChange(
+ ChangeJson.Factory json,
+ DynamicSet<ChangeAttributeFactory> attrFactories,
+ DynamicSet<ChangePluginDefinedInfoFactory> pdiFactories) {
this.json = json;
this.attrFactories = attrFactories;
+ this.pdiFactories = pdiFactories;
}
@Override
@@ -82,11 +91,17 @@
}
private ChangeJson newChangeJson() {
- return json.create(options, this::buildPluginInfo);
+ return json.create(options, this::buildPluginInfo, this::createPluginDefinedInfos);
}
private ImmutableList<PluginDefinedInfo> buildPluginInfo(ChangeData cd) {
return PluginDefinedAttributesFactories.createAll(
cd, this, Streams.stream(attrFactories.entries()));
}
+
+ private ImmutableListMultimap<Change.Id, PluginDefinedInfo> createPluginDefinedInfos(
+ Collection<ChangeData> cds) {
+ return PluginDefinedAttributesFactories.createAll(
+ cds, this, Streams.stream(pdiFactories.entries()));
+ }
}
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index f4e2ddd..8d51786 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -15,7 +15,6 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import static com.google.gerrit.util.cli.Localizable.localizable;
import com.google.common.base.MoreObjects;
import com.google.common.collect.ImmutableList;
@@ -54,9 +53,7 @@
import com.google.inject.Inject;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
-import org.kohsuke.args4j.CmdLineException;
import org.kohsuke.args4j.CmdLineParser;
-import org.kohsuke.args4j.NamedOptionDef;
import org.kohsuke.args4j.Option;
import org.kohsuke.args4j.OptionDef;
import org.kohsuke.args4j.spi.OptionHandler;
@@ -84,8 +81,9 @@
@Option(name = "--whitespace")
Whitespace whitespace;
+ // TODO(hiesel): Remove parameter when not used by callers (e.g. frontend) anymore.
@Option(name = "--context", handler = ContextOptionHandler.class)
- int context = DiffPreferencesInfo.DEFAULT_CONTEXT;
+ int context;
@Option(name = "--intraline")
boolean intraline;
@@ -114,11 +112,10 @@
} else {
prefs.ignoreWhitespace = Whitespace.IGNORE_LEADING_AND_TRAILING;
}
- prefs.context = context;
prefs.intralineDifference = intraline;
logger.atFine().log(
- "diff preferences: ignoreWhitespace = %s, context = %s, intralineDifference = %s",
- prefs.ignoreWhitespace, prefs.context, prefs.intralineDifference);
+ "diff preferences: ignoreWhitespace = %s, intralineDifference = %s",
+ prefs.ignoreWhitespace, prefs.intralineDifference);
PatchScriptFactory psf;
PatchSet basePatchSet = null;
@@ -143,7 +140,6 @@
}
try {
- psf.setLoadComments(context != DiffPreferencesInfo.WHOLE_FILE_CONTEXT);
PatchScript ps = psf.call();
Project.NameKey projectName = resource.getRevision().getChange().getProject();
ProjectState state = projectCache.get(projectName).orElseThrow(illegalState(projectName));
@@ -245,11 +241,6 @@
return this;
}
- public GetDiff setContext(int context) {
- this.context = context;
- return this;
- }
-
public GetDiff setIntraline(boolean intraline) {
this.intraline = intraline;
return this;
@@ -274,6 +265,7 @@
}
}
+ // TODO(hiesel): Remove this class once clients don't send the context parameter anymore.
public static class ContextOptionHandler extends OptionHandler<Short> {
public ContextOptionHandler(CmdLineParser parser, OptionDef option, Setter<Short> setter) {
@@ -281,33 +273,14 @@
}
@Override
- public final int parseArguments(Parameters params) throws CmdLineException {
- final String value = params.getParameter(0);
- short context;
- if ("all".equalsIgnoreCase(value)) {
- context = DiffPreferencesInfo.WHOLE_FILE_CONTEXT;
- } else {
- try {
- context = Short.parseShort(value, 10);
- if (context < 0) {
- throw new NumberFormatException();
- }
- } catch (NumberFormatException e) {
- logger.atFine().withCause(e).log("invalid numeric value");
- throw new CmdLineException(
- owner,
- localizable("\"%s\" is not a valid value for \"%s\""),
- value,
- ((NamedOptionDef) option).name());
- }
- }
- setter.addValue(context);
+ public final int parseArguments(Parameters params) {
+ // Return 1 to consume the context parameter.
return 1;
}
@Override
public final String getDefaultMetaVariable() {
- return "ALL|# LINES";
+ return "ignored";
}
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/HumanCommentFilter.java b/java/com/google/gerrit/server/restapi/change/HumanCommentFilter.java
new file mode 100644
index 0000000..0180042
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/HumanCommentFilter.java
@@ -0,0 +1,32 @@
+// 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.restapi.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.HumanComment;
+
+/** Filter for human comments. */
+public interface HumanCommentFilter {
+
+ /**
+ * Filters the given comments. The returned comments are the ones which still remain after this
+ * filter was applied.
+ *
+ * @param comments comments which should be filtered
+ * @return remaining comments. Must not include comments which weren't included in the given
+ * comments.
+ */
+ ImmutableList<HumanComment> filter(ImmutableList<HumanComment> comments);
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
index b842f55..fa7c1f5 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeComments.java
@@ -20,6 +20,7 @@
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestReadView;
@@ -30,17 +31,29 @@
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
import com.google.inject.Provider;
-import com.google.inject.Singleton;
import java.util.List;
import java.util.Map;
+import org.kohsuke.args4j.Option;
-@Singleton
public class ListChangeComments implements RestReadView<ChangeResource> {
private final ChangeMessagesUtil changeMessagesUtil;
private final ChangeData.Factory changeDataFactory;
private final Provider<CommentJson> commentJson;
private final CommentsUtil commentsUtil;
+ private boolean includeContext;
+
+ /**
+ * Optional parameter. If set, the contextLines field of the {@link ContextLineInfo} of the
+ * response will contain the lines of the source file where the comment was written.
+ *
+ * @param context If true, comment context will be attached to the response
+ */
+ @Option(name = "--enable-context")
+ public void setContext(boolean context) {
+ this.includeContext = context;
+ }
+
@Inject
ListChangeComments(
ChangeData.Factory changeDataFactory,
@@ -70,7 +83,7 @@
private ImmutableList<CommentInfo> getAsList(Iterable<HumanComment> comments, ChangeResource rsrc)
throws PermissionBackendException {
- ImmutableList<CommentInfo> commentInfos = getCommentFormatter().formatAsList(comments);
+ ImmutableList<CommentInfo> commentInfos = getCommentFormatter(rsrc).formatAsList(comments);
List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
CommentsUtil.linkCommentsToChangeMessages(commentInfos, changeMessages, true);
return commentInfos;
@@ -78,7 +91,7 @@
private Map<String, List<CommentInfo>> getAsMap(
Iterable<HumanComment> comments, ChangeResource rsrc) throws PermissionBackendException {
- Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter().format(comments);
+ Map<String, List<CommentInfo>> commentInfosMap = getCommentFormatter(rsrc).format(comments);
List<CommentInfo> commentInfos =
commentInfosMap.values().stream().flatMap(List::stream).collect(toList());
List<ChangeMessage> changeMessages = changeMessagesUtil.byChange(rsrc.getNotes());
@@ -86,7 +99,14 @@
return commentInfosMap;
}
- private CommentJson.HumanCommentFormatter getCommentFormatter() {
- return commentJson.get().setFillAccounts(true).setFillPatchSet(true).newHumanCommentFormatter();
+ private CommentJson.HumanCommentFormatter getCommentFormatter(ChangeResource rsrc) {
+ return commentJson
+ .get()
+ .setFillAccounts(true)
+ .setFillPatchSet(true)
+ .setFillCommentContext(includeContext)
+ .setProjectKey(rsrc.getProject())
+ .setChangeId(rsrc.getId())
+ .newHumanCommentFormatter();
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedComments.java b/java/com/google/gerrit/server/restapi/change/ListPortedComments.java
new file mode 100644
index 0000000..6d6a02a
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedComments.java
@@ -0,0 +1,72 @@
+// 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.restapi.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class ListPortedComments implements RestReadView<RevisionResource> {
+
+ private final CommentsUtil commentsUtil;
+ private final CommentPorter commentPorter;
+ private final Provider<CommentJson> commentJson;
+
+ @Inject
+ public ListPortedComments(
+ Provider<CommentJson> commentJson, CommentsUtil commentsUtil, CommentPorter commentPorter) {
+ this.commentJson = commentJson;
+ this.commentsUtil = commentsUtil;
+ this.commentPorter = commentPorter;
+ }
+
+ @Override
+ public Response<Map<String, List<CommentInfo>>> apply(RevisionResource revisionResource)
+ throws PermissionBackendException {
+ PatchSet targetPatchset = revisionResource.getPatchSet();
+
+ List<HumanComment> allComments =
+ commentsUtil.publishedHumanCommentsByChange(revisionResource.getNotes());
+ ImmutableList<HumanComment> portedComments =
+ commentPorter.portComments(
+ revisionResource.getNotes(),
+ targetPatchset,
+ allComments,
+ ImmutableList.of(new UnresolvedCommentFilter()));
+ return Response.ok(format(portedComments));
+ }
+
+ private Map<String, List<CommentInfo>> format(List<HumanComment> comments)
+ throws PermissionBackendException {
+ return commentJson
+ .get()
+ .setFillAccounts(true)
+ .setFillPatchSet(true)
+ .newHumanCommentFormatter()
+ .format(comments);
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
new file mode 100644
index 0000000..e92fe5c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ListPortedDrafts.java
@@ -0,0 +1,77 @@
+// 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.restapi.change;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class ListPortedDrafts implements RestReadView<RevisionResource> {
+
+ private final CommentsUtil commentsUtil;
+ private final CommentPorter commentPorter;
+ private final Provider<CommentJson> commentJson;
+
+ @Inject
+ public ListPortedDrafts(
+ Provider<CommentJson> commentJson, CommentsUtil commentsUtil, CommentPorter commentPorter) {
+ this.commentJson = commentJson;
+ this.commentsUtil = commentsUtil;
+ this.commentPorter = commentPorter;
+ }
+
+ @Override
+ public Response<Map<String, List<CommentInfo>>> apply(RevisionResource revisionResource)
+ throws PermissionBackendException, RestApiException {
+ if (!revisionResource.getUser().isIdentifiedUser()) {
+ throw new AuthException("requires authentication; only authenticated users can have drafts");
+ }
+ PatchSet targetPatchset = revisionResource.getPatchSet();
+
+ List<HumanComment> draftComments =
+ commentsUtil.draftByChangeAuthor(
+ revisionResource.getNotes(), revisionResource.getAccountId());
+ ImmutableList<HumanComment> portedDraftComments =
+ commentPorter.portComments(
+ revisionResource.getNotes(), targetPatchset, draftComments, ImmutableList.of());
+ return Response.ok(format(portedDraftComments));
+ }
+
+ private Map<String, List<CommentInfo>> format(List<HumanComment> comments)
+ throws PermissionBackendException {
+ return commentJson
+ .get()
+ // Always unset for draft comments as only draft comments of the requesting user are
+ // returned.
+ .setFillAccounts(false)
+ .setFillPatchSet(true)
+ .newHumanCommentFormatter()
+ .format(comments);
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/Mergeable.java b/java/com/google/gerrit/server/restapi/change/Mergeable.java
index 5d65663..7683ab7 100644
--- a/java/com/google/gerrit/server/restapi/change/Mergeable.java
+++ b/java/com/google/gerrit/server/restapi/change/Mergeable.java
@@ -16,10 +16,10 @@
import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import com.google.gerrit.common.data.SubmitTypeRecord;
import com.google.gerrit.entities.BranchOrderSection;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.MergeableInfo;
import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/Module.java
index 52a8f47..681534c 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/Module.java
@@ -29,6 +29,7 @@
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.CommentContextLoader;
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.change.AddReviewersOp;
import com.google.gerrit.server.change.AddToAttentionSetOp;
@@ -46,9 +47,11 @@
import com.google.gerrit.server.change.SetCherryPickOp;
import com.google.gerrit.server.change.SetHashtagsOp;
import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetTopicOp;
import com.google.gerrit.server.change.WorkInProgressOp;
import com.google.gerrit.server.restapi.change.Reviewed.DeleteReviewed;
import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
+import com.google.gerrit.server.util.AttentionSetEmail;
public class Module extends RestApiModule {
@Override
@@ -171,6 +174,9 @@
post(FIX_KIND, "apply").to(ApplyFix.class);
get(FIX_KIND, "preview").to(GetFixPreview.class);
+ get(REVISION_KIND, "ported_comments").to(ListPortedComments.class);
+ get(REVISION_KIND, "ported_drafts").to(ListPortedDrafts.class);
+
child(REVISION_KIND, "files").to(Files.class);
put(FILE_KIND, "reviewed").to(PutReviewed.class);
delete(FILE_KIND, "reviewed").to(DeleteReviewed.class);
@@ -201,6 +207,7 @@
factory(AccountLoader.Factory.class);
factory(ChangeInserter.Factory.class);
factory(ChangeResource.Factory.class);
+ factory(CommentContextLoader.Factory.class);
factory(DeleteChangeOp.Factory.class);
factory(DeleteReviewerByEmailOp.Factory.class);
factory(DeleteReviewerOp.Factory.class);
@@ -212,10 +219,11 @@
factory(SetAssigneeOp.Factory.class);
factory(SetCherryPickOp.Factory.class);
factory(SetHashtagsOp.Factory.class);
+ factory(SetTopicOp.Factory.class);
factory(SetPrivateOp.Factory.class);
factory(WorkInProgressOp.Factory.class);
- factory(SetTopicOp.Factory.class);
factory(AddToAttentionSetOp.Factory.class);
factory(RemoveFromAttentionSetOp.Factory.class);
+ factory(AttentionSetEmail.Factory.class);
}
}
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index c109cbf..577174f 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -23,10 +23,10 @@
import com.google.common.base.Strings;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 902986c..604c87f 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -18,7 +18,6 @@
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
import static com.google.gerrit.server.project.ProjectCache.illegalState;
@@ -41,8 +40,6 @@
import com.google.common.hash.HashCode;
import com.google.common.hash.Hashing;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.Change;
@@ -51,6 +48,8 @@
import com.google.gerrit.entities.FixReplacement;
import com.google.gerrit.entities.FixSuggestion;
import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
@@ -178,7 +177,7 @@
private final ProjectCache projectCache;
private final PermissionBackend permissionBackend;
private final PluginSetContext<CommentValidator> commentValidators;
- private final PostReviewAttentionSet postReviewAttentionSet;
+ private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
private final boolean strictLabels;
@Inject
@@ -203,7 +202,7 @@
ProjectCache projectCache,
PermissionBackend permissionBackend,
PluginSetContext<CommentValidator> commentValidators,
- PostReviewAttentionSet postReviewAttentionSet) {
+ ReplyAttentionSetUpdates replyAttentionSetUpdates) {
this.updateFactory = updateFactory;
this.changeResourceFactory = changeResourceFactory;
this.changeDataFactory = changeDataFactory;
@@ -223,7 +222,7 @@
this.projectCache = projectCache;
this.permissionBackend = permissionBackend;
this.commentValidators = commentValidators;
- this.postReviewAttentionSet = postReviewAttentionSet;
+ this.replyAttentionSetUpdates = replyAttentionSetUpdates;
this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
}
@@ -383,7 +382,8 @@
bu.setNotify(notify);
// Adjust the attention set based on the input
- postReviewAttentionSet.updateAttentionSet(bu, revision, input, reviewerResults);
+ replyAttentionSetUpdates.updateAttentionSet(
+ bu, revision.getNotes(), input, revision.getUser());
bu.execute();
// Re-read change to take into account results of the update.
@@ -568,8 +568,8 @@
}
}
- private static <T extends CommentInput> Map<String, List<T>> cleanUpComments(
- Map<String, List<T>> commentsPerPath) {
+ private static <T extends com.google.gerrit.extensions.client.Comment>
+ Map<String, List<T>> cleanUpComments(Map<String, List<T>> commentsPerPath) {
Map<String, List<T>> cleanedUpCommentMap = new HashMap<>();
for (Map.Entry<String, List<T>> e : commentsPerPath.entrySet()) {
String path = e.getKey();
@@ -587,7 +587,8 @@
return cleanedUpCommentMap;
}
- private static <T extends CommentInput> List<T> cleanUpComments(List<T> comments) {
+ private static <T extends com.google.gerrit.extensions.client.Comment> List<T> cleanUpComments(
+ List<T> comments) {
return comments.stream()
.filter(Objects::nonNull)
.filter(comment -> !Strings.nullToEmpty(comment.message).trim().isEmpty())
@@ -598,7 +599,7 @@
return TraceContext.newTimer(getClass().getSimpleName() + "#" + method, Metadata.empty());
}
- private <T extends CommentInput> void checkComments(
+ private <T extends com.google.gerrit.extensions.client.Comment> void checkComments(
RevisionResource revision, Map<String, List<T>> commentsPerPath)
throws BadRequestException, PatchListNotAvailableException {
logger.atFine().log("checking comments");
@@ -614,6 +615,7 @@
ensureCommentNotOnMagicFilesOfAutoMerge(path, comment);
ensureRangeIsValid(path, comment.range);
ensureValidPatchsetLevelComment(path, comment);
+ ensureValidInReplyTo(revision.getNotes(), comment.inReplyTo);
}
}
}
@@ -645,21 +647,32 @@
}
}
- private static <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
- String path, T comment) throws BadRequestException {
+ private static <T extends com.google.gerrit.extensions.client.Comment>
+ void ensureCommentNotOnMagicFilesOfAutoMerge(String path, T comment)
+ throws BadRequestException {
if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
}
}
- private static <T extends CommentInput> void ensureValidPatchsetLevelComment(
- String path, T comment) throws BadRequestException {
+ private static <T extends com.google.gerrit.extensions.client.Comment>
+ void ensureValidPatchsetLevelComment(String path, T comment) throws BadRequestException {
if (path.equals(PATCHSET_LEVEL)
&& (comment.side != null || comment.range != null || comment.line != null)) {
throw new BadRequestException("Patchset-level comments can't have side, range, or line");
}
}
+ private void ensureValidInReplyTo(ChangeNotes changeNotes, String inReplyTo)
+ throws BadRequestException {
+ if (inReplyTo != null
+ && !commentsUtil.getPublishedHumanComment(changeNotes, inReplyTo).isPresent()
+ && !commentsUtil.getRobotComment(changeNotes, inReplyTo).isPresent()) {
+ throw new BadRequestException(
+ String.format("Invalid inReplyTo, comment %s not found", inReplyTo));
+ }
+ }
+
private void checkRobotComments(
RevisionResource revision, Map<String, List<RobotCommentInput>> in)
throws BadRequestException, PatchListNotAvailableException {
@@ -881,7 +894,7 @@
@Override
public boolean updateChange(ChangeContext ctx)
throws ResourceConflictException, UnprocessableEntityException, IOException,
- PatchListNotAvailableException, CommentsRejectedException {
+ CommentsRejectedException {
user = ctx.getIdentifiedUser();
notes = ctx.getNotes();
ps = psUtil.get(ctx.getNotes(), psId);
@@ -939,8 +952,7 @@
}
private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
- throws UnprocessableEntityException, PatchListNotAvailableException,
- CommentsRejectedException {
+ throws CommentsRejectedException {
Map<String, List<CommentInput>> inputComments = in.comments;
if (inputComments == null) {
inputComments = Collections.emptyMap();
@@ -976,7 +988,9 @@
String parent = Url.decode(inputComment.inReplyTo);
comment =
commentsUtil.newHumanComment(
- ctx,
+ ctx.getNotes(),
+ ctx.getUser(),
+ ctx.getWhen(),
path,
psId,
inputComment.side(),
@@ -990,7 +1004,7 @@
comment.message = inputComment.message;
}
- setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+ commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
comment.setLineNbrAndRange(inputComment.line, inputComment.range);
comment.tag = in.tag;
@@ -1069,8 +1083,7 @@
return !newRobotComments.isEmpty();
}
- private List<RobotComment> getNewRobotComments(ChangeContext ctx)
- throws PatchListNotAvailableException {
+ private List<RobotComment> getNewRobotComments(ChangeContext ctx) {
List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
Set<CommentSetEntry> existingIds =
@@ -1090,8 +1103,7 @@
}
private RobotComment createRobotCommentFromInput(
- ChangeContext ctx, String path, RobotCommentInput robotCommentInput)
- throws PatchListNotAvailableException {
+ ChangeContext ctx, String path, RobotCommentInput robotCommentInput) {
RobotComment robotComment =
commentsUtil.newRobotComment(
ctx,
@@ -1106,7 +1118,7 @@
robotComment.properties = robotCommentInput.properties;
robotComment.setLineNbrAndRange(robotCommentInput.line, robotCommentInput.range);
robotComment.tag = in.tag;
- setCommentCommitId(robotComment, patchListCache, ctx.getChange(), ps);
+ commentsUtil.setCommentCommitId(robotComment, ctx.getChange(), ps);
robotComment.fixSuggestions = createFixSuggestionsFromInput(robotCommentInput.fixSuggestions);
return robotComment;
}
@@ -1153,13 +1165,7 @@
private Map<String, HumanComment> changeDrafts(ChangeContext ctx) {
return commentsUtil.draftByChangeAuthor(ctx.getNotes(), user.getAccountId()).stream()
- .collect(
- Collectors.toMap(
- c -> c.key.uuid,
- c -> {
- c.tag = in.tag;
- return c;
- }));
+ .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
}
private Map<String, HumanComment> patchSetDrafts(ChangeContext ctx) {
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewAttentionSet.java b/java/com/google/gerrit/server/restapi/change/PostReviewAttentionSet.java
deleted file mode 100644
index aeb2c2e..0000000
--- a/java/com/google/gerrit/server/restapi/change/PostReviewAttentionSet.java
+++ /dev/null
@@ -1,238 +0,0 @@
-// 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.restapi.change;
-
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-import static java.util.stream.Collectors.toList;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.extensions.api.changes.AttentionSetInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.change.AddToAttentionSetOp;
-import com.google.gerrit.server.change.AttentionSetUnchangedOp;
-import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
-import com.google.gerrit.server.change.ReviewerAdder;
-import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.util.AttentionSetUtil;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-
-/**
- * This class is used by {@link PostReview} to update the attention set when performing a review.
- */
-public class PostReviewAttentionSet {
-
- private final PermissionBackend permissionBackend;
- private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
- private final RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory;
- private final ApprovalsUtil approvalsUtil;
- private final AccountResolver accountResolver;
-
- @Inject
- PostReviewAttentionSet(
- PermissionBackend permissionBackend,
- AddToAttentionSetOp.Factory addToAttentionSetOpFactory,
- RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory,
- ApprovalsUtil approvalsUtil,
- AccountResolver accountResolver) {
- this.permissionBackend = permissionBackend;
- this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
- this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
- this.approvalsUtil = approvalsUtil;
- this.accountResolver = accountResolver;
- }
-
- /**
- * Adjusts the attention set by adding and removing users. If the same user should be added and
- * removed or added/removed twice, the user will only be added/removed once, based on first
- * addition/removal.
- */
- public void updateAttentionSet(
- BatchUpdate bu,
- RevisionResource revision,
- ReviewInput input,
- List<ReviewerAdder.ReviewerAddition> reviewerResults)
- throws BadRequestException, IOException, PermissionBackendException,
- UnprocessableEntityException, ConfigInvalidException {
- processManualUpdates(bu, revision, input);
- if (input.ignoreDefaultAttentionSetRules) {
-
- // If We ignore default attention set rules it means we need to pass this information to
- // ChangeUpdate. Also, we should stop all other attention set update that are part of
- // this method (that happen in PostReview.
- bu.addOp(revision.getChange().getId(), new AttentionSetUnchangedOp());
- return;
- }
- processRules(bu, revision, input, reviewerResults);
- }
-
- /**
- * Process the default rules of the attention set. All of the default rules except adding/removing
- * reviewers and entering/exiting WIP state are done here, and the rest are done in {@link
- * ChangeUpdate}
- */
- private void processRules(
- BatchUpdate bu,
- RevisionResource revision,
- ReviewInput input,
- List<ReviewerAdder.ReviewerAddition> reviewerResults) {
- // Replying removes the publishing user from the attention set.
- RemoveFromAttentionSetOp removeFromAttentionSetOp =
- removeFromAttentionSetOpFactory.create(revision.getAccountId(), "removed on reply");
- bu.addOp(revision.getChange().getId(), removeFromAttentionSetOp);
-
- // The rest of the conditions only apply if the change is ready for review
- if (!isReadyForReview(revision, input)) {
- return;
- }
- Account.Id uploader = revision.getPatchSet().uploader();
- Account.Id owner = revision.getChange().getOwner();
- Account.Id currentUser = revision.getAccountId();
- if (currentUser.equals(uploader) && !uploader.equals(owner)) {
- // When the uploader replies, add the owner to the attention set.
- AddToAttentionSetOp addToAttentionSetOp =
- addToAttentionSetOpFactory.create(owner, "uploader replied");
- bu.addOp(revision.getChange().getId(), addToAttentionSetOp);
- }
- if (currentUser.equals(uploader) || currentUser.equals(owner)) {
- // When the owner or uploader replies, add the reviewers to the attention set.
- // Filter by users that are currently reviewers.
- Set<Account.Id> finalCCs =
- reviewerResults.stream()
- .filter(r -> r.result.ccs == null)
- .map(r -> r.reviewers)
- .flatMap(x -> x.stream())
- .collect(toSet());
- for (Account.Id reviewer :
- approvalsUtil.getReviewers(revision.getChangeResource().getNotes()).byState(REVIEWER)
- .stream()
- .filter(r -> !finalCCs.contains(r))
- .collect(toList())) {
- AddToAttentionSetOp addToAttentionSetOp =
- addToAttentionSetOpFactory.create(reviewer, "owner or uploader replied");
- bu.addOp(revision.getChange().getId(), addToAttentionSetOp);
- }
- }
- if (!currentUser.equals(uploader) && !currentUser.equals(owner)) {
- // When neither the uploader nor the owner (reviewer or cc) replies, add the owner and the
- // uploader to the attention set.
- AddToAttentionSetOp addToAttentionSetOp =
- addToAttentionSetOpFactory.create(owner, "reviewer or cc replied");
- bu.addOp(revision.getChange().getId(), addToAttentionSetOp);
-
- if (owner.get() != uploader.get()) {
- addToAttentionSetOp = addToAttentionSetOpFactory.create(uploader, "reviewer or cc replied");
- bu.addOp(revision.getChange().getId(), addToAttentionSetOp);
- }
- }
- }
-
- /** Process the manual updates of the attention set. */
- private void processManualUpdates(BatchUpdate bu, RevisionResource revision, ReviewInput input)
- throws BadRequestException, IOException, PermissionBackendException,
- UnprocessableEntityException, ConfigInvalidException {
- Set<Account.Id> accountsChangedInCommit = new HashSet<>();
- // If we specify a user to remove, and the user is in the attention set, we remove it.
- if (input.removeFromAttentionSet != null) {
- for (AttentionSetInput remove : input.removeFromAttentionSet) {
- removeFromAttentionSet(bu, revision, remove, accountsChangedInCommit);
- }
- }
-
- // If we don't specify a user to remove, but we specify addition for that user, the user will be
- // added if they are not in the attention set yet.
- if (input.addToAttentionSet != null) {
- for (AttentionSetInput add : input.addToAttentionSet) {
- addToAttentionSet(bu, revision, add, accountsChangedInCommit);
- }
- }
- }
-
- private static boolean isReadyForReview(RevisionResource revision, ReviewInput input) {
- return (!revision.getChange().isWorkInProgress() && !input.workInProgress) || input.ready;
- }
-
- private void addToAttentionSet(
- BatchUpdate bu,
- RevisionResource revision,
- AttentionSetInput add,
- Set<Account.Id> accountsChangedInCommitv)
- throws BadRequestException, IOException, PermissionBackendException,
- UnprocessableEntityException, ConfigInvalidException {
- AttentionSetUtil.validateInput(add);
- Account.Id attentionUserId =
- getAccountIdAndValidateUser(revision, add.user, accountsChangedInCommitv);
-
- AddToAttentionSetOp addToAttentionSetOp =
- addToAttentionSetOpFactory.create(attentionUserId, add.reason);
- bu.addOp(revision.getChange().getId(), addToAttentionSetOp);
- }
-
- private void removeFromAttentionSet(
- BatchUpdate bu,
- RevisionResource revision,
- AttentionSetInput remove,
- Set<Account.Id> accountsChangedInCommit)
- throws BadRequestException, IOException, PermissionBackendException,
- UnprocessableEntityException, ConfigInvalidException {
- AttentionSetUtil.validateInput(remove);
- Account.Id attentionUserId =
- getAccountIdAndValidateUser(revision, remove.user, accountsChangedInCommit);
-
- RemoveFromAttentionSetOp removeFromAttentionSetOp =
- removeFromAttentionSetOpFactory.create(attentionUserId, remove.reason);
- bu.addOp(revision.getChange().getId(), removeFromAttentionSetOp);
- }
-
- private Account.Id getAccountIdAndValidateUser(
- RevisionResource revision, String user, Set<Account.Id> accountsChangedInCommit)
- throws ConfigInvalidException, IOException, PermissionBackendException,
- UnprocessableEntityException, BadRequestException {
- Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
- try {
- permissionBackend
- .absentUser(attentionUserId)
- .change(revision.getNotes())
- .check(ChangePermission.READ);
- } catch (AuthException e) {
- throw new UnprocessableEntityException(
- "Can't add to attention set: Read not permitted for " + attentionUserId, e);
- }
- if (accountsChangedInCommit.contains(attentionUserId)) {
- throw new BadRequestException(
- String.format(
- "%s can not be added/removed twice, and can not be added and "
- + "removed at the same time",
- user));
- }
- accountsChangedInCommit.add(attentionUserId);
- return attentionUserId;
- }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
index f327f16..84a3d89 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDraftComment.java
@@ -15,7 +15,6 @@
package com.google.gerrit.server.restapi.change;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
-import static com.google.gerrit.server.CommentsUtil.setCommentCommitId;
import com.google.gerrit.entities.Comment;
import com.google.gerrit.entities.HumanComment;
@@ -32,8 +31,6 @@
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.change.DraftCommentResource;
import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.BatchUpdateOp;
@@ -54,7 +51,6 @@
private final CommentsUtil commentsUtil;
private final PatchSetUtil psUtil;
private final Provider<CommentJson> commentJson;
- private final PatchListCache patchListCache;
@Inject
PutDraftComment(
@@ -62,14 +58,12 @@
DeleteDraftComment delete,
CommentsUtil commentsUtil,
PatchSetUtil psUtil,
- Provider<CommentJson> commentJson,
- PatchListCache patchListCache) {
+ Provider<CommentJson> commentJson) {
this.updateFactory = updateFactory;
this.delete = delete;
this.commentsUtil = commentsUtil;
this.psUtil = psUtil;
this.commentJson = commentJson;
- this.patchListCache = patchListCache;
}
@Override
@@ -86,8 +80,12 @@
throw new BadRequestException("patchset-level comments can't have side, range, or line");
} else if (in.line != null && in.range != null && in.line != in.range.endLine) {
throw new BadRequestException("range endLine must be on the same line as the comment");
+ } else if (in.inReplyTo != null
+ && !commentsUtil.getPublishedHumanComment(rsrc.getNotes(), in.inReplyTo).isPresent()
+ && !commentsUtil.getRobotComment(rsrc.getNotes(), in.inReplyTo).isPresent()) {
+ throw new BadRequestException(
+ String.format("Invalid inReplyTo, comment %s not found", in.inReplyTo));
}
-
try (BatchUpdate bu =
updateFactory.create(rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
Op op = new Op(rsrc.getComment().key, in);
@@ -110,8 +108,7 @@
}
@Override
- public boolean updateChange(ChangeContext ctx)
- throws ResourceNotFoundException, PatchListNotAvailableException {
+ public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException {
Optional<HumanComment> maybeComment =
commentsUtil.getDraft(ctx.getNotes(), ctx.getIdentifiedUser(), key);
if (!maybeComment.isPresent()) {
@@ -139,7 +136,7 @@
commentsUtil.deleteHumanComments(update, Collections.singleton(origComment));
comment.key.filename = in.path;
}
- setCommentCommitId(comment, patchListCache, ctx.getChange(), ps);
+ commentsUtil.setCommentCommitId(comment, ctx.getChange(), ps);
commentsUtil.putHumanComments(
update,
HumanComment.Status.DRAFT,
diff --git a/java/com/google/gerrit/server/restapi/change/PutMessage.java b/java/com/google/gerrit/server/restapi/change/PutMessage.java
index 2e9d21a..190deb5 100644
--- a/java/com/google/gerrit/server/restapi/change/PutMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/PutMessage.java
@@ -21,6 +21,7 @@
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.common.CommitMessageInput;
+import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -34,6 +35,7 @@
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.config.UrlFormatter;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.ChangePermission;
@@ -73,6 +75,7 @@
private final PatchSetUtil psUtil;
private final NotifyResolver notifyResolver;
private final ProjectCache projectCache;
+ private final DynamicItem<UrlFormatter> urlFormatter;
@Inject
PutMessage(
@@ -84,7 +87,8 @@
@GerritPersonIdent PersonIdent gerritIdent,
PatchSetUtil psUtil,
NotifyResolver notifyResolver,
- ProjectCache projectCache) {
+ ProjectCache projectCache,
+ DynamicItem<UrlFormatter> urlFormatter) {
this.updateFactory = updateFactory;
this.repositoryManager = repositoryManager;
this.userProvider = userProvider;
@@ -94,6 +98,7 @@
this.psUtil = psUtil;
this.notifyResolver = notifyResolver;
this.projectCache = projectCache;
+ this.urlFormatter = urlFormatter;
}
@Override
@@ -200,7 +205,7 @@
}
}
- private static String ensureChangeIdIsCorrect(
+ private String ensureChangeIdIsCorrect(
boolean requireChangeId, String currentChangeId, String newCommitMessage)
throws ResourceConflictException, BadRequestException {
RevCommit revCommit =
@@ -210,7 +215,7 @@
// Check that the commit message without footers is not empty
CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
- List<String> changeIdFooters = revCommit.getFooterLines(FooterConstants.CHANGE_ID);
+ List<String> changeIdFooters = ChangeUtil.getChangeIdsFromFooter(revCommit, urlFormatter.get());
if (!changeIdFooters.isEmpty() && !changeIdFooters.get(0).equals(currentChangeId)) {
throw new ResourceConflictException("wrong Change-Id footer");
}
diff --git a/java/com/google/gerrit/server/restapi/change/PutTopic.java b/java/com/google/gerrit/server/restapi/change/PutTopic.java
index 325b80c..3031781 100644
--- a/java/com/google/gerrit/server/restapi/change/PutTopic.java
+++ b/java/com/google/gerrit/server/restapi/change/PutTopic.java
@@ -23,6 +23,7 @@
import com.google.gerrit.extensions.webui.UiAction;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetTopicOp;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
@@ -60,7 +61,7 @@
sanitizedInput.topic = sanitizedInput.topic.trim();
}
- SetTopicOp op = topicOpFactory.create(sanitizedInput);
+ SetTopicOp op = topicOpFactory.create(sanitizedInput.topic);
try (BatchUpdate u =
updateFactory.create(req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
u.addOp(req.getId(), op);
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index 878e714..0fec476 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -165,7 +165,9 @@
int cnt = queries.size();
List<QueryResult<ChangeData>> results = queryProcessor.query(qb.parse(queries));
List<List<ChangeInfo>> res =
- json.create(options, queryProcessor.getAttributesFactory()).format(results);
+ json.create(
+ options, queryProcessor.getAttributesFactory(), queryProcessor.getInfosFactory())
+ .format(results);
for (int n = 0; n < cnt; n++) {
List<ChangeInfo> info = res.get(n);
if (results.get(n).more() && !info.isEmpty()) {
diff --git a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
index ae2f2bf..7fe463e 100644
--- a/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/RemoveFromAttentionSet.java
@@ -17,6 +17,7 @@
import com.google.common.base.Strings;
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.Response;
import com.google.gerrit.extensions.restapi.RestApiException;
@@ -24,10 +25,12 @@
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.change.AttentionSetEntryResource;
import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.NotifyResolver;
import com.google.gerrit.server.change.RemoveFromAttentionSetOp;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.AttentionSetUtil;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
import java.io.IOException;
@@ -39,15 +42,18 @@
private final BatchUpdate.Factory updateFactory;
private final RemoveFromAttentionSetOp.Factory opFactory;
private final AccountResolver accountResolver;
+ private final NotifyResolver notifyResolver;
@Inject
RemoveFromAttentionSet(
BatchUpdate.Factory updateFactory,
RemoveFromAttentionSetOp.Factory opFactory,
- AccountResolver accountResolver) {
+ AccountResolver accountResolver,
+ NotifyResolver notifyResolver) {
this.updateFactory = updateFactory;
this.opFactory = opFactory;
this.accountResolver = accountResolver;
+ this.notifyResolver = notifyResolver;
}
@Override
@@ -64,13 +70,9 @@
}
input.user = Strings.nullToEmpty(input.user).trim();
if (!input.user.isEmpty()) {
- Account.Id attentionUserId = null;
- try {
- attentionUserId = accountResolver.resolve(input.user).asUnique().account().id();
- } catch (AccountResolver.UnresolvableAccountException ex) {
- throw new BadRequestException(
- "The user specified in the input body couldn't be found.", ex);
- }
+ Account.Id attentionUserId =
+ AttentionSetUtil.resolveAccount(
+ accountResolver, attentionResource.getChangeResource().getNotes(), input.user);
if (attentionUserId.get() != attentionResource.getAccountId().get()) {
throw new BadRequestException(
"The field \"user\" must be empty, or must match the user specified in the URL.");
@@ -81,8 +83,11 @@
updateFactory.create(
changeResource.getProject(), changeResource.getUser(), TimeUtil.nowTs())) {
RemoveFromAttentionSetOp op =
- opFactory.create(attentionResource.getAccountId(), input.reason);
+ opFactory.create(attentionResource.getAccountId(), input.reason, true);
bu.addOp(changeResource.getId(), op);
+ NotifyHandling notify = input.notify == null ? NotifyHandling.OWNER : input.notify;
+ NotifyResolver.Result notifyResult = notifyResolver.resolve(notify, input.notifyDetails);
+ bu.setNotify(notifyResult);
bu.execute();
}
return Response.none();
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
new file mode 100644
index 0000000..a1bd678
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -0,0 +1,384 @@
+// 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.restapi.change;
+
+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;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResolver;
+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;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.AttentionSetUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+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;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * This class is used to update the attention set when performing a review or replying on a change.
+ */
+public class ReplyAttentionSetUpdates {
+
+ private final PermissionBackend permissionBackend;
+ private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
+ private final RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory;
+ private final ApprovalsUtil approvalsUtil;
+ private final AccountResolver accountResolver;
+ private final ServiceUserClassifier serviceUserClassifier;
+ private final CommentsUtil commentsUtil;
+
+ @Inject
+ ReplyAttentionSetUpdates(
+ PermissionBackend permissionBackend,
+ AddToAttentionSetOp.Factory addToAttentionSetOpFactory,
+ RemoveFromAttentionSetOp.Factory removeFromAttentionSetOpFactory,
+ ApprovalsUtil approvalsUtil,
+ AccountResolver accountResolver,
+ ServiceUserClassifier serviceUserClassifier,
+ CommentsUtil commentsUtil) {
+ this.permissionBackend = permissionBackend;
+ this.addToAttentionSetOpFactory = addToAttentionSetOpFactory;
+ this.removeFromAttentionSetOpFactory = removeFromAttentionSetOpFactory;
+ this.approvalsUtil = approvalsUtil;
+ this.accountResolver = accountResolver;
+ this.serviceUserClassifier = serviceUserClassifier;
+ this.commentsUtil = commentsUtil;
+ }
+
+ /** Adjusts the attention set but only based on the automatic rules. */
+ public void processAutomaticAttentionSetRulesOnReply(
+ BatchUpdate bu,
+ ChangeNotes changeNotes,
+ boolean readyForReview,
+ CurrentUser currentUser,
+ List<HumanComment> commentsToBePublished) {
+ if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
+ return;
+ }
+ processRules(
+ bu,
+ changeNotes,
+ readyForReview,
+ currentUser,
+ commentsToBePublished.stream().collect(toImmutableSet()));
+ }
+
+ /**
+ * Adjusts the attention set by adding and removing users. If the same user should be added and
+ * removed or added/removed twice, the user will only be added/removed once, based on first
+ * addition/removal.
+ */
+ public void updateAttentionSet(
+ BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser)
+ throws BadRequestException, IOException, PermissionBackendException,
+ UnprocessableEntityException, ConfigInvalidException {
+ processManualUpdates(bu, changeNotes, input);
+ if (input.ignoreAutomaticAttentionSetRules) {
+
+ // If we ignore automatic attention set rules it means we need to pass this information to
+ // ChangeUpdate. Also, we should stop all other attention set updates that are part of
+ // this method and happen in PostReview.
+ bu.addOp(changeNotes.getChangeId(), new AttentionSetUnchangedOp());
+ return;
+ }
+ if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
+ botsWithNegativeLabelsAddOwnerAndUploader(bu, changeNotes, input);
+ return;
+ }
+
+ processRules(
+ bu,
+ changeNotes,
+ isReadyForReview(changeNotes, input),
+ currentUser,
+ getAllNewComments(changeNotes, input, currentUser));
+ }
+
+ private ImmutableSet<HumanComment> getAllNewComments(
+ ChangeNotes changeNotes, ReviewInput input, CurrentUser currentUser) {
+ Set<HumanComment> newComments = new HashSet<>();
+ if (input.comments != null) {
+ for (ReviewInput.CommentInput commentInput :
+ input.comments.values().stream().flatMap(x -> x.stream()).collect(Collectors.toList())) {
+ newComments.add(
+ commentsUtil.newHumanComment(
+ changeNotes,
+ currentUser,
+ TimeUtil.nowTs(),
+ commentInput.path,
+ commentInput.patchSet == null
+ ? changeNotes.getChange().currentPatchSetId()
+ : PatchSet.id(changeNotes.getChange().getId(), commentInput.patchSet),
+ commentInput.side(),
+ commentInput.message,
+ commentInput.unresolved,
+ commentInput.inReplyTo));
+ }
+ }
+ List<HumanComment> drafts = new ArrayList<>();
+ if (input.drafts == ReviewInput.DraftHandling.PUBLISH) {
+ drafts =
+ commentsUtil.draftByPatchSetAuthor(
+ changeNotes.getChange().currentPatchSetId(), currentUser.getAccountId(), changeNotes);
+ }
+ if (input.drafts == ReviewInput.DraftHandling.PUBLISH_ALL_REVISIONS) {
+ drafts = commentsUtil.draftByChangeAuthor(changeNotes, currentUser.getAccountId());
+ }
+ return Stream.concat(newComments.stream(), drafts.stream()).collect(toImmutableSet());
+ }
+
+ /**
+ * Process the automatic rules of the attention set. All of the automatic rules except
+ * adding/removing reviewers and entering/exiting WIP state are done here, and the rest are done
+ * in {@link ChangeUpdate}
+ */
+ private void processRules(
+ BatchUpdate bu,
+ ChangeNotes changeNotes,
+ boolean readyForReview,
+ CurrentUser currentUser,
+ ImmutableSet<HumanComment> allNewComments) {
+ // Replying removes the publishing user from the attention set.
+ removeFromAttentionSet(bu, changeNotes, currentUser.getAccountId(), "removed on reply", false);
+
+ Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
+ Account.Id owner = changeNotes.getChange().getOwner();
+
+ // The rest of the conditions only apply if the change is open.
+ if (changeNotes.getChange().getStatus().isClosed()) {
+ // We still add the owner if a new comment thread was created, on closed changes.
+ if (allNewComments.stream().anyMatch(c -> c.parentUuid == null)) {
+ addToAttentionSet(bu, changeNotes, owner, "A new comment thread was created", false);
+ }
+ return;
+ }
+ // The rest of the conditions only apply if the change is ready for review.
+ if (!readyForReview) {
+ return;
+ }
+
+ if (!currentUser.getAccountId().equals(owner)) {
+ addToAttentionSet(bu, changeNotes, owner, "Someone else replied on the change", false);
+ }
+ if (!owner.equals(uploader) && !currentUser.getAccountId().equals(uploader)) {
+ addToAttentionSet(bu, changeNotes, uploader, "Someone else replied on the change", false);
+ }
+
+ addAllAuthorsOfCommentThreads(bu, changeNotes, allNewComments);
+ }
+
+ /** Adds all authors of all comment threads that received a reply during this update */
+ private void addAllAuthorsOfCommentThreads(
+ BatchUpdate bu, ChangeNotes changeNotes, ImmutableSet<HumanComment> allNewComments) {
+ List<HumanComment> publishedComments = commentsUtil.publishedHumanCommentsByChange(changeNotes);
+ ImmutableSet<CommentThread<HumanComment>> repliedToCommentThreads =
+ CommentThreads.forComments(publishedComments).getThreadsForChildren(allNewComments);
+
+ 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);
+ }
+ }
+
+ /** Process the manual updates of the attention set. */
+ private void processManualUpdates(BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input)
+ throws BadRequestException, IOException, PermissionBackendException,
+ UnprocessableEntityException, ConfigInvalidException {
+ Set<Account.Id> accountsChangedInCommit = new HashSet<>();
+ // If we specify a user to remove, and the user is in the attention set, we remove it.
+ if (input.removeFromAttentionSet != null) {
+ for (AttentionSetInput remove : input.removeFromAttentionSet) {
+ removeFromAttentionSet(bu, changeNotes, remove, accountsChangedInCommit);
+ }
+ }
+
+ // If we don't specify a user to remove, but we specify addition for that user, the user will be
+ // added if they are not in the attention set yet.
+ if (input.addToAttentionSet != null) {
+ for (AttentionSetInput add : input.addToAttentionSet) {
+ addToAttentionSet(bu, changeNotes, add, accountsChangedInCommit);
+ }
+ }
+ }
+
+ /**
+ * Bots don't process automatic rules, the only attention set change they do is this rule: Add
+ * owner and uploader when a bot votes negatively.
+ */
+ private void botsWithNegativeLabelsAddOwnerAndUploader(
+ BatchUpdate bu, ChangeNotes changeNotes, ReviewInput input) {
+ if (input.labels != null && input.labels.values().stream().anyMatch(vote -> vote < 0)) {
+ Account.Id uploader = changeNotes.getCurrentPatchSet().uploader();
+ Account.Id owner = changeNotes.getChange().getOwner();
+ addToAttentionSet(bu, changeNotes, owner, "A robot voted negatively on a label", false);
+ if (!owner.equals(uploader)) {
+ addToAttentionSet(bu, changeNotes, uploader, "A robot voted negatively on a label", false);
+ }
+ }
+ }
+
+ /**
+ * Adds the user to the attention set
+ *
+ * @param bu BatchUpdate to perform the updates to the attention set
+ * @param changeNotes current change
+ * @param user user to add to the attention set
+ * @param reason reason for adding
+ * @param notify whether or not to notify about this addition
+ */
+ private void addToAttentionSet(
+ BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
+ AddToAttentionSetOp addOwnerToAttentionSet =
+ addToAttentionSetOpFactory.create(user, reason, notify);
+ bu.addOp(changeNotes.getChangeId(), addOwnerToAttentionSet);
+ }
+
+ /**
+ * Removes the user from the attention set
+ *
+ * @param bu BatchUpdate to perform the updates to the attention set.
+ * @param changeNotes current change.
+ * @param user user to add remove from the attention set.
+ * @param reason reason for removing.
+ * @param notify whether or not to notify about this removal.
+ */
+ private void removeFromAttentionSet(
+ BatchUpdate bu, ChangeNotes changeNotes, Account.Id user, String reason, boolean notify) {
+ RemoveFromAttentionSetOp removeFromAttentionSetOp =
+ removeFromAttentionSetOpFactory.create(user, reason, notify);
+ bu.addOp(changeNotes.getChangeId(), removeFromAttentionSetOp);
+ }
+
+ private static boolean isReadyForReview(ChangeNotes changeNotes, ReviewInput input) {
+ return (!changeNotes.getChange().isWorkInProgress() && !input.workInProgress) || input.ready;
+ }
+
+ private void addToAttentionSet(
+ BatchUpdate bu,
+ ChangeNotes changeNotes,
+ AttentionSetInput add,
+ Set<Account.Id> accountsChangedInCommit)
+ throws BadRequestException, IOException, PermissionBackendException,
+ UnprocessableEntityException, ConfigInvalidException {
+ AttentionSetUtil.validateInput(add);
+ try {
+ Account.Id attentionUserId =
+ getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
+ addToAttentionSet(bu, changeNotes, attentionUserId, add.reason, false);
+ } catch (AccountResolver.UnresolvableAccountException ex) {
+ // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
+ // message here, then it would be possible to probe whether an account exists.
+ }
+ }
+
+ private void removeFromAttentionSet(
+ BatchUpdate bu,
+ ChangeNotes changeNotes,
+ AttentionSetInput remove,
+ Set<Account.Id> accountsChangedInCommit)
+ throws BadRequestException, IOException, PermissionBackendException,
+ UnprocessableEntityException, ConfigInvalidException {
+ AttentionSetUtil.validateInput(remove);
+ try {
+ Account.Id attentionUserId =
+ getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
+ removeFromAttentionSet(bu, changeNotes, attentionUserId, remove.reason, false);
+ } catch (AccountResolver.UnresolvableAccountException ex) {
+ // This happens only when the account doesn't exist. Silently ignore it. If we threw an error
+ // message here, then it would be possible to probe whether an account exists.
+ }
+ }
+
+ private Account.Id getAccountId(ChangeNotes changeNotes, String user)
+ throws ConfigInvalidException, IOException, UnprocessableEntityException,
+ PermissionBackendException {
+ Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
+ try {
+ permissionBackend
+ .absentUser(attentionUserId)
+ .change(changeNotes)
+ .check(ChangePermission.READ);
+ } catch (AuthException e) {
+ if (!changeNotes.getChange().isPrivate()) {
+ // If the change is private, it is okay to add the user to the attention set since that
+ // person will be granted visibility when a reviewer.
+ throw new UnprocessableEntityException(
+ "Can't add to attention set: Read not permitted for " + attentionUserId, e);
+ }
+ }
+ return attentionUserId;
+ }
+
+ private Account.Id getAccountIdAndValidateUser(
+ ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
+ throws ConfigInvalidException, IOException, PermissionBackendException,
+ UnprocessableEntityException, BadRequestException {
+ try {
+ Account.Id attentionUserId = getAccountId(changeNotes, user);
+ if (accountsChangedInCommit.contains(attentionUserId)) {
+ throw new BadRequestException(
+ String.format(
+ "%s can not be added/removed twice, and can not be added and "
+ + "removed at the same time",
+ user));
+ }
+ accountsChangedInCommit.add(attentionUserId);
+ return attentionUserId;
+ } catch (AccountResolver.UnresolvableAccountException ex) {
+ // This can only happen if this user can't see the account or the account doesn't exist.
+ // Silently modify the account's attention set anyway, if the account exists.
+ return accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index cb3a375..ca39a57 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -203,6 +203,7 @@
checkPermissionsForAllChanges(changeResource, changeDatas);
input.topic = createTopic(input.topic, submissionId);
+
return Response.ok(revertSubmission(changeDatas, input));
}
@@ -258,6 +259,9 @@
cherryPickInput.base = null;
Project.NameKey project = projectAndBranch.project();
cherryPickInput.destination = projectAndBranch.branch();
+ if (revertInput.workInProgress) {
+ cherryPickInput.notify = NotifyHandling.OWNER;
+ }
Collection<ChangeData> changesInProjectAndBranch =
changesPerProjectAndBranch.get(projectAndBranch);
@@ -332,7 +336,11 @@
bu.addOp(
changeNotes.getChange().getId(),
new CreateCherryPickOp(
- revCommitId, generatedChangeId, cherryPickRevertChangeId, timestamp));
+ revCommitId,
+ generatedChangeId,
+ cherryPickRevertChangeId,
+ timestamp,
+ revertInput.workInProgress));
bu.addOp(changeNotes.getChange().getId(), new PostRevertedMessageOp(generatedChangeId));
bu.addOp(
cherryPickRevertChangeId,
@@ -549,16 +557,19 @@
private final ObjectId computedChangeId;
private final Change.Id cherryPickRevertChangeId;
private final Timestamp timestamp;
+ private final boolean workInProgress;
CreateCherryPickOp(
ObjectId revCommitId,
ObjectId computedChangeId,
Change.Id cherryPickRevertChangeId,
- Timestamp timestamp) {
+ Timestamp timestamp,
+ Boolean workInProgress) {
this.revCommitId = revCommitId;
this.computedChangeId = computedChangeId;
this.cherryPickRevertChangeId = cherryPickRevertChangeId;
this.timestamp = timestamp;
+ this.workInProgress = workInProgress;
}
@Override
@@ -575,7 +586,8 @@
timestamp,
change.getId(),
computedChangeId,
- cherryPickRevertChangeId);
+ cherryPickRevertChangeId,
+ workInProgress);
// save the commit as base for next cherryPick of that branch
cherryPickInput.base =
changeNotesFactory
diff --git a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java b/java/com/google/gerrit/server/restapi/change/SetTopicOp.java
deleted file mode 100644
index 9eff5c1..0000000
--- a/java/com/google/gerrit/server/restapi/change/SetTopicOp.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2012 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.restapi.change;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.extensions.api.changes.TopicInput;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.extensions.events.TopicEdited;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.validators.ValidationException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-
-public class SetTopicOp implements BatchUpdateOp {
- public interface Factory {
- SetTopicOp create(TopicInput input);
- }
-
- private final TopicInput input;
- private final TopicEdited topicEdited;
- private final ChangeMessagesUtil cmUtil;
-
- private Change change;
- private String oldTopicName;
- private String newTopicName;
-
- @Inject
- public SetTopicOp(
- TopicEdited topicEdited, ChangeMessagesUtil cmUtil, @Assisted TopicInput input) {
- this.input = input;
- this.topicEdited = topicEdited;
- this.cmUtil = cmUtil;
- }
-
- @Override
- public boolean updateChange(ChangeContext ctx) throws BadRequestException {
- change = ctx.getChange();
- ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
- newTopicName = Strings.nullToEmpty(input.topic);
- oldTopicName = Strings.nullToEmpty(change.getTopic());
- if (oldTopicName.equals(newTopicName)) {
- return false;
- }
-
- String summary;
- if (oldTopicName.isEmpty()) {
- summary = "Topic set to " + newTopicName;
- } else if (newTopicName.isEmpty()) {
- summary = "Topic " + oldTopicName + " removed";
- } else {
- summary = String.format("Topic changed from %s to %s", oldTopicName, newTopicName);
- }
- change.setTopic(Strings.emptyToNull(newTopicName));
- try {
- update.setTopic(change.getTopic());
- } catch (ValidationException ex) {
- throw new BadRequestException(ex.getMessage());
- }
- ChangeMessage cmsg =
- ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
- cmUtil.addChangeMessage(update, cmsg);
- return true;
- }
-
- @Override
- public void postUpdate(Context ctx) {
- if (change != null) {
- topicEdited.fire(change, ctx.getAccount(), oldTopicName, ctx.getWhen());
- }
- }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index e77bfe7..790b2db 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -30,8 +30,10 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -66,12 +68,14 @@
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
+import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.Config;
@@ -373,8 +377,15 @@
public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws IOException {
Set<ChangeData> mergeabilityMap = new HashSet<>();
+ Set<ObjectId> outDatedPatchsets = new HashSet<>();
for (ChangeData change : cs.changes()) {
mergeabilityMap.add(change);
+ // Add all the patchsets commit ids except the current patchset.
+ outDatedPatchsets.addAll(
+ change.notes().getPatchSets().values().stream()
+ .map(p -> p.commitId())
+ .collect(Collectors.toSet()));
+ outDatedPatchsets.remove(change.currentPatchSet().commitId());
}
ListMultimap<BranchNameKey, ChangeData> cbb = cs.changesByBranch();
@@ -388,12 +399,17 @@
allParents.add(parent.getId());
}
}
-
for (ChangeData change : targetBranch) {
+
RevCommit commit = commits.get(change.getId());
boolean isMergeCommit = commit.getParentCount() > 1;
boolean isLastInChain = !allParents.contains(commit.getId());
-
+ if (Arrays.stream(commit.getParents()).anyMatch(c -> outDatedPatchsets.contains(c.getId()))
+ && !isCherryPickSubmit(change)) {
+ // Found a parent that depends on an outdated patchset and the submit strategy is not
+ // cherry-pick.
+ continue;
+ }
// Recheck mergeability rather than using value stored in the index,
// which may be stale.
// TODO(dborowitz): This is ugly; consider providing a way to not read
@@ -419,6 +435,11 @@
return mergeabilityMap;
}
+ private boolean isCherryPickSubmit(ChangeData changeData) {
+ SubmitTypeRecord submitTypeRecord = changeData.submitTypeRecord();
+ return submitTypeRecord.isOk() && submitTypeRecord.type == SubmitType.CHERRY_PICK;
+ }
+
private HashMap<Change.Id, RevCommit> findCommits(
Collection<ChangeData> changes, Project.NameKey project) throws IOException {
HashMap<Change.Id, RevCommit> commits = new HashMap<>();
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
index e0398c7..02c2ff0 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitRule.java
@@ -15,7 +15,7 @@
package com.google.gerrit.server.restapi.change;
import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.TestSubmitRuleInfo;
import com.google.gerrit.extensions.common.TestSubmitRuleInput;
diff --git a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
index cb52fcb..ecb455e 100644
--- a/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
+++ b/java/com/google/gerrit/server/restapi/change/TestSubmitType.java
@@ -15,7 +15,7 @@
package com.google.gerrit.server.restapi.change;
import com.google.common.base.MoreObjects;
-import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.TestSubmitRuleInput;
import com.google.gerrit.extensions.common.TestSubmitRuleInput.Filters;
diff --git a/java/com/google/gerrit/server/restapi/change/UnresolvedCommentFilter.java b/java/com/google/gerrit/server/restapi/change/UnresolvedCommentFilter.java
new file mode 100644
index 0000000..e75ccca
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/UnresolvedCommentFilter.java
@@ -0,0 +1,40 @@
+// 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.restapi.change;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.server.change.CommentThread;
+import com.google.gerrit.server.change.CommentThreads;
+import java.util.Collection;
+
+/** A filter which only keeps comments which are part of an unresolved {@link CommentThread}. */
+public class UnresolvedCommentFilter implements HumanCommentFilter {
+
+ @Override
+ public ImmutableList<HumanComment> filter(ImmutableList<HumanComment> comments) {
+ ImmutableSet<CommentThread<HumanComment>> commentThreads =
+ CommentThreads.forComments(comments).getThreads();
+
+ return commentThreads.stream()
+ .filter(CommentThread::unresolved)
+ .map(CommentThread::comments)
+ .flatMap(Collection::stream)
+ .collect(toImmutableList());
+ }
+}
diff --git a/java/com/google/gerrit/server/restapi/config/AgreementJson.java b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
index e1e8e96..92dd489 100644
--- a/java/com/google/gerrit/server/restapi/config/AgreementJson.java
+++ b/java/com/google/gerrit/server/restapi/config/AgreementJson.java
@@ -15,7 +15,7 @@
package com.google.gerrit.server.restapi.config;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.entities.ContributorAgreement;
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.exceptions.NoSuchGroupException;
import com.google.gerrit.exceptions.StorageException;
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index 1f4b468..780c60a 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -19,7 +19,7 @@
import com.google.common.base.CharMatcher;
import com.google.common.base.Strings;
import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.entities.ContributorAgreement;
import com.google.gerrit.extensions.common.AccountDefaultDisplayName;
import com.google.gerrit.extensions.common.AccountsInfo;
import com.google.gerrit.extensions.common.AuthInfo;
@@ -238,8 +238,9 @@
info.mergeabilityComputationBehavior =
MergeabilityComputationBehavior.fromConfig(config).name();
info.enableAttentionSet =
- toBoolean(this.config.getBoolean("change", null, "enableAttentionSet", false));
- info.enableAssignee = toBoolean(this.config.getBoolean("change", null, "enableAssignee", true));
+ toBoolean(this.config.getBoolean("change", null, "enableAttentionSet", true));
+ info.enableAssignee =
+ toBoolean(this.config.getBoolean("change", null, "enableAssignee", false));
return info;
}
@@ -348,7 +349,7 @@
// Return non-null theme path without checking for file existence. Even if the file doesn't
// exist under the site path, it may be served from a CDN (in which case it's up to the admin
// to also pass a proper asset path to the index Soy template).
- return DEFAULT_THEME;
+ return DEFAULT_THEME_JS;
}
return null;
}
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 037a953..4ef724a 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -28,6 +28,7 @@
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.server.account.AccountResolver;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.permissions.DefaultPermissionMappings;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
@@ -73,60 +74,74 @@
throw new BadRequestException("input requires 'account'");
}
- Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
+ try (TraceContext traceContext = TraceContext.open()) {
+ traceContext.enableAclLogging();
- AccessCheckInfo info = new AccessCheckInfo();
- try {
- permissionBackend
- .absentUser(match)
- .project(rsrc.getNameKey())
- .check(ProjectPermission.ACCESS);
- } catch (AuthException e) {
- info.message = String.format("user %s cannot see project %s", match, rsrc.getName());
- info.status = HttpServletResponse.SC_FORBIDDEN;
- return Response.ok(info);
- }
+ Account.Id match = accountResolver.resolve(input.account).asUnique().account().id();
- RefPermission refPerm;
- if (!Strings.isNullOrEmpty(input.permission)) {
- if (Strings.isNullOrEmpty(input.ref)) {
- throw new BadRequestException("must set 'ref' when specifying 'permission'");
- }
- Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
- if (!rp.isPresent()) {
- throw new BadRequestException(
- String.format("'%s' is not recognized as ref permission", input.permission));
- }
-
- refPerm = rp.get();
- } else {
- refPerm = RefPermission.READ;
- }
-
- if (!Strings.isNullOrEmpty(input.ref)) {
try {
permissionBackend
.absentUser(match)
- .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
- .check(refPerm);
+ .project(rsrc.getNameKey())
+ .check(ProjectPermission.ACCESS);
} catch (AuthException e) {
- info.status = HttpServletResponse.SC_FORBIDDEN;
- info.message =
- String.format(
- "user %s lacks permission %s for %s in project %s",
- match, input.permission, input.ref, rsrc.getName());
- return Response.ok(info);
+ return Response.ok(
+ createInfo(
+ traceContext,
+ HttpServletResponse.SC_FORBIDDEN,
+ String.format("user %s cannot see project %s", match, rsrc.getName())));
}
- } else {
- // We say access is okay if there are no refs, but this warrants a warning,
- // as access denied looks the same as no branches to the user.
- try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
- if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
- info.message = "access is OK, but repository has no branches under refs/heads/";
+
+ RefPermission refPerm;
+ if (!Strings.isNullOrEmpty(input.permission)) {
+ if (Strings.isNullOrEmpty(input.ref)) {
+ throw new BadRequestException("must set 'ref' when specifying 'permission'");
+ }
+ Optional<RefPermission> rp = DefaultPermissionMappings.refPermission(input.permission);
+ if (!rp.isPresent()) {
+ throw new BadRequestException(
+ String.format("'%s' is not recognized as ref permission", input.permission));
+ }
+
+ refPerm = rp.get();
+ } else {
+ refPerm = RefPermission.READ;
+ }
+
+ String message = null;
+ if (!Strings.isNullOrEmpty(input.ref)) {
+ try {
+ permissionBackend
+ .absentUser(match)
+ .ref(BranchNameKey.create(rsrc.getNameKey(), input.ref))
+ .check(refPerm);
+ } catch (AuthException e) {
+ return Response.ok(
+ createInfo(
+ traceContext,
+ HttpServletResponse.SC_FORBIDDEN,
+ String.format(
+ "user %s lacks permission %s for %s in project %s",
+ match, input.permission, input.ref, rsrc.getName())));
+ }
+ } else {
+ // We say access is okay if there are no refs, but this warrants a warning,
+ // as access denied looks the same as no branches to the user.
+ try (Repository repo = gitRepositoryManager.openRepository(rsrc.getNameKey())) {
+ if (repo.getRefDatabase().getRefsByPrefix(REFS_HEADS).isEmpty()) {
+ message = "access is OK, but repository has no branches under refs/heads/";
+ }
}
}
+ return Response.ok(createInfo(traceContext, HttpServletResponse.SC_OK, message));
}
- info.status = HttpServletResponse.SC_OK;
- return Response.ok(info);
+ }
+
+ private AccessCheckInfo createInfo(TraceContext traceContext, int statusCode, String message) {
+ AccessCheckInfo info = new AccessCheckInfo();
+ info.status = statusCode;
+ info.message = message;
+ info.debugLogs = traceContext.getAclLogRecords();
+ return info;
}
}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index a87bbd1..eceab43 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -17,7 +17,7 @@
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 416eeb3..3e1ef49 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -15,8 +15,8 @@
package com.google.gerrit.server.restapi.project;
import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
import com.google.gerrit.extensions.common.LabelDefinitionInput;
diff --git a/java/com/google/gerrit/server/restapi/project/GetAccess.java b/java/com/google/gerrit/server/restapi/project/GetAccess.java
index f60601e..b572db3 100644
--- a/java/com/google/gerrit/server/restapi/project/GetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/GetAccess.java
@@ -26,11 +26,11 @@
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.access.AccessSectionInfo;
diff --git a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
index 82e34eb..0f49e63 100644
--- a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
+++ b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
@@ -16,10 +16,10 @@
import com.google.common.collect.ImmutableList;
import com.google.common.primitives.Shorts;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.InvalidNameException;
import com.google.gerrit.extensions.restapi.BadRequestException;
diff --git a/java/com/google/gerrit/server/restapi/project/LabelsCollection.java b/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
index 0409729..54179e5 100644
--- a/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/LabelsCollection.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.restapi.project;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ChildCollection;
diff --git a/java/com/google/gerrit/server/restapi/project/ListLabels.java b/java/com/google/gerrit/server/restapi/project/ListLabels.java
index 19a8915..56ee4cd 100644
--- a/java/com/google/gerrit/server/restapi/project/ListLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/ListLabels.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.restapi.project;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.Response;
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index 0c16822..5418876 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -71,6 +71,7 @@
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
@@ -434,8 +435,9 @@
PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
try {
- Iterable<ProjectState> projectStatesIt = filter(perm)::iterator;
- for (ProjectState e : projectStatesIt) {
+ Iterator<ProjectState> projectStatesIt = filter(perm).iterator();
+ while (projectStatesIt.hasNext()) {
+ ProjectState e = projectStatesIt.next();
Project.NameKey projectName = e.getNameKey();
if (e.getProject().getState() == HIDDEN && !all && state != HIDDEN) {
// If we can't get it from the cache, pretend it's not present.
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
index 8835359..0c42ab2 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -15,7 +15,7 @@
package com.google.gerrit.server.restapi.project;
import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.extensions.common.BatchLabelInput;
import com.google.gerrit.extensions.common.LabelDefinitionInput;
import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 658f57e..55ea312 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -41,7 +41,6 @@
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.EnableSignedPush;
import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.PluginConfig;
import com.google.gerrit.server.config.PluginConfigFactory;
import com.google.gerrit.server.config.ProjectConfigEntry;
import com.google.gerrit.server.extensions.webui.UiActions;
@@ -176,7 +175,7 @@
throw new ResourceConflictException("Cannot update " + projectName);
}
- ProjectState state = projectStateFactory.create(projectConfigFactory.read(md));
+ ProjectState state = projectStateFactory.create(projectConfigFactory.read(md).getCacheable());
return new ConfigInfoImpl(
serverEnableSignedPush,
state,
@@ -202,7 +201,6 @@
throws BadRequestException {
for (Map.Entry<String, Map<String, ConfigValue>> e : pluginConfigValues.entrySet()) {
String pluginName = e.getKey();
- PluginConfig cfg = projectConfig.getPluginConfig(pluginName);
for (Map.Entry<String, ConfigValue> v : e.getValue().entrySet()) {
ProjectConfigEntry projectConfigEntry = pluginConfigEntries.get(pluginName, v.getKey());
if (projectConfigEntry != null) {
@@ -213,10 +211,11 @@
v.getKey(), PARAMETER_NAME_PATTERN.pattern());
continue;
}
- String oldValue = cfg.getString(v.getKey());
+ String oldValue = projectConfig.getPluginConfig(pluginName).getString(v.getKey());
String value = v.getValue().value;
if (projectConfigEntry.getType() == ProjectConfigEntryType.ARRAY) {
- List<String> l = Arrays.asList(cfg.getStringList(v.getKey()));
+ List<String> l =
+ Arrays.asList(projectConfig.getPluginConfig(pluginName).getStringList(v.getKey()));
oldValue = Joiner.on("\n").join(l);
value = Joiner.on("\n").join(v.getValue().values);
}
@@ -230,15 +229,18 @@
switch (projectConfigEntry.getType()) {
case BOOLEAN:
boolean newBooleanValue = Boolean.parseBoolean(value);
- cfg.setBoolean(v.getKey(), newBooleanValue);
+ projectConfig.updatePluginConfig(
+ pluginName, cfg -> cfg.setBoolean(v.getKey(), newBooleanValue));
break;
case INT:
int newIntValue = Integer.parseInt(value);
- cfg.setInt(v.getKey(), newIntValue);
+ projectConfig.updatePluginConfig(
+ pluginName, cfg -> cfg.setInt(v.getKey(), newIntValue));
break;
case LONG:
long newLongValue = Long.parseLong(value);
- cfg.setLong(v.getKey(), newLongValue);
+ projectConfig.updatePluginConfig(
+ pluginName, cfg -> cfg.setLong(v.getKey(), newLongValue));
break;
case LIST:
if (!projectConfigEntry.getPermittedValues().contains(value)) {
@@ -252,10 +254,13 @@
}
// $FALL-THROUGH$
case STRING:
- cfg.setString(v.getKey(), value);
+ String valueToSet = value;
+ projectConfig.updatePluginConfig(
+ pluginName, cfg -> cfg.setString(v.getKey(), valueToSet));
break;
case ARRAY:
- cfg.setStringList(v.getKey(), v.getValue().values);
+ projectConfig.updatePluginConfig(
+ pluginName, cfg -> cfg.setStringList(v.getKey(), v.getValue().values));
break;
default:
logger.atWarning().log(
@@ -273,7 +278,7 @@
if (oldValue != null) {
validateProjectConfigEntryIsEditable(
projectConfigEntry, projectState, v.getKey(), pluginName);
- cfg.unset(v.getKey());
+ projectConfig.updatePluginConfig(pluginName, cfg -> cfg.unset(v.getKey()));
}
}
} else {
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccess.java b/java/com/google/gerrit/server/restapi/project/SetAccess.java
index 02c1b54..794cae8 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccess.java
@@ -16,7 +16,7 @@
import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.InvalidNameException;
import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
diff --git a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
index 572b798..65cc5a2 100644
--- a/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
+++ b/java/com/google/gerrit/server/restapi/project/SetAccessUtil.java
@@ -15,13 +15,13 @@
package com.google.gerrit.server.restapi.project;
import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.GroupDescription;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.InvalidNameException;
import com.google.gerrit.extensions.api.access.AccessSectionInfo;
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index ade274a..ffc591b 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -15,7 +15,7 @@
package com.google.gerrit.server.restapi.project;
import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
import com.google.gerrit.extensions.common.LabelDefinitionInput;
import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index 799d706..4592100 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -18,10 +18,10 @@
import static com.google.gerrit.server.project.ProjectCache.illegalState;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.extensions.config.FactoryModule;
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
index 132747d..b2bfbd5 100644
--- a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -18,12 +18,12 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.server.query.change.ChangeData;
diff --git a/java/com/google/gerrit/server/rules/PrologRule.java b/java/com/google/gerrit/server/rules/PrologRule.java
index 1861ee7..8f17fa1 100644
--- a/java/com/google/gerrit/server/rules/PrologRule.java
+++ b/java/com/google/gerrit/server/rules/PrologRule.java
@@ -16,8 +16,8 @@
import static com.google.gerrit.server.project.ProjectCache.illegalState;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 87f5758..57c4832 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -23,11 +23,11 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CharMatcher;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitTypeRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.server.account.AccountCache;
diff --git a/java/com/google/gerrit/server/rules/SubmitRule.java b/java/com/google/gerrit/server/rules/SubmitRule.java
index b221117..90d2137 100644
--- a/java/com/google/gerrit/server/rules/SubmitRule.java
+++ b/java/com/google/gerrit/server/rules/SubmitRule.java
@@ -13,7 +13,7 @@
// limitations under the License.
package com.google.gerrit.server.rules;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.annotations.ExtensionPoint;
import com.google.gerrit.server.query.change.ChangeData;
import java.util.Optional;
diff --git a/java/com/google/gerrit/server/schema/AclUtil.java b/java/com/google/gerrit/server/schema/AclUtil.java
index 6db93397..911756b 100644
--- a/java/com/google/gerrit/server/schema/AclUtil.java
+++ b/java/com/google/gerrit/server/schema/AclUtil.java
@@ -14,11 +14,11 @@
package com.google.gerrit.server.schema;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.server.project.ProjectConfig;
/**
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index cd3c945..6faaec5 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -23,12 +23,12 @@
import static com.google.gerrit.server.schema.AclUtil.rule;
import com.google.gerrit.common.Version;
-import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.config.AllProjectsName;
@@ -157,7 +157,7 @@
AccessSection.GLOBAL_CAPABILITIES,
capabilities -> {
input
- .batchUsersGroup()
+ .serviceUsersGroup()
.ifPresent(
batchUsersGroup ->
initDefaultAclsForBatchUsers(capabilities, config, batchUsersGroup));
diff --git a/java/com/google/gerrit/server/schema/AllProjectsInput.java b/java/com/google/gerrit/server/schema/AllProjectsInput.java
index bd405f7..daa24d8 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsInput.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsInput.java
@@ -18,9 +18,9 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.common.UsedAt;
-import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.extensions.client.InheritableBoolean;
import com.google.gerrit.server.notedb.Sequences;
@@ -63,7 +63,7 @@
public abstract Optional<GroupReference> administratorsGroup();
/** The group which gets stream-events permission granted and appropriate properties set. */
- public abstract Optional<GroupReference> batchUsersGroup();
+ public abstract Optional<GroupReference> serviceUsersGroup();
/** The commit message used when commit the project config change. */
public abstract Optional<String> commitMessage();
@@ -106,7 +106,7 @@
public abstract static class Builder {
public abstract Builder administratorsGroup(GroupReference adminGroup);
- public abstract Builder batchUsersGroup(GroupReference batchGroup);
+ public abstract Builder serviceUsersGroup(GroupReference serviceGroup);
public abstract Builder commitMessage(String commitMessage);
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 89fd3654d..90973fb 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -22,9 +22,9 @@
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.UsedAt;
import com.google.gerrit.common.Version;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.config.AllUsersName;
diff --git a/java/com/google/gerrit/server/schema/GrantRevertPermission.java b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
index 77513d3..f3404bc 100644
--- a/java/com/google/gerrit/server/schema/GrantRevertPermission.java
+++ b/java/com/google/gerrit/server/schema/GrantRevertPermission.java
@@ -18,9 +18,9 @@
import static com.google.gerrit.server.schema.AclUtil.grant;
import static com.google.gerrit.server.schema.AclUtil.remove;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
index c3c8f5e..d65268b 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
@@ -19,6 +19,7 @@
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@@ -30,7 +31,7 @@
* <p>Implementations must have a single non-private constructor with no arguments (e.g. the default
* constructor).
*/
-interface NoteDbSchemaVersion {
+public interface NoteDbSchemaVersion {
@Singleton
class Arguments {
final GitRepositoryManager repoManager;
@@ -39,6 +40,7 @@
final ProjectConfig.Factory projectConfigFactory;
final SystemGroupBackend systemGroupBackend;
final PersonIdent serverUser;
+ final GroupIndexCollection groupIndexCollection;
@Inject
Arguments(
@@ -47,13 +49,15 @@
AllUsersName allUsers,
ProjectConfig.Factory projectConfigFactory,
SystemGroupBackend systemGroupBackend,
- @GerritPersonIdent PersonIdent serverUser) {
+ @GerritPersonIdent PersonIdent serverUser,
+ GroupIndexCollection groupIndexCollection) {
this.repoManager = repoManager;
this.allProjects = allProjects;
this.allUsers = allUsers;
this.projectConfigFactory = projectConfigFactory;
this.systemGroupBackend = systemGroupBackend;
this.serverUser = serverUser;
+ this.groupIndexCollection = groupIndexCollection;
}
}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
index 97c9f3a..209ff89 100644
--- a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
@@ -28,7 +28,12 @@
public class NoteDbSchemaVersions {
static final ImmutableSortedMap<Integer, Class<? extends NoteDbSchemaVersion>> ALL =
// List all supported NoteDb schema versions here.
- Stream.of(Schema_180.class, Schema_181.class, Schema_182.class, Schema_183.class)
+ Stream.of(
+ Schema_180.class,
+ Schema_181.class,
+ Schema_182.class,
+ Schema_183.class,
+ Schema_184.class)
.collect(toImmutableSortedMap(naturalOrder(), v -> guessVersion(v).get(), v -> v));
public static final int FIRST = ALL.firstKey();
diff --git a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
index 21ce1d1..868e7ea 100644
--- a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
+++ b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
@@ -19,7 +19,7 @@
import com.google.common.annotations.VisibleForTesting;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.server.config.AllProjectsName;
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index f53f9a6..de9374e 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -23,6 +23,7 @@
import com.google.gerrit.metrics.MetricMaker;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -91,10 +92,13 @@
@Override
public void create() throws IOException, ConfigInvalidException {
GroupReference admins = createGroupReference("Administrators");
- GroupReference batchUsers = createGroupReference("Non-Interactive Users");
+ GroupReference serviceUsers = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
AllProjectsInput allProjectsInput =
- AllProjectsInput.builder().administratorsGroup(admins).batchUsersGroup(batchUsers).build();
+ AllProjectsInput.builder()
+ .administratorsGroup(admins)
+ .serviceUsersGroup(serviceUsers)
+ .build();
allProjectsCreator.create(allProjectsInput);
// We have to create the All-Users repository before we can use it to store the groups in it.
allUsersCreator.setAdministrators(admins).create();
@@ -111,7 +115,7 @@
metricMaker);
try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
createAdminsGroup(seqs, allUsersRepo, admins);
- createBatchUsersGroup(seqs, allUsersRepo, batchUsers, admins.getUUID());
+ createBatchUsersGroup(seqs, allUsersRepo, serviceUsers, admins.getUUID());
}
}
diff --git a/java/com/google/gerrit/server/schema/Schema_184.java b/java/com/google/gerrit/server/schema/Schema_184.java
new file mode 100644
index 0000000..c14ae8a
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_184.java
@@ -0,0 +1,118 @@
+// 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.schema;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.AuditLogFormatter;
+import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupNameNotes;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Schema 184 for Gerrit metadata.
+ *
+ * <p>Upgrading to this schema version will rename the {@code Non-Interactive Users} group to {@code
+ * Service Users}.
+ */
+public class Schema_184 implements NoteDbSchemaVersion {
+ @Override
+ public void upgrade(Arguments args, UpdateUI ui) throws Exception {
+ try (Repository allUsersRepo = args.repoManager.openRepository(args.allUsers)) {
+ AccountGroup.NameKey newName = AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS);
+ Optional<GroupReference> nonInteractiveUsers =
+ GroupNameNotes.loadAllGroups(allUsersRepo).stream()
+ .filter(g -> g.getName().equals("Non-Interactive Users"))
+ .findAny();
+ if (!nonInteractiveUsers.isPresent()) {
+ return;
+ }
+
+ GroupNameNotes newNameNotes =
+ GroupNameNotes.forRename(
+ args.allUsers,
+ allUsersRepo,
+ nonInteractiveUsers.get().getUUID(),
+ AccountGroup.nameKey(nonInteractiveUsers.get().getName()),
+ newName);
+ GroupConfig groupConfig =
+ GroupConfig.loadForGroup(
+ args.allUsers, allUsersRepo, nonInteractiveUsers.get().getUUID());
+ groupConfig.setGroupUpdate(
+ InternalGroupUpdate.builder().setName(newName).build(),
+ AuditLogFormatter.createPartiallyWorkingFallBack());
+ commit(args.allUsers, args.serverUser, allUsersRepo, groupConfig, newNameNotes);
+ index(
+ args.groupIndexCollection,
+ groupConfig
+ .getLoadedGroup()
+ .orElseThrow(
+ () -> new IllegalStateException("Created group wasn't automatically loaded")));
+ }
+ }
+
+ private void commit(
+ AllUsersName allUsersName,
+ PersonIdent serverUser,
+ Repository allUsersRepo,
+ GroupConfig groupConfig,
+ GroupNameNotes groupNameNotes)
+ throws IOException {
+ BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
+ try (MetaDataUpdate metaDataUpdate =
+ createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
+ groupConfig.commit(metaDataUpdate);
+ }
+ // MetaDataUpdates unfortunately can't be reused. -> Create a new one.
+ try (MetaDataUpdate metaDataUpdate =
+ createMetaDataUpdate(allUsersName, serverUser, allUsersRepo, batchRefUpdate)) {
+ groupNameNotes.commit(metaDataUpdate);
+ }
+ RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
+ }
+
+ private MetaDataUpdate createMetaDataUpdate(
+ AllUsersName allUsersName,
+ PersonIdent serverUser,
+ Repository allUsersRepo,
+ @Nullable BatchRefUpdate batchRefUpdate) {
+ MetaDataUpdate metaDataUpdate =
+ new MetaDataUpdate(
+ GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo, batchRefUpdate);
+ metaDataUpdate.getCommitBuilder().setAuthor(serverUser);
+ metaDataUpdate.getCommitBuilder().setCommitter(serverUser);
+ return metaDataUpdate;
+ }
+
+ private void index(GroupIndexCollection indexCollection, InternalGroup group) {
+ for (GroupIndex groupIndex : indexCollection.getWriteIndexes()) {
+ groupIndex.replace(group);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index 8b159bc..5485192 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -48,8 +48,8 @@
ImmutableList.of(
"[capability]",
" administrateServer = group Administrators",
- " priority = batch group Non-Interactive Users",
- " streamEvents = group Non-Interactive Users");
+ " priority = batch group Service Users",
+ " streamEvents = group Service Users");
private static final ImmutableList<String> DEFAULT_ALL_PROJECTS_ACCESS_SECTION =
ImmutableList.of(
"[access \"refs/*\"]",
diff --git a/java/com/google/gerrit/server/submit/CherryPick.java b/java/com/google/gerrit/server/submit/CherryPick.java
index b66006a..a09ba63 100644
--- a/java/com/google/gerrit/server/submit/CherryPick.java
+++ b/java/com/google/gerrit/server/submit/CherryPick.java
@@ -193,7 +193,7 @@
// was configured.
MergeTip mergeTip = args.mergeTip;
if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
- && !args.submoduleOp.hasSubscription(args.destBranch)) {
+ && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
mergeTip.moveTipTo(toMerge, toMerge);
} else {
PersonIdent myIdent = new PersonIdent(args.serverIdent, ctx.getWhen());
diff --git a/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java b/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java
new file mode 100644
index 0000000..3f3b544
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/ConfiguredSubscriptionGraphFactory.java
@@ -0,0 +1,53 @@
+// 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.submit;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Wrap a {@link SubscriptionGraph.Factory} to honor the gerrit configuration.
+ *
+ * <p>If superproject subscriptions are disabled in the conf, return an empty graph.
+ */
+public class ConfiguredSubscriptionGraphFactory implements SubscriptionGraph.Factory {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final SubscriptionGraph.Factory subscriptionGraphFactory;
+ private final Config cfg;
+
+ @Inject
+ ConfiguredSubscriptionGraphFactory(
+ @VanillaSubscriptionGraph SubscriptionGraph.Factory subscriptionGraphFactory,
+ @GerritServerConfig Config cfg) {
+ this.subscriptionGraphFactory = subscriptionGraphFactory;
+ this.cfg = cfg;
+ }
+
+ @Override
+ public SubscriptionGraph compute(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
+ throws SubmoduleConflictException {
+ if (cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true)) {
+ return subscriptionGraphFactory.compute(updatedBranches, orm);
+ }
+ logger.atFine().log("Updating superprojects disabled");
+ return SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
+ }
+}
diff --git a/java/com/google/gerrit/server/submit/GitlinkOp.java b/java/com/google/gerrit/server/submit/GitlinkOp.java
new file mode 100644
index 0000000..70a52b6
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/GitlinkOp.java
@@ -0,0 +1,64 @@
+// 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.submit;
+
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RepoOnlyOp;
+import java.util.Collection;
+import java.util.Optional;
+
+/** Only used for branches without code review changes */
+public class GitlinkOp implements RepoOnlyOp {
+
+ static class Factory {
+ private SubmoduleCommits submoduleCommits;
+ private SubscriptionGraph subscriptionGraph;
+
+ Factory(SubmoduleCommits submoduleCommits, SubscriptionGraph subscriptionGraph) {
+ this.submoduleCommits = submoduleCommits;
+ this.subscriptionGraph = subscriptionGraph;
+ }
+
+ GitlinkOp create(BranchNameKey branch) {
+ return new GitlinkOp(branch, submoduleCommits, subscriptionGraph.getSubscriptions(branch));
+ }
+ }
+
+ private final BranchNameKey branch;
+ private final SubmoduleCommits commitHelper;
+ private final Collection<SubmoduleSubscription> branchTargets;
+
+ GitlinkOp(
+ BranchNameKey branch,
+ SubmoduleCommits commitHelper,
+ Collection<SubmoduleSubscription> branchTargets) {
+ this.branch = branch;
+ this.commitHelper = commitHelper;
+ this.branchTargets = branchTargets;
+ }
+
+ @Override
+ public void updateRepo(RepoContext ctx) throws Exception {
+ Optional<CodeReviewCommit> commit = commitHelper.composeGitlinksCommit(branch, branchTargets);
+ if (commit.isPresent()) {
+ CodeReviewCommit c = commit.get();
+ ctx.addRefUpdate(c.getParent(0), c, branch.branch());
+ commitHelper.addBranchTip(branch, c);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index b8b8b55..0b05607 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -22,9 +22,9 @@
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitTypeRecord;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.registration.DynamicItem;
diff --git a/java/com/google/gerrit/server/submit/MergeIfNecessary.java b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
index 82499b3..30f1661 100644
--- a/java/com/google/gerrit/server/submit/MergeIfNecessary.java
+++ b/java/com/google/gerrit/server/submit/MergeIfNecessary.java
@@ -30,7 +30,7 @@
List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
if (args.mergeTip.getInitialTip() == null
- || !args.submoduleOp.hasSubscription(args.destBranch)) {
+ || !args.subscriptionGraph.hasSubscription(args.destBranch)) {
CodeReviewCommit firstFastForward =
args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
if (firstFastForward != null && !firstFastForward.equals(args.mergeTip.getInitialTip())) {
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index f96b0c5..01c7b75 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -24,6 +24,7 @@
import com.github.rholder.retry.RetryListener;
import com.google.auto.value.AutoValue;
import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSetMultimap;
@@ -32,15 +33,15 @@
import com.google.common.collect.SetMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
-import com.google.gerrit.common.data.SubmitTypeRecord;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitTypeRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.SubmitInput;
@@ -76,6 +77,9 @@
import com.google.gerrit.server.update.BatchUpdateOp;
import com.google.gerrit.server.update.ChangeContext;
import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.SubmissionExecutor;
+import com.google.gerrit.server.update.SubmissionListener;
+import com.google.gerrit.server.update.SuperprojectUpdateOnSubmission;
import com.google.gerrit.server.update.UpdateException;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.inject.Inject;
@@ -226,7 +230,9 @@
private final MergeValidators.Factory mergeValidatorsFactory;
private final Provider<InternalChangeQuery> queryProvider;
private final SubmitStrategyFactory submitStrategyFactory;
- private final SubmoduleOp.Factory subOpFactory;
+ private final SubscriptionGraph.Factory subscriptionGraphFactory;
+ private final SubmoduleCommits.Factory submoduleCommitsFactory;
+ private final ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners;
private final Provider<MergeOpRepoManager> ormProvider;
private final NotifyResolver notifyResolver;
private final RetryHelper retryHelper;
@@ -256,7 +262,10 @@
MergeValidators.Factory mergeValidatorsFactory,
Provider<InternalChangeQuery> queryProvider,
SubmitStrategyFactory submitStrategyFactory,
- SubmoduleOp.Factory subOpFactory,
+ SubmoduleCommits.Factory submoduleCommitsFactory,
+ SubscriptionGraph.Factory subscriptionGraphFactory,
+ @SuperprojectUpdateOnSubmission
+ ImmutableList<SubmissionListener> superprojectUpdateSubmissionListeners,
Provider<MergeOpRepoManager> ormProvider,
NotifyResolver notifyResolver,
TopicMetrics topicMetrics,
@@ -269,7 +278,9 @@
this.mergeValidatorsFactory = mergeValidatorsFactory;
this.queryProvider = queryProvider;
this.submitStrategyFactory = submitStrategyFactory;
- this.subOpFactory = subOpFactory;
+ this.submoduleCommitsFactory = submoduleCommitsFactory;
+ this.subscriptionGraphFactory = subscriptionGraphFactory;
+ this.superprojectUpdateSubmissionListeners = superprojectUpdateSubmissionListeners;
this.ormProvider = ormProvider;
this.notifyResolver = notifyResolver;
this.retryHelper = retryHelper;
@@ -342,7 +353,7 @@
}
if (record.requirements != null) {
record.requirements.stream()
- .map(SubmitRequirement::fallbackText)
+ .map(MergeOp::describeSubmitRequirement)
.forEach(blockingConditions::add);
}
return Joiner.on("; ").join(blockingConditions);
@@ -378,6 +389,10 @@
return Joiner.on("; ").join(labelResults);
}
+ private static String describeSubmitRequirement(SubmitRequirement submitRequirement) {
+ return String.format("Submit requirement not fulfilled: %s", submitRequirement.fallbackText());
+ }
+
private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
throws ResourceConflictException {
checkArgument(
@@ -487,6 +502,8 @@
topicMetrics.topicSubmissions.increment();
}
+ SubmissionExecutor submissionExecutor =
+ new SubmissionExecutor(dryrun, superprojectUpdateSubmissionListeners);
RetryTracker retryTracker = new RetryTracker();
retryHelper
.changeUpdate(
@@ -507,7 +524,7 @@
logger.atFine().log("Bypassing submit rules");
bypassSubmitRules(cs, isRetry);
}
- integrateIntoHistory(cs);
+ integrateIntoHistory(cs, submissionExecutor);
return null;
})
.listener(retryTracker)
@@ -516,6 +533,7 @@
// submit.
.defaultTimeoutMultiplier(cs.projects().size())
.call();
+ submissionExecutor.afterExecutions(orm);
if (projects > 1) {
topicMetrics.topicSubmissionsCompleted.increment();
@@ -581,7 +599,8 @@
}
}
- private void integrateIntoHistory(ChangeSet cs) throws RestApiException, UpdateException {
+ private void integrateIntoHistory(ChangeSet cs, SubmissionExecutor submissionExecutor)
+ throws RestApiException, UpdateException {
checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
logger.atFine().log("Beginning merge attempt on %s", cs);
Map<BranchNameKey, BranchBatch> toSubmit = new HashMap<>();
@@ -605,19 +624,28 @@
commitStatus.maybeFailVerbose();
try {
- SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
- List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
- this.allProjects = submoduleOp.getProjectsInOrder();
+ SubscriptionGraph subscriptionGraph = subscriptionGraphFactory.compute(branches, orm);
+ SubmoduleCommits submoduleCommits = submoduleCommitsFactory.create(orm);
+ UpdateOrderCalculator updateOrderCalculator = new UpdateOrderCalculator(subscriptionGraph);
+ List<SubmitStrategy> strategies =
+ getSubmitStrategies(
+ toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
+ this.allProjects = updateOrderCalculator.getProjectsInOrder();
+ List<BatchUpdate> batchUpdates = orm.batchUpdates(allProjects);
try {
- BatchUpdate.execute(
- orm.batchUpdates(allProjects),
- new SubmitStrategyListener(submitInput, strategies, commitStatus),
- dryrun);
+ submissionExecutor.setAdditionalBatchUpdateListeners(
+ ImmutableList.of(new SubmitStrategyListener(submitInput, strategies, commitStatus)));
+ submissionExecutor.execute(batchUpdates);
} finally {
// If the BatchUpdate fails it can be that merging some of the changes was actually
- // successful. This is why we must to collect the updated changes also when an exception was
- // thrown.
+ // successful. This is why we must to collect the updated changes also when an
+ // exception was thrown.
strategies.forEach(s -> updatedChanges.putAll(s.getUpdatedChanges()));
+
+ // Do not leave executed BatchUpdates in the OpenRepos
+ if (!dryrun) {
+ orm.resetUpdates(ImmutableSet.copyOf(this.allProjects));
+ }
}
} catch (NoSuchProjectException e) {
throw new ResourceNotFoundException(e.getMessage());
@@ -658,12 +686,17 @@
}
private List<SubmitStrategy> getSubmitStrategies(
- Map<BranchNameKey, BranchBatch> toSubmit, SubmoduleOp submoduleOp, boolean dryrun)
+ Map<BranchNameKey, BranchBatch> toSubmit,
+ UpdateOrderCalculator updateOrderCalculator,
+ SubmoduleCommits submoduleCommits,
+ SubscriptionGraph subscriptionGraph,
+ boolean dryrun)
throws IntegrationConflictException, NoSuchProjectException, IOException {
List<SubmitStrategy> strategies = new ArrayList<>();
- Set<BranchNameKey> allBranches = submoduleOp.getBranchesInOrder();
+ Set<BranchNameKey> allBranches = updateOrderCalculator.getBranchesInOrder();
Set<CodeReviewCommit> allCommits =
toSubmit.values().stream().map(BranchBatch::commits).flatMap(Set::stream).collect(toSet());
+
for (BranchNameKey branch : allBranches) {
OpenRepo or = orm.getRepo(branch.project());
if (toSubmit.containsKey(branch)) {
@@ -688,20 +721,14 @@
commitStatus,
submissionId,
submitInput,
- submoduleOp,
+ submoduleCommits,
+ subscriptionGraph,
dryrun);
strategies.add(strategy);
strategy.addOps(or.getUpdate(), commitsToSubmit);
- if (submitting.submitType().equals(SubmitType.FAST_FORWARD_ONLY)
- && submoduleOp.hasSubscription(branch)) {
- submoduleOp.addOp(or.getUpdate(), branch);
- }
- } else {
- // no open change for this branch
- // add submodule triggered op into BatchUpdate
- submoduleOp.addOp(or.getUpdate(), branch);
}
}
+
return strategies;
}
@@ -736,7 +763,7 @@
@Nullable
abstract SubmitType submitType();
- abstract Set<CodeReviewCommit> commits();
+ abstract ImmutableSet<CodeReviewCommit> commits();
}
private BranchBatch validateChangeList(OpenRepo or, Collection<ChangeData> submitted) {
@@ -842,7 +869,8 @@
MergeValidators mergeValidators = mergeValidatorsFactory.create();
try {
- mergeValidators.validatePreMerge(or.repo, commit, or.project, destBranch, ps.id(), caller);
+ mergeValidators.validatePreMerge(
+ or.repo, or.rw, commit, or.project, destBranch, ps.id(), caller);
} catch (MergeValidationException mve) {
commitStatus.problem(changeId, mve.getMessage());
continue;
@@ -851,7 +879,7 @@
toSubmit.add(commit);
}
logger.atFine().log("Submitting on this run: %s", toSubmit);
- return new AutoValue_MergeOp_BranchBatch(submitType, toSubmit);
+ return new AutoValue_MergeOp_BranchBatch(submitType, ImmutableSet.copyOf(toSubmit));
}
private SetMultimap<ObjectId, PatchSet.Id> getRevisions(OpenRepo or, Collection<ChangeData> cds) {
diff --git a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
index b32c712..8981b07 100644
--- a/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
+++ b/java/com/google/gerrit/server/submit/MergeOpRepoManager.java
@@ -18,6 +18,7 @@
import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
import static java.util.Objects.requireNonNull;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Project;
@@ -119,6 +120,14 @@
return update;
}
+ // We want to reuse the open repo BUT not the BatchUpdate (because they are already executed)
+ public void resetExecutedUpdates() {
+ if (update != null && update.isExecuted()) {
+ update.close();
+ update = null;
+ }
+ }
+
private void close() {
if (update != null) {
update.close();
@@ -206,6 +215,13 @@
return updates;
}
+ public void resetUpdates(ImmutableSet<Project.NameKey> projects)
+ throws NoSuchProjectException, IOException {
+ for (Project.NameKey project : projects) {
+ getRepo(project).resetExecutedUpdates();
+ }
+ }
+
@Override
public void close() {
for (OpenRepo repo : openRepos.values()) {
diff --git a/java/com/google/gerrit/server/submit/MergeSuperSet.java b/java/com/google/gerrit/server/submit/MergeSuperSet.java
index 93c78a8..67f2907 100644
--- a/java/com/google/gerrit/server/submit/MergeSuperSet.java
+++ b/java/com/google/gerrit/server/submit/MergeSuperSet.java
@@ -18,19 +18,16 @@
import static java.util.Objects.requireNonNull;
import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.logging.TraceContext;
-import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.plugincontext.PluginContext;
-import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
@@ -55,8 +52,6 @@
* included.
*/
public class MergeSuperSet {
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
private final ChangeData.Factory changeDataFactory;
private final Provider<InternalChangeQuery> queryProvider;
private final Provider<MergeOpRepoManager> repoManagerProvider;
@@ -64,7 +59,6 @@
private final PermissionBackend permissionBackend;
private final Config cfg;
private final ProjectCache projectCache;
- private final ChangeNotes.Factory notesFactory;
private MergeOpRepoManager orm;
private boolean closeOrm;
@@ -77,8 +71,7 @@
Provider<MergeOpRepoManager> repoManagerProvider,
DynamicItem<MergeSuperSetComputation> mergeSuperSetComputation,
PermissionBackend permissionBackend,
- ProjectCache projectCache,
- ChangeNotes.Factory notesFactory) {
+ ProjectCache projectCache) {
this.cfg = cfg;
this.changeDataFactory = changeDataFactory;
this.queryProvider = queryProvider;
@@ -86,7 +79,6 @@
this.mergeSuperSetComputation = mergeSuperSetComputation;
this.permissionBackend = permissionBackend;
this.projectCache = projectCache;
- this.notesFactory = notesFactory;
}
public static boolean wholeTopicEnabled(Config config) {
@@ -212,24 +204,8 @@
if (!projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false)) {
return false;
}
-
- ChangeNotes notes;
try {
- notes = cd.notes();
- } catch (NoSuchChangeException e) {
- // The change was found in the index but is missing in NoteDb.
- // This can happen in systems with multiple primary nodes when the replication of the index
- // documents is faster than the replication of the Git data.
- // Instead of failing, create the change notes from the index data so that the read permission
- // check can be performed successfully.
- logger.atWarning().log(
- "Got change %d of project %s from index, but couldn't find it in NoteDb",
- cd.getId().get(), cd.project().get());
- notes = notesFactory.createFromIndexedChange(cd.change());
- }
-
- try {
- permissionBackend.user(user).change(notes).check(ChangePermission.READ);
+ permissionBackend.user(user).change(cd).check(ChangePermission.READ);
return true;
} catch (AuthException e) {
return false;
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index edc3725..db48cce 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -298,7 +298,7 @@
// merge commits.
MergeTip mergeTip = args.mergeTip;
if (args.rw.isMergedInto(mergeTip.getCurrentTip(), toMerge)
- && !args.submoduleOp.hasSubscription(args.destBranch)) {
+ && !args.subscriptionGraph.hasSubscription(args.destBranch)) {
mergeTip.moveTipTo(toMerge, toMerge);
} else {
PersonIdent caller =
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 4010ad7..21ff2fc 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -97,7 +97,8 @@
Set<CodeReviewCommit> incoming,
SubmissionId submissionId,
SubmitInput submitInput,
- SubmoduleOp submoduleOp,
+ SubmoduleCommits submoduleCommits,
+ SubscriptionGraph subscriptionGraph,
boolean dryrun);
}
@@ -129,7 +130,8 @@
final SubmissionId submissionId;
final SubmitType submitType;
final SubmitInput submitInput;
- final SubmoduleOp submoduleOp;
+ final SubscriptionGraph subscriptionGraph;
+ final SubmoduleCommits submoduleCommits;
final ProjectState project;
final MergeSorter mergeSorter;
@@ -168,7 +170,8 @@
@Assisted SubmissionId submissionId,
@Assisted SubmitType submitType,
@Assisted SubmitInput submitInput,
- @Assisted SubmoduleOp submoduleOp,
+ @Assisted SubscriptionGraph subscriptionGraph,
+ @Assisted SubmoduleCommits submoduleCommits,
@Assisted boolean dryrun) {
this.accountCache = accountCache;
this.approvalsUtil = approvalsUtil;
@@ -197,7 +200,8 @@
this.submissionId = submissionId;
this.submitType = submitType;
this.submitInput = submitInput;
- this.submoduleOp = submoduleOp;
+ this.submoduleCommits = submoduleCommits;
+ this.subscriptionGraph = subscriptionGraph;
this.dryrun = dryrun;
this.project =
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
index 1cc78ff..2e66ae2 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyFactory.java
@@ -55,7 +55,8 @@
CommitStatus commitStatus,
SubmissionId submissionId,
SubmitInput submitInput,
- SubmoduleOp submoduleOp,
+ SubmoduleCommits submoduleCommits,
+ SubscriptionGraph subscriptionGraph,
boolean dryrun) {
SubmitStrategy.Arguments args =
argsFactory.create(
@@ -70,7 +71,8 @@
incoming,
submissionId,
submitInput,
- submoduleOp,
+ submoduleCommits,
+ subscriptionGraph,
dryrun);
switch (submitType) {
case CHERRY_PICK:
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index ab28aa9..3430047 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -22,7 +22,6 @@
import static java.util.Objects.requireNonNull;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
@@ -31,6 +30,7 @@
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.server.ApprovalsUtil;
import com.google.gerrit.server.ChangeMessagesUtil;
@@ -133,7 +133,7 @@
new ReceiveCommand(
firstNonNull(tipBefore, ObjectId.zeroId()), tipAfter, getDest().branch());
ctx.addRefUpdate(command);
- args.submoduleOp.addBranchTip(getDest(), tipAfter);
+ args.submoduleCommits.addBranchTip(getDest(), tipAfter);
}
private void checkProjectConfig(RepoContext ctx, CodeReviewCommit commit) {
@@ -461,9 +461,12 @@
// If we naively execute postUpdate even if the change is already merged when updateChange
// being, then we are subject to a race where postUpdate steps are run twice if two submit
// processes run at the same time.
- logger.atFine().log("Skipping post-update steps for change %s", getId());
+ logger.atFine().log(
+ "Skipping post-update steps for change %s; submitter is %s", getId(), submitter);
return;
}
+ logger.atFine().log(
+ "Begin post-update steps for change %s; submitter is %s", getId(), submitter);
postUpdateImpl(ctx);
if (command != null) {
@@ -483,6 +486,9 @@
}
}
+ logger.atFine().log(
+ "Begin sending emails for submitting change %s; submitter is %s", getId(), submitter);
+
// Assume the change must have been merged at this point, otherwise we would
// have failed fast in one of the other steps.
try {
@@ -535,13 +541,14 @@
*/
protected CodeReviewCommit amendGitlink(CodeReviewCommit commit)
throws IntegrationConflictException {
- if (!args.submoduleOp.hasSubscription(args.destBranch)) {
+ if (!args.subscriptionGraph.hasSubscription(args.destBranch)) {
return commit;
}
// Modify the commit with gitlink update
try {
- return args.submoduleOp.amendGitlinksCommit(args.destBranch, commit);
+ return args.submoduleCommits.amendGitlinksCommit(
+ args.destBranch, commit, args.subscriptionGraph.getSubscriptions(args.destBranch));
} catch (IOException e) {
throw new StorageException(
String.format("cannot update gitlink for the commit at branch %s", args.destBranch), e);
diff --git a/java/com/google/gerrit/server/submit/SubmoduleCommits.java b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
new file mode 100644
index 0000000..1312a4b
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/SubmoduleCommits.java
@@ -0,0 +1,356 @@
+// 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.submit;
+
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import org.apache.commons.lang.StringUtils;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Create commit or amend existing one updating gitlinks. */
+class SubmoduleCommits {
+
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final PersonIdent myIdent;
+ private final VerboseSuperprojectUpdate verboseSuperProject;
+ private final MergeOpRepoManager orm;
+ private final long maxCombinedCommitMessageSize;
+ private final long maxCommitMessages;
+ private final BranchTips branchTips = new BranchTips();
+
+ @Singleton
+ public static class Factory {
+ private final Provider<PersonIdent> serverIdent;
+ private final Config cfg;
+
+ @Inject
+ Factory(@GerritPersonIdent Provider<PersonIdent> serverIdent, @GerritServerConfig Config cfg) {
+ this.serverIdent = serverIdent;
+ this.cfg = cfg;
+ }
+
+ public SubmoduleCommits create(MergeOpRepoManager orm) {
+ return new SubmoduleCommits(orm, serverIdent.get(), cfg);
+ }
+ }
+
+ SubmoduleCommits(MergeOpRepoManager orm, PersonIdent myIdent, Config cfg) {
+ this.orm = orm;
+ this.myIdent = myIdent;
+ this.verboseSuperProject =
+ cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
+ this.maxCombinedCommitMessageSize =
+ cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
+ this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
+ }
+
+ /**
+ * Use the commit as tip of the branch
+ *
+ * <p>This keeps track of the tip of the branch as the submission progresses.
+ */
+ void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
+ branchTips.put(branch, tip);
+ }
+
+ /**
+ * Create a separate gitlink commit
+ *
+ * @param subscriber superproject (and branch)
+ * @param subscriptions subprojects the superproject is subscribed to
+ * @return a new commit on top of subscriber with gitlinks update to the tips of the subprojects;
+ * empty if nothing has changed. Subproject tips are read from the cached branched tips
+ * (defaulting to the mergeOpRepoManager).
+ */
+ Optional<CodeReviewCommit> composeGitlinksCommit(
+ BranchNameKey subscriber, Collection<SubmoduleSubscription> subscriptions)
+ throws IOException, SubmoduleConflictException {
+ OpenRepo or;
+ try {
+ or = orm.getRepo(subscriber.project());
+ } catch (NoSuchProjectException | IOException e) {
+ throw new StorageException("Cannot access superproject", e);
+ }
+
+ CodeReviewCommit currentCommit =
+ branchTips
+ .getTip(subscriber, or)
+ .orElseThrow(
+ () ->
+ new SubmoduleConflictException(
+ "The branch was probably deleted from the subscriber repository"));
+
+ StringBuilder msgbuf = new StringBuilder();
+ PersonIdent author = null;
+ DirCache dc = readTree(or.getCodeReviewRevWalk(), currentCommit);
+ DirCacheEditor ed = dc.editor();
+ int count = 0;
+
+ for (SubmoduleSubscription s : sortByPath(subscriptions)) {
+ if (count > 0) {
+ msgbuf.append("\n\n");
+ }
+ RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
+ count++;
+ if (newCommit != null) {
+ PersonIdent newCommitAuthor = newCommit.getAuthorIdent();
+ if (author == null) {
+ author = new PersonIdent(newCommitAuthor, myIdent.getWhen());
+ } else if (!author.getName().equals(newCommitAuthor.getName())
+ || !author.getEmailAddress().equals(newCommitAuthor.getEmailAddress())) {
+ author = myIdent;
+ }
+ }
+ }
+ ed.finish();
+ ObjectId newTreeId = dc.writeTree(or.ins);
+
+ // Gitlinks are already in the branch, return null
+ if (newTreeId.equals(currentCommit.getTree())) {
+ return Optional.empty();
+ }
+ CommitBuilder commit = new CommitBuilder();
+ commit.setTreeId(newTreeId);
+ commit.setParentId(currentCommit);
+ StringBuilder commitMsg = new StringBuilder("Update git submodules\n\n");
+ if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+ commitMsg.append(msgbuf);
+ }
+ commit.setMessage(commitMsg.toString());
+ commit.setAuthor(author);
+ commit.setCommitter(myIdent);
+ ObjectId id = or.ins.insert(commit);
+ return Optional.of(or.getCodeReviewRevWalk().parseCommit(id));
+ }
+
+ /** Amend an existing commit with gitlink updates */
+ CodeReviewCommit amendGitlinksCommit(
+ BranchNameKey subscriber,
+ CodeReviewCommit currentCommit,
+ Collection<SubmoduleSubscription> subscriptions)
+ throws IOException, SubmoduleConflictException {
+ OpenRepo or;
+ try {
+ or = orm.getRepo(subscriber.project());
+ } catch (NoSuchProjectException | IOException e) {
+ throw new StorageException("Cannot access superproject", e);
+ }
+
+ StringBuilder msgbuf = new StringBuilder();
+ DirCache dc = readTree(or.rw, currentCommit);
+ DirCacheEditor ed = dc.editor();
+ for (SubmoduleSubscription s : sortByPath(subscriptions)) {
+ updateSubmodule(dc, ed, msgbuf, s);
+ }
+ ed.finish();
+ ObjectId newTreeId = dc.writeTree(or.ins);
+
+ // Gitlinks are already updated, just return the commit
+ if (newTreeId.equals(currentCommit.getTree())) {
+ return currentCommit;
+ }
+ or.rw.parseBody(currentCommit);
+ CommitBuilder commit = new CommitBuilder();
+ commit.setTreeId(newTreeId);
+ commit.setParentIds(currentCommit.getParents());
+ if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+ // TODO(czhen): handle cherrypick footer
+ commit.setMessage(currentCommit.getFullMessage() + "\n\n* submodules:\n" + msgbuf.toString());
+ } else {
+ commit.setMessage(currentCommit.getFullMessage());
+ }
+ commit.setAuthor(currentCommit.getAuthorIdent());
+ commit.setCommitter(myIdent);
+ ObjectId id = or.ins.insert(commit);
+ CodeReviewCommit newCommit = or.getCodeReviewRevWalk().parseCommit(id);
+ newCommit.copyFrom(currentCommit);
+ return newCommit;
+ }
+
+ private RevCommit updateSubmodule(
+ DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
+ throws SubmoduleConflictException, IOException {
+ logger.atFine().log("Updating gitlink for %s", s);
+ OpenRepo subOr;
+ try {
+ subOr = orm.getRepo(s.getSubmodule().project());
+ } catch (NoSuchProjectException | IOException e) {
+ throw new StorageException("Cannot access submodule", e);
+ }
+
+ DirCacheEntry dce = dc.getEntry(s.getPath());
+ RevCommit oldCommit = null;
+ if (dce != null) {
+ if (!dce.getFileMode().equals(FileMode.GITLINK)) {
+ String errMsg =
+ "Requested to update gitlink "
+ + s.getPath()
+ + " in "
+ + s.getSubmodule().project().get()
+ + " but entry "
+ + "doesn't have gitlink file mode.";
+ throw new SubmoduleConflictException(errMsg);
+ }
+ // Parse the current gitlink entry commit in the subproject repo. This is used to add a
+ // shortlog for this submodule to the commit message in the superproject.
+ //
+ // Even if we don't strictly speaking need that commit message, parsing the commit is a sanity
+ // check that the old gitlink is a commit that actually exists. If not, then there is an
+ // inconsistency between the superproject and subproject state, and we don't want to risk
+ // making things worse by updating the gitlink to something else.
+ try {
+ oldCommit = subOr.getCodeReviewRevWalk().parseCommit(dce.getObjectId());
+ } catch (IOException e) {
+ // Broken gitlink; sanity check failed. Warn and continue so the submit operation can
+ // proceed, it will just skip this gitlink update.
+ logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
+ return null;
+ }
+ }
+
+ Optional<CodeReviewCommit> maybeNewCommit = branchTips.getTip(s.getSubmodule(), subOr);
+ if (!maybeNewCommit.isPresent()) {
+ // For whatever reason, this submodule was not updated as part of this submit batch, but the
+ // superproject is still subscribed to this branch. Re-read the ref to see if anything has
+ // changed since the last time the gitlink was updated, and roll that update into the same
+ // commit as all other submodule updates.
+ ed.add(new DeletePath(s.getPath()));
+ return null;
+ }
+
+ CodeReviewCommit newCommit = maybeNewCommit.get();
+ if (Objects.equals(newCommit, oldCommit)) {
+ // gitlink have already been updated for this submodule
+ return null;
+ }
+ ed.add(
+ new PathEdit(s.getPath()) {
+ @Override
+ public void apply(DirCacheEntry ent) {
+ ent.setFileMode(FileMode.GITLINK);
+ ent.setObjectId(newCommit.getId());
+ }
+ });
+
+ if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
+ createSubmoduleCommitMsg(msgbuf, s, subOr, newCommit, oldCommit);
+ }
+ subOr.getCodeReviewRevWalk().parseBody(newCommit);
+ return newCommit;
+ }
+
+ private void createSubmoduleCommitMsg(
+ StringBuilder msgbuf,
+ SubmoduleSubscription s,
+ OpenRepo subOr,
+ RevCommit newCommit,
+ RevCommit oldCommit) {
+ msgbuf.append("* Update ");
+ msgbuf.append(s.getPath());
+ msgbuf.append(" from branch '");
+ msgbuf.append(s.getSubmodule().shortName());
+ msgbuf.append("'");
+ msgbuf.append("\n to ");
+ msgbuf.append(newCommit.getName());
+
+ // newly created submodule gitlink, do not append whole history
+ if (oldCommit == null) {
+ return;
+ }
+
+ try {
+ subOr.rw.resetRetain(subOr.canMergeFlag);
+ subOr.rw.markStart(newCommit);
+ subOr.rw.markUninteresting(oldCommit);
+ int numMessages = 0;
+ for (Iterator<RevCommit> iter = subOr.rw.iterator(); iter.hasNext(); ) {
+ RevCommit c = iter.next();
+ subOr.rw.parseBody(c);
+
+ String message =
+ verboseSuperProject == VerboseSuperprojectUpdate.SUBJECT_ONLY
+ ? c.getShortMessage()
+ : StringUtils.replace(c.getFullMessage(), "\n", "\n ");
+
+ String bullet = "\n - ";
+ String ellipsis = "\n\n[...]";
+ int newSize = msgbuf.length() + bullet.length() + message.length();
+ if (++numMessages > maxCommitMessages
+ || newSize > maxCombinedCommitMessageSize
+ || (iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize)) {
+ msgbuf.append(ellipsis);
+ break;
+ }
+ msgbuf.append(bullet);
+ msgbuf.append(message);
+ }
+ } catch (IOException e) {
+ throw new StorageException(
+ "Could not perform a revwalk to create superproject commit message", e);
+ }
+ }
+
+ private static DirCache readTree(RevWalk rw, ObjectId base) throws IOException {
+ final DirCache dc = DirCache.newInCore();
+ final DirCacheBuilder b = dc.builder();
+ b.addTree(
+ new byte[0], // no prefix path
+ DirCacheEntry.STAGE_0, // standard stage
+ rw.getObjectReader(),
+ rw.parseTree(base));
+ b.finish();
+ return dc;
+ }
+
+ private static List<SubmoduleSubscription> sortByPath(
+ Collection<SubmoduleSubscription> subscriptions) {
+ return subscriptions.stream()
+ .sorted(comparing(SubmoduleSubscription::getPath))
+ .collect(toList());
+ }
+}
diff --git a/java/com/google/gerrit/server/submit/SubmoduleOp.java b/java/com/google/gerrit/server/submit/SubmoduleOp.java
index a1ed373..69d76e2 100644
--- a/java/com/google/gerrit/server/submit/SubmoduleOp.java
+++ b/java/com/google/gerrit/server/submit/SubmoduleOp.java
@@ -14,145 +14,84 @@
package com.google.gerrit.server.submit;
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.SubmoduleSubscription;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.VerboseSuperprojectUpdate;
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdateListener;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.update.RepoOnlyOp;
import com.google.gerrit.server.update.UpdateException;
import com.google.inject.Inject;
-import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.Iterator;
import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.Set;
-import org.apache.commons.lang.StringUtils;
-import org.eclipse.jgit.dircache.DirCache;
-import org.eclipse.jgit.dircache.DirCacheBuilder;
-import org.eclipse.jgit.dircache.DirCacheEditor;
-import org.eclipse.jgit.dircache.DirCacheEditor.DeletePath;
-import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
-import org.eclipse.jgit.dircache.DirCacheEntry;
-import org.eclipse.jgit.lib.CommitBuilder;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.FileMode;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
+import java.util.Map;
+import org.eclipse.jgit.transport.ReceiveCommand;
public class SubmoduleOp {
- private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
- /** Only used for branches without code review changes */
- public class GitlinkOp implements RepoOnlyOp {
- private final BranchNameKey branch;
- private final BranchTips currentBranchTips;
-
- GitlinkOp(BranchNameKey branch, BranchTips branchTips) {
- this.branch = branch;
- this.currentBranchTips = branchTips;
- }
-
- @Override
- public void updateRepo(RepoContext ctx) throws Exception {
- CodeReviewCommit c = composeGitlinksCommit(branch);
- if (c != null) {
- ctx.addRefUpdate(c.getParent(0), c, branch.branch());
- currentBranchTips.put(branch, c);
- }
- }
- }
@Singleton
public static class Factory {
private final SubscriptionGraph.Factory subscriptionGraphFactory;
- private final Provider<PersonIdent> serverIdent;
- private final Config cfg;
+ private final SubmoduleCommits.Factory submoduleCommitsFactory;
@Inject
Factory(
SubscriptionGraph.Factory subscriptionGraphFactory,
- @GerritPersonIdent Provider<PersonIdent> serverIdent,
- @GerritServerConfig Config cfg) {
+ SubmoduleCommits.Factory submoduleCommitsFactory) {
this.subscriptionGraphFactory = subscriptionGraphFactory;
- this.serverIdent = serverIdent;
- this.cfg = cfg;
+ this.submoduleCommitsFactory = submoduleCommitsFactory;
}
- public SubmoduleOp create(Set<BranchNameKey> updatedBranches, MergeOpRepoManager orm)
+ public SubmoduleOp create(
+ Map<BranchNameKey, ReceiveCommand> updatedBranches, MergeOpRepoManager orm)
throws SubmoduleConflictException {
- SubscriptionGraph subscriptionGraph;
- if (cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true)) {
- subscriptionGraph = subscriptionGraphFactory.compute(updatedBranches, orm);
- } else {
- logger.atFine().log("Updating superprojects disabled");
- subscriptionGraph =
- SubscriptionGraph.createEmptyGraph(ImmutableSet.copyOf(updatedBranches));
- }
- return new SubmoduleOp(serverIdent.get(), cfg, orm, subscriptionGraph);
+ return new SubmoduleOp(
+ updatedBranches,
+ orm,
+ subscriptionGraphFactory.compute(updatedBranches.keySet(), orm),
+ submoduleCommitsFactory.create(orm));
}
}
- private final PersonIdent myIdent;
- private final VerboseSuperprojectUpdate verboseSuperProject;
- private final long maxCombinedCommitMessageSize;
- private final long maxCommitMessages;
+ private final Map<BranchNameKey, ReceiveCommand> updatedBranches;
private final MergeOpRepoManager orm;
private final SubscriptionGraph subscriptionGraph;
-
- private final BranchTips branchTips = new BranchTips();
+ private final SubmoduleCommits submoduleCommits;
+ private final UpdateOrderCalculator updateOrderCalculator;
private SubmoduleOp(
- PersonIdent myIdent,
- Config cfg,
+ Map<BranchNameKey, ReceiveCommand> updatedBranches,
MergeOpRepoManager orm,
- SubscriptionGraph subscriptionGraph) {
- this.myIdent = myIdent;
- this.verboseSuperProject =
- cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
- this.maxCombinedCommitMessageSize =
- cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
- this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
+ SubscriptionGraph subscriptionGraph,
+ SubmoduleCommits submoduleCommits) {
+ this.updatedBranches = updatedBranches;
this.orm = orm;
this.subscriptionGraph = subscriptionGraph;
+ this.submoduleCommits = submoduleCommits;
+ this.updateOrderCalculator = new UpdateOrderCalculator(subscriptionGraph);
}
- @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
- public boolean hasSuperproject(BranchNameKey branch) {
- return subscriptionGraph.hasSuperproject(branch);
- }
-
- public void updateSuperProjects() throws RestApiException {
- ImmutableSet<Project.NameKey> projects = getProjectsInOrder();
+ public void updateSuperProjects(boolean dryrun) throws RestApiException {
+ ImmutableSet<Project.NameKey> projects = updateOrderCalculator.getProjectsInOrder();
if (projects == null) {
return;
}
+ if (dryrun) {
+ // On dryrun, the refs hasn't been updated.
+ // force the new tips on submoduleCommits
+ forceRefTips(updatedBranches, submoduleCommits);
+ }
+
LinkedHashSet<Project.NameKey> superProjects = new LinkedHashSet<>();
try {
+ GitlinkOp.Factory gitlinkOpFactory =
+ new GitlinkOp.Factory(submoduleCommits, subscriptionGraph);
for (Project.NameKey project : projects) {
// only need superprojects
if (subscriptionGraph.isAffectedSuperProject(project)) {
@@ -160,315 +99,33 @@
// get a new BatchUpdate for the super project
OpenRepo or = orm.getRepo(project);
for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
- addOp(or.getUpdate(), branch);
+ or.getUpdate().addRepoOnlyOp(gitlinkOpFactory.create(branch));
}
}
}
- BatchUpdate.execute(orm.batchUpdates(superProjects), BatchUpdateListener.NONE, false);
+ BatchUpdate.execute(orm.batchUpdates(superProjects), ImmutableList.of(), dryrun);
} catch (UpdateException | IOException | NoSuchProjectException e) {
throw new StorageException("Cannot update gitlinks", e);
}
}
- /** Create a separate gitlink commit */
- private CodeReviewCommit composeGitlinksCommit(BranchNameKey subscriber)
- throws IOException, SubmoduleConflictException {
- OpenRepo or;
- try {
- or = orm.getRepo(subscriber.project());
- } catch (NoSuchProjectException | IOException e) {
- throw new StorageException("Cannot access superproject", e);
- }
-
- CodeReviewCommit currentCommit =
- branchTips
- .getTip(subscriber, or)
- .orElseThrow(
- () ->
- new SubmoduleConflictException(
- "The branch was probably deleted from the subscriber repository"));
-
- StringBuilder msgbuf = new StringBuilder();
- PersonIdent author = null;
- DirCache dc = readTree(or.rw, currentCommit);
- DirCacheEditor ed = dc.editor();
- int count = 0;
-
- List<SubmoduleSubscription> subscriptions =
- subscriptionGraph.getSubscriptions(subscriber).stream()
- .sorted(comparing(SubmoduleSubscription::getPath))
- .collect(toList());
- for (SubmoduleSubscription s : subscriptions) {
- if (count > 0) {
- msgbuf.append("\n\n");
- }
- RevCommit newCommit = updateSubmodule(dc, ed, msgbuf, s);
- count++;
- if (newCommit != null) {
- PersonIdent newCommitAuthor = newCommit.getAuthorIdent();
- if (author == null) {
- author = new PersonIdent(newCommitAuthor, myIdent.getWhen());
- } else if (!author.getName().equals(newCommitAuthor.getName())
- || !author.getEmailAddress().equals(newCommitAuthor.getEmailAddress())) {
- author = myIdent;
- }
- }
- }
- ed.finish();
- ObjectId newTreeId = dc.writeTree(or.ins);
-
- // Gitlinks are already in the branch, return null
- if (newTreeId.equals(currentCommit.getTree())) {
- return null;
- }
- CommitBuilder commit = new CommitBuilder();
- commit.setTreeId(newTreeId);
- commit.setParentId(currentCommit);
- StringBuilder commitMsg = new StringBuilder("Update git submodules\n\n");
- if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
- commitMsg.append(msgbuf);
- }
- commit.setMessage(commitMsg.toString());
- commit.setAuthor(author);
- commit.setCommitter(myIdent);
- ObjectId id = or.ins.insert(commit);
- return or.rw.parseCommit(id);
- }
-
- /** Amend an existing commit with gitlink updates */
- CodeReviewCommit amendGitlinksCommit(BranchNameKey subscriber, CodeReviewCommit currentCommit)
- throws IOException, SubmoduleConflictException {
- OpenRepo or;
- try {
- or = orm.getRepo(subscriber.project());
- } catch (NoSuchProjectException | IOException e) {
- throw new StorageException("Cannot access superproject", e);
- }
-
- StringBuilder msgbuf = new StringBuilder();
- DirCache dc = readTree(or.rw, currentCommit);
- DirCacheEditor ed = dc.editor();
- for (SubmoduleSubscription s : subscriptionGraph.getSubscriptions(subscriber)) {
- updateSubmodule(dc, ed, msgbuf, s);
- }
- ed.finish();
- ObjectId newTreeId = dc.writeTree(or.ins);
-
- // Gitlinks are already updated, just return the commit
- if (newTreeId.equals(currentCommit.getTree())) {
- return currentCommit;
- }
- or.rw.parseBody(currentCommit);
- CommitBuilder commit = new CommitBuilder();
- commit.setTreeId(newTreeId);
- commit.setParentIds(currentCommit.getParents());
- if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
- // TODO(czhen): handle cherrypick footer
- commit.setMessage(currentCommit.getFullMessage() + "\n\n* submodules:\n" + msgbuf.toString());
- } else {
- commit.setMessage(currentCommit.getFullMessage());
- }
- commit.setAuthor(currentCommit.getAuthorIdent());
- commit.setCommitter(myIdent);
- ObjectId id = or.ins.insert(commit);
- CodeReviewCommit newCommit = or.rw.parseCommit(id);
- newCommit.copyFrom(currentCommit);
- return newCommit;
- }
-
- private RevCommit updateSubmodule(
- DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
- throws SubmoduleConflictException, IOException {
- logger.atFine().log("Updating gitlink for %s", s);
- OpenRepo subOr;
- try {
- subOr = orm.getRepo(s.getSubmodule().project());
- } catch (NoSuchProjectException | IOException e) {
- throw new StorageException("Cannot access submodule", e);
- }
-
- DirCacheEntry dce = dc.getEntry(s.getPath());
- RevCommit oldCommit = null;
- if (dce != null) {
- if (!dce.getFileMode().equals(FileMode.GITLINK)) {
- String errMsg =
- "Requested to update gitlink "
- + s.getPath()
- + " in "
- + s.getSubmodule().project().get()
- + " but entry "
- + "doesn't have gitlink file mode.";
- throw new SubmoduleConflictException(errMsg);
- }
- // Parse the current gitlink entry commit in the subproject repo. This is used to add a
- // shortlog for this submodule to the commit message in the superproject.
- //
- // Even if we don't strictly speaking need that commit message, parsing the commit is a sanity
- // check that the old gitlink is a commit that actually exists. If not, then there is an
- // inconsistency between the superproject and subproject state, and we don't want to risk
- // making things worse by updating the gitlink to something else.
+ private void forceRefTips(
+ Map<BranchNameKey, ReceiveCommand> updatedBranches, SubmoduleCommits submoduleCommits) {
+ // This is dryrun, all commands succeeded (no need to filter success).
+ for (Map.Entry<BranchNameKey, ReceiveCommand> updateBranch : updatedBranches.entrySet()) {
try {
- oldCommit = subOr.rw.parseCommit(dce.getObjectId());
- } catch (IOException e) {
- // Broken gitlink; sanity check failed. Warn and continue so the submit operation can
- // proceed, it will just skip this gitlink update.
- logger.atSevere().withCause(e).log("Failed to read commit %s", dce.getObjectId().name());
- return null;
- }
- }
-
- Optional<CodeReviewCommit> maybeNewCommit = branchTips.getTip(s.getSubmodule(), subOr);
- if (!maybeNewCommit.isPresent()) {
- // This submodule branch is neither in the submit set nor in the repository itself
- ed.add(new DeletePath(s.getPath()));
- return null;
- }
-
- CodeReviewCommit newCommit = maybeNewCommit.get();
-
- if (Objects.equals(newCommit, oldCommit)) {
- // gitlink have already been updated for this submodule
- return null;
- }
- ed.add(
- new PathEdit(s.getPath()) {
- @Override
- public void apply(DirCacheEntry ent) {
- ent.setFileMode(FileMode.GITLINK);
- ent.setObjectId(newCommit.getId());
- }
- });
-
- if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
- createSubmoduleCommitMsg(msgbuf, s, subOr, newCommit, oldCommit);
- }
- subOr.rw.parseBody(newCommit);
- return newCommit;
- }
-
- private void createSubmoduleCommitMsg(
- StringBuilder msgbuf,
- SubmoduleSubscription s,
- OpenRepo subOr,
- RevCommit newCommit,
- RevCommit oldCommit) {
- msgbuf.append("* Update ");
- msgbuf.append(s.getPath());
- msgbuf.append(" from branch '");
- msgbuf.append(s.getSubmodule().shortName());
- msgbuf.append("'");
- msgbuf.append("\n to ");
- msgbuf.append(newCommit.getName());
-
- // newly created submodule gitlink, do not append whole history
- if (oldCommit == null) {
- return;
- }
-
- try {
- subOr.rw.resetRetain(subOr.canMergeFlag);
- subOr.rw.markStart(newCommit);
- subOr.rw.markUninteresting(oldCommit);
- int numMessages = 0;
- for (Iterator<RevCommit> iter = subOr.rw.iterator(); iter.hasNext(); ) {
- RevCommit c = iter.next();
- subOr.rw.parseBody(c);
-
- String message =
- verboseSuperProject == VerboseSuperprojectUpdate.SUBJECT_ONLY
- ? c.getShortMessage()
- : StringUtils.replace(c.getFullMessage(), "\n", "\n ");
-
- String bullet = "\n - ";
- String ellipsis = "\n\n[...]";
- int newSize = msgbuf.length() + bullet.length() + message.length();
- if (++numMessages > maxCommitMessages
- || newSize > maxCombinedCommitMessageSize
- || (iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize)) {
- msgbuf.append(ellipsis);
- break;
+ ReceiveCommand command = updateBranch.getValue();
+ if (command.getType() == ReceiveCommand.Type.DELETE) {
+ continue;
}
- msgbuf.append(bullet);
- msgbuf.append(message);
- }
- } catch (IOException e) {
- throw new StorageException(
- "Could not perform a revwalk to create superproject commit message", e);
- }
- }
- private static DirCache readTree(RevWalk rw, ObjectId base) throws IOException {
- final DirCache dc = DirCache.newInCore();
- final DirCacheBuilder b = dc.builder();
- b.addTree(
- new byte[0], // no prefix path
- DirCacheEntry.STAGE_0, // standard stage
- rw.getObjectReader(),
- rw.parseTree(base));
- b.finish();
- return dc;
- }
-
- ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
- LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
- for (Project.NameKey project : subscriptionGraph.getAffectedSuperProjects()) {
- addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
- }
-
- for (BranchNameKey branch : subscriptionGraph.getUpdatedBranches()) {
- projects.add(branch.project());
- }
- return ImmutableSet.copyOf(projects);
- }
-
- private void addAllSubmoduleProjects(
- Project.NameKey project,
- LinkedHashSet<Project.NameKey> current,
- LinkedHashSet<Project.NameKey> projects)
- throws SubmoduleConflictException {
- if (current.contains(project)) {
- throw new SubmoduleConflictException(
- "Project level circular subscriptions detected: "
- + CircularPathFinder.printCircularPath(current, project));
- }
-
- if (projects.contains(project)) {
- return;
- }
-
- current.add(project);
- Set<Project.NameKey> subprojects = new HashSet<>();
- for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
- Collection<SubmoduleSubscription> subscriptions = subscriptionGraph.getSubscriptions(branch);
- for (SubmoduleSubscription s : subscriptions) {
- subprojects.add(s.getSubmodule().project());
+ BranchNameKey branchNameKey = updateBranch.getKey();
+ OpenRepo openRepo = orm.getRepo(branchNameKey.project());
+ CodeReviewCommit fakeTip = openRepo.rw.parseCommit(command.getNewId());
+ submoduleCommits.addBranchTip(branchNameKey, fakeTip);
+ } catch (NoSuchProjectException | IOException e) {
+ throw new StorageException("Cannot find branch tip target in dryrun", e);
}
}
-
- for (Project.NameKey p : subprojects) {
- addAllSubmoduleProjects(p, current, projects);
- }
-
- current.remove(project);
- projects.add(project);
- }
-
- ImmutableSet<BranchNameKey> getBranchesInOrder() {
- LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
- branches.addAll(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches());
- branches.addAll(subscriptionGraph.getUpdatedBranches());
- return ImmutableSet.copyOf(branches);
- }
-
- boolean hasSubscription(BranchNameKey branch) {
- return subscriptionGraph.hasSubscription(branch);
- }
-
- void addBranchTip(BranchNameKey branch, CodeReviewCommit tip) {
- branchTips.put(branch, tip);
- }
-
- void addOp(BatchUpdate bu, BranchNameKey branch) {
- bu.addRepoOnlyOp(new GitlinkOp(branch, branchTips));
}
}
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
index f037261..ad16cb0 100644
--- a/java/com/google/gerrit/server/submit/SubscriptionGraph.java
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -22,11 +22,12 @@
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.SetMultimap;
import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.common.UsedAt;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.entities.SubscribeSection;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectCache;
@@ -137,6 +138,7 @@
}
/** Check if a {@code branch} is a submodule of a superproject. */
+ @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
public boolean hasSuperproject(BranchNameKey branch) {
return subscribedBranches.contains(branch);
}
@@ -159,11 +161,11 @@
public static class Module extends AbstractModule {
@Override
protected void configure() {
- bind(Factory.class).to(DefaultFactory.class);
+ bind(Factory.class).annotatedWith(VanillaSubscriptionGraph.class).to(DefaultFactory.class);
}
}
- static class DefaultFactory implements Factory {
+ public static class DefaultFactory implements Factory {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final ProjectCache projectCache;
private final GitModules.Factory gitmodulesFactory;
diff --git a/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java b/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java
new file mode 100644
index 0000000..517c708
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/UpdateOrderCalculator.java
@@ -0,0 +1,91 @@
+// 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.submit;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+/**
+ * Sorts the projects or branches affected by the update.
+ *
+ * <p>The subscription graph contains all branches (and projects) affected by the update, but the
+ * updates must be executed in the right order, so no superproject reference is updated before its
+ * target.
+ */
+class UpdateOrderCalculator {
+
+ private final SubscriptionGraph subscriptionGraph;
+
+ UpdateOrderCalculator(SubscriptionGraph subscriptionGraph) {
+ this.subscriptionGraph = subscriptionGraph;
+ }
+
+ ImmutableSet<Project.NameKey> getProjectsInOrder() throws SubmoduleConflictException {
+ LinkedHashSet<Project.NameKey> projects = new LinkedHashSet<>();
+ for (Project.NameKey project : subscriptionGraph.getAffectedSuperProjects()) {
+ addAllSubmoduleProjects(project, new LinkedHashSet<>(), projects);
+ }
+
+ for (BranchNameKey branch : subscriptionGraph.getUpdatedBranches()) {
+ projects.add(branch.project());
+ }
+ return ImmutableSet.copyOf(projects);
+ }
+
+ private void addAllSubmoduleProjects(
+ Project.NameKey project,
+ LinkedHashSet<Project.NameKey> current,
+ LinkedHashSet<Project.NameKey> projects)
+ throws SubmoduleConflictException {
+ if (current.contains(project)) {
+ throw new SubmoduleConflictException(
+ "Project level circular subscriptions detected: "
+ + CircularPathFinder.printCircularPath(current, project));
+ }
+
+ if (projects.contains(project)) {
+ return;
+ }
+
+ current.add(project);
+ Set<Project.NameKey> subprojects = new HashSet<>();
+ for (BranchNameKey branch : subscriptionGraph.getAffectedSuperBranches(project)) {
+ Collection<SubmoduleSubscription> subscriptions = subscriptionGraph.getSubscriptions(branch);
+ for (SubmoduleSubscription s : subscriptions) {
+ subprojects.add(s.getSubmodule().project());
+ }
+ }
+
+ for (Project.NameKey p : subprojects) {
+ addAllSubmoduleProjects(p, current, projects);
+ }
+
+ current.remove(project);
+ projects.add(project);
+ }
+
+ ImmutableSet<BranchNameKey> getBranchesInOrder() {
+ LinkedHashSet<BranchNameKey> branches = new LinkedHashSet<>();
+ branches.addAll(subscriptionGraph.getSortedSuperprojectAndSubmoduleBranches());
+ branches.addAll(subscriptionGraph.getUpdatedBranches());
+ return ImmutableSet.copyOf(branches);
+ }
+}
diff --git a/java/com/google/gerrit/server/submit/VanillaSubscriptionGraph.java b/java/com/google/gerrit/server/submit/VanillaSubscriptionGraph.java
new file mode 100644
index 0000000..a88157e
--- /dev/null
+++ b/java/com/google/gerrit/server/submit/VanillaSubscriptionGraph.java
@@ -0,0 +1,29 @@
+// 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.submit;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/**
+ * Marker on a {@link SubscriptionGraph.Factory} without gerrit configuration.
+ *
+ * <p>See {@link ConfiguredSubscriptionGraphFactory}.
+ */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface VanillaSubscriptionGraph {}
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 166e88d..7fdf833 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -20,6 +20,7 @@
import static com.google.common.flogger.LazyArgs.lazy;
import static java.util.Comparator.comparing;
import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toSet;
import com.google.common.base.Throwables;
@@ -33,6 +34,7 @@
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSet.Id;
@@ -82,6 +84,7 @@
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.PushCertificate;
import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
/**
* Helper for a set of change updates that should be applied to the NoteDb database.
@@ -121,9 +124,9 @@
}
public static void execute(
- Collection<BatchUpdate> updates, BatchUpdateListener listener, boolean dryrun)
+ Collection<BatchUpdate> updates, ImmutableList<BatchUpdateListener> listeners, boolean dryrun)
throws UpdateException, RestApiException {
- requireNonNull(listener);
+ requireNonNull(listeners);
if (updates.isEmpty()) {
return;
}
@@ -137,16 +140,16 @@
for (BatchUpdate u : updates) {
u.executeUpdateRepo();
}
- listener.afterUpdateRepos();
+ notifyAfterUpdateRepo(listeners);
for (BatchUpdate u : updates) {
- changesHandles.add(u.executeChangeOps(dryrun));
+ changesHandles.add(u.executeChangeOps(listeners, dryrun));
}
for (ChangesHandle h : changesHandles) {
h.execute();
indexFutures.addAll(h.startIndexFutures());
}
- listener.afterUpdateRefs();
- listener.afterUpdateChanges();
+ notifyAfterUpdateRefs(listeners);
+ notifyAfterUpdateChanges(listeners);
} finally {
for (ChangesHandle h : changesHandles) {
h.close();
@@ -158,10 +161,7 @@
// Fire ref update events only after all mutations are finished, since callers may assume a
// patch set ref being created means the change was created, or a branch advancing meaning
// some changes were closed.
- updates.stream()
- .filter(u -> u.batchRefUpdate != null)
- .forEach(
- u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+ updates.forEach(BatchUpdate::fireRefChangeEvent);
if (!dryrun) {
for (BatchUpdate u : updates) {
@@ -173,6 +173,27 @@
}
}
+ private static void notifyAfterUpdateRepo(ImmutableList<BatchUpdateListener> listeners)
+ throws Exception {
+ for (BatchUpdateListener listener : listeners) {
+ listener.afterUpdateRepos();
+ }
+ }
+
+ private static void notifyAfterUpdateRefs(ImmutableList<BatchUpdateListener> listeners)
+ throws Exception {
+ for (BatchUpdateListener listener : listeners) {
+ listener.afterUpdateRefs();
+ }
+ }
+
+ private static void notifyAfterUpdateChanges(ImmutableList<BatchUpdateListener> listeners)
+ throws Exception {
+ for (BatchUpdateListener listener : listeners) {
+ listener.afterUpdateChanges();
+ }
+ }
+
private static void checkDifferentProject(Collection<BatchUpdate> updates) {
Multiset<Project.NameKey> projectCounts =
updates.stream().map(u -> u.project).collect(toImmutableMultiset());
@@ -346,6 +367,7 @@
private RepoView repoView;
private BatchRefUpdate batchRefUpdate;
+ private boolean executed;
private OnSubmitValidators onSubmitValidators;
private PushCertificate pushCert;
private String refLogMessage;
@@ -383,11 +405,15 @@
}
public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
- execute(ImmutableList.of(this), listener, false);
+ execute(ImmutableList.of(this), ImmutableList.of(listener), false);
}
public void execute() throws UpdateException, RestApiException {
- execute(BatchUpdateListener.NONE);
+ execute(ImmutableList.of(this), ImmutableList.of(), false);
+ }
+
+ public boolean isExecuted() {
+ return executed;
}
public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
@@ -441,6 +467,10 @@
return this;
}
+ public Project.NameKey getProject() {
+ return project;
+ }
+
private void initRepository() throws IOException {
if (repoView == null) {
repoView = new RepoView(repoManager, project);
@@ -462,6 +492,17 @@
return repoView != null ? repoView.getCommands().getCommands() : ImmutableMap.of();
}
+ /**
+ * Return the references successfully updated by this BatchUpdate with their command. In dryrun,
+ * we assume all updates were successful.
+ */
+ public Map<BranchNameKey, ReceiveCommand> getSuccessfullyUpdatedBranches(boolean dryrun) {
+ return getRefUpdates().entrySet().stream()
+ .filter(entry -> dryrun || entry.getValue().getResult() == Result.OK)
+ .collect(
+ toMap(entry -> BranchNameKey.create(project, entry.getKey()), Map.Entry::getValue));
+ }
+
public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
requireNonNull(op);
@@ -516,6 +557,12 @@
}
}
+ private void fireRefChangeEvent() {
+ if (batchRefUpdate != null) {
+ gitRefUpdated.fire(project, batchRefUpdate, getAccount().orElse(null));
+ }
+ }
+
private class ChangesHandle implements AutoCloseable {
private final NoteDbUpdateManager manager;
private final boolean dryrun;
@@ -539,6 +586,7 @@
void execute() throws IOException {
BatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
+ BatchUpdate.this.executed = manager.isExecuted();
}
List<ListenableFuture<?>> startIndexFutures() {
@@ -566,7 +614,8 @@
}
}
- private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
+ private ChangesHandle executeChangeOps(
+ ImmutableList<BatchUpdateListener> batchUpdateListeners, boolean dryrun) throws Exception {
logDebug("Executing change ops");
initRepository();
Repository repo = repoView.getRepository();
@@ -579,6 +628,7 @@
new ChangesHandle(
updateManagerFactory
.create(project)
+ .setBatchUpdateListeners(batchUpdateListeners)
.setChangeRepo(
repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
dryrun);
diff --git a/java/com/google/gerrit/server/update/BatchUpdateListener.java b/java/com/google/gerrit/server/update/BatchUpdateListener.java
index 765bba1..d286e84 100644
--- a/java/com/google/gerrit/server/update/BatchUpdateListener.java
+++ b/java/com/google/gerrit/server/update/BatchUpdateListener.java
@@ -14,6 +14,8 @@
package com.google.gerrit.server.update;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+
/**
* Interface for listening during batch update execution.
*
@@ -21,11 +23,25 @@
* after that phase has been completed for <em>all</em> updates.
*/
public interface BatchUpdateListener {
- public static final BatchUpdateListener NONE = new BatchUpdateListener() {};
+ BatchUpdateListener NONE = new BatchUpdateListener() {};
/** Called after updating all repositories and flushing objects but before updating any refs. */
default void afterUpdateRepos() throws Exception {}
+ /**
+ * Optional setup of the {@link BatchRefUpdate} that is going to be executed.
+ *
+ * <p>Called after {@link #afterUpdateRepos()}, before {@link #afterUpdateRefs()} and {@link
+ * #afterUpdateChanges()}
+ *
+ * @param bru a batch ref update, ready but not executed yet
+ * @return a new {@link BatchRefUpdate}. Implementations can decide to modify and return the
+ * incoming instance, but callers must not rely on that.
+ */
+ default BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) {
+ return bru;
+ }
+
/** Called after updating all refs. */
default void afterUpdateRefs() throws Exception {}
diff --git a/java/com/google/gerrit/server/update/SubmissionExecutor.java b/java/com/google/gerrit/server/update/SubmissionExecutor.java
new file mode 100644
index 0000000..39eda58
--- /dev/null
+++ b/java/com/google/gerrit/server/update/SubmissionExecutor.java
@@ -0,0 +1,71 @@
+// 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.update;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.submit.MergeOpRepoManager;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+public class SubmissionExecutor {
+
+ private final ImmutableList<SubmissionListener> submissionListeners;
+ private final boolean dryrun;
+ private ImmutableList<BatchUpdateListener> additionalListeners = ImmutableList.of();
+
+ public SubmissionExecutor(boolean dryrun, ImmutableList<SubmissionListener> submissionListeners) {
+ this.dryrun = dryrun;
+ this.submissionListeners = submissionListeners;
+ if (dryrun) {
+ submissionListeners.forEach(SubmissionListener::setDryrun);
+ }
+ }
+
+ /**
+ * Set additional listeners. These can be set again in each try (or will be reused if not
+ * overwritten).
+ */
+ public void setAdditionalBatchUpdateListeners(
+ ImmutableList<BatchUpdateListener> additionalListeners) {
+ this.additionalListeners = additionalListeners;
+ }
+
+ /** Execute the batch updates, reporting to all the Submission and BatchUpdateListeners. */
+ public void execute(Collection<BatchUpdate> updates) throws RestApiException, UpdateException {
+ submissionListeners.forEach(l -> l.beforeBatchUpdates(updates));
+
+ ImmutableList<BatchUpdateListener> listeners =
+ new ImmutableList.Builder<BatchUpdateListener>()
+ .addAll(additionalListeners)
+ .addAll(
+ submissionListeners.stream()
+ .map(l -> l.listensToBatchUpdates())
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(Collectors.toList()))
+ .build();
+ BatchUpdate.execute(updates, listeners, dryrun);
+ }
+
+ /**
+ * Caller invokes this when done with the submission (either because everything succeeded or gave
+ * up retrying).
+ */
+ public void afterExecutions(MergeOpRepoManager orm) {
+ submissionListeners.forEach(l -> l.afterSubmission(orm));
+ }
+}
diff --git a/java/com/google/gerrit/server/update/SubmissionListener.java b/java/com/google/gerrit/server/update/SubmissionListener.java
new file mode 100644
index 0000000..0df8491
--- /dev/null
+++ b/java/com/google/gerrit/server/update/SubmissionListener.java
@@ -0,0 +1,76 @@
+// 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.update;
+
+import com.google.gerrit.server.submit.MergeOpRepoManager;
+import java.util.Collection;
+import java.util.Optional;
+
+/**
+ * Status and progress of a submission.
+ *
+ * <p>{@link SubmissionExecutor} reports the progress of the submission through this interface. An
+ * instance is reused between retries but should not be reused for different submissions.
+ */
+public interface SubmissionListener {
+
+ /**
+ * This submission is a dryrun.
+ *
+ * <p>In dryrun, the submission adds objects to storage, generates receive commands and creates a
+ * BatchRefUpdate, but it won't execute the BRU (i.e. it won't update the refs).
+ *
+ * <p>The submission receives the listeners and the dryrun flag at construction time. This method
+ * is called if needed at that point (i.e. before anything else) and never again inside the
+ * submission. Listeners instances should not be reused between submissions (note that the dryrun
+ * state would not be reverted).
+ */
+ void setDryrun();
+
+ /**
+ * Submission will execute these updates.
+ *
+ * <p>The BatchUpdates haven't execute anything yet.
+ *
+ * <p>This method is called once per submission try. The retry calls can have only a subset of the
+ * BatchUpdates (what failed in the previous attempt). On retries the BatchUpdates are not reused.
+ * Implementations must store intermediate results if needed on {@link
+ * #afterSubmission(MergeOpRepoManager)}.
+ *
+ * @param updates updates to execute in this try of the submission. Implementations should not
+ * modify them.
+ */
+ void beforeBatchUpdates(Collection<BatchUpdate> updates);
+
+ /**
+ * Submission completed (either success or giving up retrying).
+ *
+ * <p>This is called after all (successfull) updates have been committed to storage and there
+ * won't be more retries.
+ *
+ * @param orm the orm to use if the after submission steps need to read from the repositories.
+ * This could be a pristine repo manager (if the previous op didn't use MergeOpRepoManager) or
+ * the latest orm used after retrying.
+ */
+ void afterSubmission(MergeOpRepoManager orm);
+
+ /**
+ * If the submission needs to know more about the BatchUpdate execution, it can provide a {@link
+ * BatchUpdateListener}.
+ *
+ * @return a BatchUpdateListener
+ */
+ Optional<BatchUpdateListener> listensToBatchUpdates();
+}
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateOnSubmission.java b/java/com/google/gerrit/server/update/SuperprojectUpdateOnSubmission.java
new file mode 100644
index 0000000..441132c
--- /dev/null
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateOnSubmission.java
@@ -0,0 +1,25 @@
+// 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.update;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+/** Marker on a {@link SubmissionListener} that updates the superprojects on submission. */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface SuperprojectUpdateOnSubmission {}
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
new file mode 100644
index 0000000..4c65c80
--- /dev/null
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -0,0 +1,96 @@
+// 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.update;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.submit.MergeOpRepoManager;
+import com.google.gerrit.server.submit.SubmoduleOp;
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import javax.inject.Inject;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Update superprojects after submission is done */
+public class SuperprojectUpdateSubmissionListener implements SubmissionListener {
+
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final SubmoduleOp.Factory subOpFactory;
+ private final Map<BranchNameKey, ReceiveCommand> updatedBranches = new HashMap<>();
+ private ImmutableList<BatchUpdate> batchUpdates = ImmutableList.of();
+ private boolean dryrun;
+
+ public static class Module extends AbstractModule {
+ @Provides
+ @SuperprojectUpdateOnSubmission
+ ImmutableList<SubmissionListener> provideSubmissionListeners(
+ SuperprojectUpdateSubmissionListener listener) {
+ return ImmutableList.of(listener);
+ }
+ }
+
+ @Inject
+ public SuperprojectUpdateSubmissionListener(SubmoduleOp.Factory subOpFactory) {
+ this.subOpFactory = subOpFactory;
+ }
+
+ @Override
+ public void setDryrun() {
+ this.dryrun = true;
+ }
+
+ @Override
+ public void beforeBatchUpdates(Collection<BatchUpdate> updates) {
+ if (!batchUpdates.isEmpty()) {
+ // This is a retry. Save previous updates, as they are not in the new BatchUpdate.
+ collectSuccessfullUpdates();
+ }
+ this.batchUpdates = ImmutableList.copyOf(updates);
+ }
+
+ @Override
+ public void afterSubmission(MergeOpRepoManager orm) {
+ collectSuccessfullUpdates();
+ // Update superproject gitlinks if required.
+ if (!updatedBranches.isEmpty()) {
+ try {
+ SubmoduleOp op = subOpFactory.create(updatedBranches, orm);
+ op.updateSuperProjects(dryrun);
+ } catch (RestApiException e) {
+ logger.atWarning().withCause(e).log("Can't update the superprojects");
+ }
+ }
+ }
+
+ @Override
+ public Optional<BatchUpdateListener> listensToBatchUpdates() {
+ return Optional.empty();
+ }
+
+ private void collectSuccessfullUpdates() {
+ if (!this.batchUpdates.isEmpty()) {
+ for (BatchUpdate bu : batchUpdates) {
+ updatedBranches.putAll(bu.getSuccessfullyUpdatedBranches(dryrun));
+ }
+ }
+ }
+}
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
new file mode 100644
index 0000000..56b1dda
--- /dev/null
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -0,0 +1,117 @@
+// 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.util;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.config.SendEmailExecutor;
+import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
+import com.google.gerrit.server.mail.send.AttentionSetSender;
+import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
+import com.google.gerrit.server.update.Context;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+
+public class AttentionSetEmail implements Runnable, RequestContext {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ public interface Factory {
+
+ /**
+ * factory for sending an email when adding users to the attention set or removing them from it.
+ *
+ * @param sender sender in charge of sending the email, can be {@link AddToAttentionSetSender}
+ * or {@link RemoveFromAttentionSetSender}.
+ * @param ctx context for sending the email.
+ * @param change the change that the user was added/removed in.
+ * @param reason reason for adding/removing the user.
+ * @param messageId messageId for tracking the email.
+ * @param attentionUserId the user added/removed.
+ */
+ AttentionSetEmail create(
+ AttentionSetSender sender,
+ Context ctx,
+ Change change,
+ String reason,
+ MessageIdGenerator.MessageId messageId,
+ Account.Id attentionUserId);
+ }
+
+ private ExecutorService sendEmailsExecutor;
+ private AttentionSetSender sender;
+ private Context ctx;
+ private Change change;
+ private String reason;
+
+ private MessageIdGenerator.MessageId messageId;
+ private Account.Id attentionUserId;
+
+ @Inject
+ AttentionSetEmail(
+ @SendEmailExecutor ExecutorService executor,
+ @Assisted AttentionSetSender sender,
+ @Assisted Context ctx,
+ @Assisted Change change,
+ @Assisted String reason,
+ @Assisted MessageIdGenerator.MessageId messageId,
+ @Assisted Account.Id attentionUserId) {
+ this.sendEmailsExecutor = executor;
+ this.sender = sender;
+ this.ctx = ctx;
+ this.change = change;
+ this.reason = reason;
+ this.messageId = messageId;
+ this.attentionUserId = attentionUserId;
+ }
+
+ public void sendAsync() {
+ @SuppressWarnings("unused")
+ Future<?> possiblyIgnoredError = sendEmailsExecutor.submit(this);
+ }
+
+ @Override
+ public void run() {
+ try {
+ AccountState accountState =
+ ctx.getUser().isIdentifiedUser() ? ctx.getUser().asIdentifiedUser().state() : null;
+ if (accountState != null) {
+ sender.setFrom(accountState.account().id());
+ }
+ sender.setNotify(ctx.getNotify(change.getId()));
+ sender.setAttentionSetUser(attentionUserId);
+ sender.setReason(reason);
+ sender.setMessageId(messageId);
+ sender.send();
+ } catch (Exception e) {
+ logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "send-email comments";
+ }
+
+ @Override
+ public CurrentUser getUser() {
+ return ctx.getUser();
+ }
+}
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index 8252e8e..26c862d 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -16,14 +16,20 @@
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.AttentionSetUpdate.Operation;
import com.google.gerrit.extensions.api.changes.AttentionSetInput;
import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import java.io.IOException;
import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
/** Common helpers for dealing with attention set data structures. */
public class AttentionSetUtil {
+
/** Returns only updates where the user was added. */
public static ImmutableSet<AttentionSetUpdate> additionsOnly(
Collection<AttentionSetUpdate> updates) {
@@ -48,5 +54,45 @@
}
}
+ /**
+ * Returns the {@code Account.Id} of {@code user} if the user is active on the change, and exists.
+ * If the user doesn't exist or is not active on the change, the same exception is thrown to
+ * disallow probing for account existence based on exception type.
+ */
+ public static Account.Id resolveAccount(
+ AccountResolver accountResolver, ChangeNotes changeNotes, String user)
+ throws ConfigInvalidException, IOException, BadRequestException {
+ // We will throw this exception if the account doesn't exist, or if the account is not active.
+ // This is purposely the same exception so that users can't probe for account existence based on
+ // the thrown exception.
+ BadRequestException possibleExceptionForNotFoundOrInactiveAccount =
+ new BadRequestException(
+ String.format(
+ "%s doesn't exist or is not active on the change as an owner, uploader, "
+ + "reviewer, or cc so they can't be added to the attention set",
+ user));
+ Account.Id attentionUserId;
+ try {
+ attentionUserId = accountResolver.resolveIgnoreVisibility(user).asUnique().account().id();
+ } catch (AccountResolver.UnresolvableAccountException ex) {
+ possibleExceptionForNotFoundOrInactiveAccount.initCause(ex);
+ throw possibleExceptionForNotFoundOrInactiveAccount;
+ }
+ if (!isActiveOnTheChange(changeNotes, attentionUserId)) {
+ throw possibleExceptionForNotFoundOrInactiveAccount;
+ }
+ return attentionUserId;
+ }
+
+ /**
+ * Returns whether {@code attentionUserId} is active on a change. Activity is defined as being a
+ * part of the reviewers, an uploader, or an owner of a change.
+ */
+ private static boolean isActiveOnTheChange(ChangeNotes changeNotes, Account.Id attentionUserId) {
+ return changeNotes.getChange().getOwner().equals(attentionUserId)
+ || changeNotes.getCurrentPatchSet().uploader().equals(attentionUserId)
+ || changeNotes.getReviewers().all().stream().anyMatch(id -> id.equals(attentionUserId));
+ }
+
private AttentionSetUtil() {}
}
diff --git a/java/com/google/gerrit/server/util/LabelVote.java b/java/com/google/gerrit/server/util/LabelVote.java
index a03c1f2..038fe2c 100644
--- a/java/com/google/gerrit/server/util/LabelVote.java
+++ b/java/com/google/gerrit/server/util/LabelVote.java
@@ -18,7 +18,7 @@
import com.google.auto.value.AutoValue;
import com.google.common.base.Strings;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
/** A single vote on a label, consisting of a label name and a value. */
@AutoValue
diff --git a/java/com/google/gerrit/server/util/MostSpecificComparator.java b/java/com/google/gerrit/server/util/MostSpecificComparator.java
index b22617c..ac33902 100644
--- a/java/com/google/gerrit/server/util/MostSpecificComparator.java
+++ b/java/com/google/gerrit/server/util/MostSpecificComparator.java
@@ -14,7 +14,7 @@
package com.google.gerrit.server.util;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.server.project.RefPattern;
import java.util.Comparator;
import org.apache.commons.lang.StringUtils;
diff --git a/java/com/google/gerrit/server/util/PluginLogFile.java b/java/com/google/gerrit/server/util/PluginLogFile.java
index de8b3aa..8235623 100644
--- a/java/com/google/gerrit/server/util/PluginLogFile.java
+++ b/java/com/google/gerrit/server/util/PluginLogFile.java
@@ -40,8 +40,13 @@
public void start() {
AsyncAppender asyncAppender = systemLog.createAsyncAppender(logName, layout, true, true);
Logger logger = LogManager.getLogger(logName);
- logger.removeAppender(logName);
- logger.addAppender(asyncAppender);
+ if (logger.getAppender(logName) == null) {
+ synchronized (this) {
+ if (logger.getAppender(logName) == null) {
+ logger.addAppender(asyncAppender);
+ }
+ }
+ }
logger.setAdditivity(false);
}
diff --git a/java/com/google/gerrit/sshd/AbstractGitCommand.java b/java/com/google/gerrit/sshd/AbstractGitCommand.java
index 8bf6cd5..b3753fd 100644
--- a/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -16,6 +16,7 @@
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -52,6 +53,7 @@
@Override
public void start(ChannelSession channel, Environment env) {
+ enableGracefulStop();
String gitProtocol = env.getEnv().get(GIT_PROTOCOL);
if (gitProtocol != null) {
extraParameters = gitProtocol.split(":");
@@ -63,8 +65,8 @@
startThread(
new ProjectCommandRunnable() {
@Override
- public void executeParseCommand() throws Exception {
- parseCommandLine();
+ public void executeParseCommand(DynamicOptions pluginOptions) throws Exception {
+ parseCommandLine(pluginOptions);
}
@Override
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index ab1f062..0dbae0a 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -101,9 +101,9 @@
@PluginName
private String pluginName;
- @Inject private Injector injector;
+ @Inject protected Injector injector;
- @Inject private DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
+ @Inject protected DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
/** The task, as scheduled on a worker thread. */
private final AtomicReference<Future<?>> task;
@@ -211,12 +211,13 @@
*
* <p>This method must be explicitly invoked to cause a parse.
*
+ * @param pluginOptions which helps to define and parse options provided from plugins
* @throws UnloggedFailure if the command line arguments were invalid.
* @see Option
* @see Argument
*/
- protected void parseCommandLine() throws UnloggedFailure {
- parseCommandLine(this);
+ protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+ parseCommandLine(this, pluginOptions);
}
/**
@@ -226,13 +227,14 @@
*
* @param options object whose fields declare Option and Argument annotations to describe the
* parameters of the command. Usually {@code this}.
+ * @param pluginOptions which helps to define and parse options provided from plugins
* @throws UnloggedFailure if the command line arguments were invalid.
* @see Option
* @see Argument
*/
- protected void parseCommandLine(Object options) throws UnloggedFailure {
+ protected void parseCommandLine(Object options, DynamicOptions pluginOptions)
+ throws UnloggedFailure {
final CmdLineParser clp = newCmdLineParser(options);
- DynamicOptions pluginOptions = new DynamicOptions(options, injector, dynamicBeans);
pluginOptions.parseDynamicBeans(clp);
pluginOptions.setDynamicBeans();
pluginOptions.onBeanParseStart();
@@ -403,6 +405,10 @@
}
}
+ protected void enableGracefulStop() {
+ context.getSession().setGracefulStop(true);
+ }
+
protected String getTaskDescription() {
String[] ta = getTrimmedArguments();
if (ta != null) {
@@ -460,13 +466,17 @@
context.started = TimeUtil.nowMs();
thisThread.setName("SSH " + taskName);
- if (thunk instanceof ProjectCommandRunnable) {
- ((ProjectCommandRunnable) thunk).executeParseCommand();
- projectName = ((ProjectCommandRunnable) thunk).getProjectName();
- }
-
try {
- thunk.run();
+ if (thunk instanceof ProjectCommandRunnable) {
+ try (DynamicOptions pluginOptions =
+ new DynamicOptions(BaseCommand.this, injector, dynamicBeans)) {
+ ((ProjectCommandRunnable) thunk).executeParseCommand(pluginOptions);
+ projectName = ((ProjectCommandRunnable) thunk).getProjectName();
+ thunk.run();
+ }
+ } else {
+ thunk.run();
+ }
} catch (NoSuchProjectException e) {
throw new UnloggedFailure(1, e.getMessage());
} catch (NoSuchChangeException e) {
@@ -529,7 +539,7 @@
public interface ProjectCommandRunnable extends CommandRunnable {
// execute parser command before running, in order to be able to retrieve
// project name
- void executeParseCommand() throws Exception;
+ void executeParseCommand(DynamicOptions pluginOptions) throws Exception;
Project.NameKey getProjectName();
}
diff --git a/java/com/google/gerrit/sshd/DispatchCommand.java b/java/com/google/gerrit/sshd/DispatchCommand.java
index 7db65bd..a45cd31 100644
--- a/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -20,6 +20,7 @@
import com.google.common.util.concurrent.Atomics;
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.args4j.SubcommandHandler;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
@@ -71,8 +72,9 @@
@Override
public void start(ChannelSession channel, Environment env) throws IOException {
- try {
- parseCommandLine();
+ try (DynamicOptions pluginOptions =
+ new DynamicOptions(DispatchCommand.this, injector, dynamicBeans)) {
+ parseCommandLine(pluginOptions);
if (Strings.isNullOrEmpty(commandName)) {
StringWriter msg = new StringWriter();
msg.write(usage());
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index e60ba6d..3ef7061 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -16,6 +16,7 @@
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.RequestInfo;
import com.google.gerrit.server.RequestListener;
import com.google.gerrit.server.config.GerritServerConfig;
@@ -49,19 +50,22 @@
public void start(ChannelSession channel, Environment env) throws IOException {
startThread(
() -> {
- parseCommandLine();
- stdout = toPrintWriter(out);
- stderr = toPrintWriter(err);
- try (TraceContext traceContext = enableTracing();
- PerformanceLogContext performanceLogContext =
- new PerformanceLogContext(config, performanceLoggers)) {
- RequestInfo requestInfo =
- RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
- requestListeners.runEach(l -> l.onRequest(requestInfo));
- SshCommand.this.run();
- } finally {
- stdout.flush();
- stderr.flush();
+ try (DynamicOptions pluginOptions =
+ new DynamicOptions(SshCommand.this, injector, dynamicBeans)) {
+ parseCommandLine(pluginOptions);
+ stdout = toPrintWriter(out);
+ stderr = toPrintWriter(err);
+ try (TraceContext traceContext = enableTracing();
+ PerformanceLogContext performanceLogContext =
+ new PerformanceLogContext(config, performanceLoggers)) {
+ RequestInfo requestInfo =
+ RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
+ requestListeners.runEach(l -> l.onRequest(requestInfo));
+ SshCommand.this.run();
+ } finally {
+ stdout.flush();
+ stderr.flush();
+ }
}
},
AccessPath.SSH_COMMAND);
diff --git a/java/com/google/gerrit/sshd/SshDaemon.java b/java/com/google/gerrit/sshd/SshDaemon.java
index 5145c13..c14ebd8 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;
@@ -86,6 +90,7 @@
import org.apache.sshd.common.random.Random;
import org.apache.sshd.common.random.SingletonRandomFactory;
import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.session.helpers.AbstractSession;
import org.apache.sshd.common.session.helpers.DefaultUnknownChannelReferenceHandler;
import org.apache.sshd.common.util.buffer.Buffer;
import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
@@ -142,6 +147,7 @@
private final List<HostKey> hostKeys;
private volatile IoAcceptor daemonAcceptor;
private final Config cfg;
+ private final long gracefulStopTimeout;
@Inject
SshDaemon(
@@ -212,6 +218,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 +349,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 +366,40 @@
}
}
+ private void waitForSessionClose() {
+ Collection<IoSession> ioSessions = daemonAcceptor.getManagedSessions().values();
+ CountDownLatch allSessionsClosed = new CountDownLatch(ioSessions.size());
+ for (IoSession io : ioSessions) {
+ AbstractSession serverSession = AbstractSession.getSession(io, true);
+ SshSession sshSession =
+ serverSession != null ? serverSession.getAttribute(SshSession.KEY) : null;
+ if (sshSession != null && sshSession.requiresGracefulStop()) {
+ logger.atFine().log("Waiting for session %s to stop.", io.getId());
+ io.addCloseFutureListener(
+ new SshFutureListener<CloseFuture>() {
+ @Override
+ public void operationComplete(CloseFuture future) {
+ logger.atFine().log("Session %s was stopped.", io.getId());
+ allSessionsClosed.countDown();
+ }
+ });
+ } else {
+ logger.atFine().log("Stopping session %s immediately.", io.getId());
+ io.close(true);
+ 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/sshd/SshSession.java b/java/com/google/gerrit/sshd/SshSession.java
index d6ecc73..b39eaed 100644
--- a/java/com/google/gerrit/sshd/SshSession.java
+++ b/java/com/google/gerrit/sshd/SshSession.java
@@ -35,6 +35,8 @@
private volatile String authError;
private volatile String peerAgent;
+ private volatile boolean gracefulStop = false;
+
SshSession(int sessionId, SocketAddress peer) {
this.sessionId = sessionId;
this.remoteAddress = peer;
@@ -58,6 +60,14 @@
return sessionId;
}
+ public boolean requiresGracefulStop() {
+ return gracefulStop;
+ }
+
+ public void setGracefulStop(boolean gracefulStop) {
+ this.gracefulStop = gracefulStop;
+ }
+
/** Identity of the authenticated user account on the socket. */
public CurrentUser getUser() {
return identity;
diff --git a/java/com/google/gerrit/sshd/SuExec.java b/java/com/google/gerrit/sshd/SuExec.java
index ea163d5..bf785bb 100644
--- a/java/com/google/gerrit/sshd/SuExec.java
+++ b/java/com/google/gerrit/sshd/SuExec.java
@@ -21,6 +21,7 @@
import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PeerDaemonUser;
import com.google.gerrit.server.config.AuthConfig;
@@ -92,9 +93,9 @@
@Override
public void start(ChannelSession channel, Environment env) throws IOException {
- try {
+ try (DynamicOptions pluginOptions = new DynamicOptions(SuExec.this, injector, dynamicBeans)) {
checkCanRunAs();
- parseCommandLine();
+ parseCommandLine(pluginOptions);
final Context ctx = callingContext.subContext(newSession(), join(args));
final Context old = sshScope.set(ctx);
diff --git a/java/com/google/gerrit/sshd/commands/AproposCommand.java b/java/com/google/gerrit/sshd/commands/AproposCommand.java
index d3db70d..e7a88a1 100644
--- a/java/com/google/gerrit/sshd/commands/AproposCommand.java
+++ b/java/com/google/gerrit/sshd/commands/AproposCommand.java
@@ -39,6 +39,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
try {
List<QueryDocumentationExecutor.DocResult> res = searcher.doQuery(q);
for (DocResult docResult : res) {
diff --git a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
index ee6f635..134fb03 100644
--- a/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
@@ -63,6 +63,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
try {
BanCommitInput input =
BanCommitInput.fromCommits(Lists.transform(commitsToBan, ObjectId::getName));
diff --git a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
index d70c153..ad8e20d 100644
--- a/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
+++ b/java/com/google/gerrit/sshd/commands/BaseTestPrologCommand.java
@@ -59,6 +59,7 @@
@Override
protected final void run() throws UnloggedFailure {
+ enableGracefulStop();
try {
RevisionResource revision =
revisions.parse(
diff --git a/java/com/google/gerrit/sshd/commands/CloseConnection.java b/java/com/google/gerrit/sshd/commands/CloseConnection.java
index 093f647..e0b87f8 100644
--- a/java/com/google/gerrit/sshd/commands/CloseConnection.java
+++ b/java/com/google/gerrit/sshd/commands/CloseConnection.java
@@ -57,6 +57,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
SshUtil.forEachSshSession(
sshDaemon,
(k, sshSession, abstractSession, ioSession) -> {
diff --git a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
index 004a0ba..4da55e2 100644
--- a/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateAccountCommand.java
@@ -72,6 +72,7 @@
@Override
protected void run()
throws IOException, ConfigInvalidException, UnloggedFailure, PermissionBackendException {
+ enableGracefulStop();
AccountInput input = new AccountInput();
input.username = username;
input.email = email;
diff --git a/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java b/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
index aad96a1..a837ecd 100644
--- a/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateBranchCommand.java
@@ -44,6 +44,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
try {
BranchInput in = new BranchInput();
in.revision = revision;
diff --git a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 17f80c0..5fd2297 100644
--- a/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -102,6 +102,7 @@
@Override
protected void run()
throws Failure, IOException, ConfigInvalidException, PermissionBackendException {
+ enableGracefulStop();
try {
GroupResource rsrc = createGroup();
diff --git a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index fca7427..f2ab4e8 100644
--- a/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -166,6 +166,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
try {
if (!suggestParent) {
if (projectName == null) {
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 4d77c60..cfd17f4 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -82,6 +82,19 @@
command(git, "upload-pack").to(Upload.class);
command("git-upload-archive").to(Commands.key(git, "upload-archive"));
command(git, "upload-archive").to(UploadArchive.class);
+ if (slaveMode) {
+ command("git-receive-pack").to(ReceiveSlaveMode.class);
+ command("gerrit-receive-pack").to(ReceiveSlaveMode.class);
+ command(git, "receive-pack").to(ReceiveSlaveMode.class);
+ } else {
+ command("git-receive-pack").to(Commands.key(git, "receive-pack"));
+ command("gerrit-receive-pack").to(Commands.key(git, "receive-pack"));
+ command(git, "receive-pack").to(Commands.key(gerrit, "receive-pack"));
+ }
+ }
+
+ if (!slaveMode) {
+ command(gerrit, "test-submit").toProvider(new DispatchCommandProvider(testSubmit));
}
command("suexec").to(SuExec.class);
listener().to(ShowCaches.StartupListener.class);
@@ -91,18 +104,6 @@
command(gerrit, CreateProjectCommand.class);
command(gerrit, SetHeadCommand.class);
- if (slaveMode) {
- command("git-receive-pack").to(ReceiveSlaveMode.class);
- command("gerrit-receive-pack").to(ReceiveSlaveMode.class);
- command(git, "receive-pack").to(ReceiveSlaveMode.class);
- } else {
- if (sshEnabled()) {
- command("git-receive-pack").to(Commands.key(git, "receive-pack"));
- command("gerrit-receive-pack").to(Commands.key(git, "receive-pack"));
- command(git, "receive-pack").to(Commands.key(gerrit, "receive-pack"));
- }
- command(gerrit, "test-submit").toProvider(new DispatchCommandProvider(testSubmit));
- }
command(gerrit, Receive.class);
command(gerrit, RenameGroupCommand.class);
diff --git a/java/com/google/gerrit/sshd/commands/FlushCaches.java b/java/com/google/gerrit/sshd/commands/FlushCaches.java
index 2afc009..fe2a897 100644
--- a/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -55,6 +55,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
try {
if (list) {
if (all || !caches.isEmpty()) {
diff --git a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
index 2073087..28a7804 100644
--- a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
@@ -62,6 +62,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
verifyCommandLine();
runGC();
}
diff --git a/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
index 0804d08..30dc5c4 100644
--- a/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
@@ -34,6 +34,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
try {
if (versionManager.isKnownIndex(name)) {
if (versionManager.activateLatestIndex(name)) {
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index fb62b48..1fb0e13 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -52,6 +52,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
boolean ok = true;
for (ChangeResource rsrc : changes.values()) {
try {
diff --git a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
index 56b00a5..168dc19 100644
--- a/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexChangesInProjectCommand.java
@@ -43,6 +43,7 @@
@Override
protected void run() throws UnloggedFailure, Failure, Exception {
+ enableGracefulStop();
if (projects.isEmpty()) {
throw die("needs at least one project as command arguments");
}
diff --git a/java/com/google/gerrit/sshd/commands/IndexStartCommand.java b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
index f3d349c..5433b17 100644
--- a/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
+++ b/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
@@ -38,6 +38,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
try {
if (versionManager.isKnownIndex(name)) {
if (versionManager.startReindexer(name, force)) {
diff --git a/java/com/google/gerrit/sshd/commands/KillCommand.java b/java/com/google/gerrit/sshd/commands/KillCommand.java
index df74f86..a633a8a 100644
--- a/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -47,6 +47,7 @@
@Override
protected void run() {
+ enableGracefulStop();
ConfigResource cfgRsrc = new ConfigResource();
for (String id : taskIds) {
try {
diff --git a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index bdf5412..7bf42eb 100644
--- a/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -52,6 +52,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
if (listGroups.getUser() != null && !listGroups.getProjects().isEmpty()) {
throw die("--user and --project options are not compatible.");
}
diff --git a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
index c8b8fa1..1a7be32 100644
--- a/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListLoggingLevelCommand.java
@@ -40,6 +40,7 @@
@SuppressWarnings("unchecked")
@Override
protected void run() {
+ enableGracefulStop();
Map<String, String> logs = new TreeMap<>();
for (Enumeration<Logger> logger = LogManager.getCurrentLoggers(); logger.hasMoreElements(); ) {
Logger log = logger.nextElement();
diff --git a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index dc1bc6e..52d0468 100644
--- a/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -20,6 +20,7 @@
import com.google.common.base.Strings;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.account.AccountLoader;
import com.google.gerrit.server.account.GroupCache;
import com.google.gerrit.server.account.GroupControl;
@@ -45,12 +46,13 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
impl.display(stdout);
}
@Override
- protected void parseCommandLine() throws UnloggedFailure {
- parseCommandLine(impl);
+ protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
+ parseCommandLine(impl, pluginOptions);
}
private static class ListMembersCommandImpl extends ListMembers {
diff --git a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
index 9f2ffa9..e711d57 100644
--- a/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -32,6 +32,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
if (!impl.getFormat().isJson()) {
List<String> showBranch = impl.getShowBranch();
if (impl.isShowTree() && (showBranch != null) && !showBranch.isEmpty()) {
diff --git a/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index 80aee01..6eb045b 100644
--- a/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -74,6 +74,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
Account.Id userAccountId;
try {
userAccountId = accountResolver.resolve(userName).asUnique().account().id();
diff --git a/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java b/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
index 7e32615..086081c 100644
--- a/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginAdminSshCommand.java
@@ -28,6 +28,7 @@
@Override
protected final void run() throws UnloggedFailure {
+ enableGracefulStop();
if (!loader.isRemoteAdminEnabled()) {
throw die("remote plugin administration is disabled");
}
diff --git a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
index 3a952f0..504b239 100644
--- a/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
@@ -41,6 +41,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
Map<String, PluginInfo> output = list.apply(TopLevelResource.INSTANCE).value();
if (format.isJson()) {
diff --git a/java/com/google/gerrit/sshd/commands/Query.java b/java/com/google/gerrit/sshd/commands/Query.java
index 78485d3..da19153 100644
--- a/java/com/google/gerrit/sshd/commands/Query.java
+++ b/java/com/google/gerrit/sshd/commands/Query.java
@@ -106,6 +106,7 @@
@Override
protected void run() throws Exception {
+ enableGracefulStop();
processor.query(join(query, " "));
}
@@ -115,9 +116,9 @@
}
@Override
- protected void parseCommandLine() throws UnloggedFailure {
+ protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
processor.setOutput(out, OutputFormat.TEXT);
- super.parseCommandLine();
+ super.parseCommandLine(pluginOptions);
if (processor.getIncludeFiles()
&& !(processor.getIncludePatchSets() || processor.getIncludeCurrentPatchSet())) {
throw die("--files option needs --patch-sets or --current-patch-set");
diff --git a/java/com/google/gerrit/sshd/commands/ReloadConfig.java b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
index cbe3c57..eeb48bb 100644
--- a/java/com/google/gerrit/sshd/commands/ReloadConfig.java
+++ b/java/com/google/gerrit/sshd/commands/ReloadConfig.java
@@ -38,6 +38,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
Multimap<UpdateResult, ConfigUpdateEntry> updates = gerritServerConfigReloader.reloadConfig();
if (updates.isEmpty()) {
stdout.println("No config entries updated!");
diff --git a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
index 166ad68..976e7bd 100644
--- a/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
+++ b/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
@@ -46,6 +46,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
try {
GroupResource rsrc = groups.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(groupName));
NameInput input = new NameInput();
diff --git a/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index 42d781f..4c84bd3 100644
--- a/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -22,7 +22,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.CharStreams;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.exceptions.StorageException;
@@ -36,6 +36,7 @@
import com.google.gerrit.extensions.api.changes.RevisionApi;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.ProjectCache;
@@ -167,6 +168,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
if (abandonChange) {
if (restoreChange) {
throw die("abandon and restore actions are mutually exclusive");
@@ -319,7 +321,7 @@
}
@Override
- protected void parseCommandLine() throws UnloggedFailure {
+ protected void parseCommandLine(DynamicOptions pluginOptions) throws UnloggedFailure {
optionMap = new LinkedHashMap<>();
customLabels = new HashMap<>();
@@ -340,7 +342,7 @@
optionMap.put(newApproveOption(type, usage.toString()), new LabelSetter(type));
}
- super.parseCommandLine();
+ super.parseCommandLine(pluginOptions);
}
private static String asOptionName(LabelType type) {
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index df1e3ed..43a1670 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -154,6 +154,7 @@
@Override
public void run() throws Exception {
+ enableGracefulStop();
user = genericUserFactory.create(id);
validate();
diff --git a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
index fd7ef75..b6d283e 100644
--- a/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetHeadCommand.java
@@ -43,6 +43,7 @@
@Override
protected void run() throws Exception {
+ enableGracefulStop();
HeadInput input = new HeadInput();
input.ref = newHead;
try {
diff --git a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
index cfdd735..3faf598 100644
--- a/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetLoggingLevelCommand.java
@@ -61,6 +61,7 @@
@SuppressWarnings("unchecked")
@Override
protected void run() throws MalformedURLException {
+ enableGracefulStop();
if (level == LevelOption.RESET) {
reset();
} else {
diff --git a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index 2511df4..db8e42a 100644
--- a/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -102,6 +102,7 @@
@Override
protected void run() throws UnloggedFailure, Failure, Exception {
+ enableGracefulStop();
try {
for (AccountGroup.UUID groupUuid : groups) {
GroupResource resource =
diff --git a/java/com/google/gerrit/sshd/commands/SetParentCommand.java b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
index 406949e..d23f7fa 100644
--- a/java/com/google/gerrit/sshd/commands/SetParentCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetParentCommand.java
@@ -90,6 +90,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
if (oldParent == null && children.isEmpty()) {
throw die(
"child projects have to be specified as "
diff --git a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index 8c9fc9f..9866c4e 100644
--- a/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -132,6 +132,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
ConfigInput configInput = new ConfigInput();
configInput.requireChangeId = requireChangeID;
configInput.submitType = submitType;
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index 5bc5537..95627e1 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -95,6 +95,7 @@
@Override
protected void run() throws UnloggedFailure {
+ enableGracefulStop();
boolean ok = true;
for (ChangeResource rsrc : changes.values()) {
try {
diff --git a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
index 70700f1..35cb3ba 100644
--- a/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetTopicCommand.java
@@ -16,12 +16,11 @@
import com.google.gerrit.entities.Change;
import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.TopicInput;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SetTopicOp;
import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.restapi.change.SetTopicOp;
import com.google.gerrit.server.update.BatchUpdate;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gerrit.sshd.ChangeArgumentParser;
@@ -74,18 +73,17 @@
@Override
public void run() throws Exception {
- TopicInput input = new TopicInput();
if (topic != null) {
- input.topic = topic.trim();
+ topic = topic.trim();
}
- if (input.topic != null && input.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+ if (topic != null && topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
throw new BadRequestException(
String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
}
for (ChangeResource r : changes.values()) {
- SetTopicOp op = topicOpFactory.create(input);
+ SetTopicOp op = topicOpFactory.create(topic);
try (BatchUpdate u =
updateFactory.create(r.getChange().getProject(), user, TimeUtil.nowTs())) {
u.addOp(r.getId(), op);
diff --git a/java/com/google/gerrit/sshd/commands/ShowCaches.java b/java/com/google/gerrit/sshd/commands/ShowCaches.java
index 1d756de..ba84179 100644
--- a/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -112,6 +112,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
nw = columns - 50;
Date now = new Date();
stdout.format(
diff --git a/java/com/google/gerrit/sshd/commands/ShowConnections.java b/java/com/google/gerrit/sshd/commands/ShowConnections.java
index decf5d5..d271364 100644
--- a/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -86,6 +86,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
final IoAcceptor acceptor = daemon.getIoAcceptor();
if (acceptor == null) {
throw new Failure(1, "fatal: sshd no longer running");
diff --git a/java/com/google/gerrit/sshd/commands/ShowQueue.java b/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 2ec9e2d..779f2df 100644
--- a/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -85,6 +85,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
stdout.print(
String.format(
diff --git a/java/com/google/gerrit/sshd/commands/StreamEvents.java b/java/com/google/gerrit/sshd/commands/StreamEvents.java
index 45540a0..188cc83 100644
--- a/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -22,6 +22,7 @@
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.extensions.registration.RegistrationHandle;
import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.events.Event;
import com.google.gerrit.server.events.EventGson;
@@ -107,59 +108,63 @@
@Override
public void start(ChannelSession channel, Environment env) throws IOException {
- try {
- parseCommandLine();
- } catch (UnloggedFailure e) {
- String msg = e.getMessage();
- if (!msg.endsWith("\n")) {
- msg += "\n";
+ try (DynamicOptions pluginOptions =
+ new DynamicOptions(StreamEvents.this, injector, dynamicBeans)) {
+ try {
+ parseCommandLine(pluginOptions);
+ } catch (UnloggedFailure e) {
+ String msg = e.getMessage();
+ if (!msg.endsWith("\n")) {
+ msg += "\n";
+ }
+ err.write(msg.getBytes(UTF_8));
+ err.flush();
+ onExit(1);
+ return;
}
- err.write(msg.getBytes(UTF_8));
- err.flush();
- onExit(1);
- return;
- }
- PrintWriter stdout = toPrintWriter(out);
- CancelableRunnable writer =
- new CancelableRunnable() {
- @Override
- public void run() {
- writeEvents(this, stdout);
- }
-
- @Override
- public void cancel() {
- onExit(0);
- }
-
- @Override
- public String toString() {
- StringBuilder b = new StringBuilder();
- b.append("Stream Events");
- if (currentUser.getUserName().isPresent()) {
- b.append(" (").append(currentUser.getUserName().get()).append(")");
+ PrintWriter stdout = toPrintWriter(out);
+ CancelableRunnable writer =
+ new CancelableRunnable() {
+ @Override
+ public void run() {
+ writeEvents(this, stdout);
}
- return b.toString();
- }
- };
- eventListenerRegistration =
- eventListeners.add(
- "gerrit",
- new UserScopedEventListener() {
- @Override
- public void onEvent(Event event) {
- if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
- offer(writer, event);
+ @Override
+ public void cancel() {
+ onExit(0);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder b = new StringBuilder();
+ b.append("Stream Events");
+ if (currentUser.getUserName().isPresent()) {
+ b.append(" (").append(currentUser.getUserName().get()).append(")");
+ }
+ return b.toString();
+ }
+ };
+
+ eventListenerRegistration =
+ eventListeners.add(
+ "gerrit",
+ new UserScopedEventListener() {
+ @Override
+ public void onEvent(Event event) {
+ if (subscribedToEvents.isEmpty()
+ || subscribedToEvents.contains(event.getType())) {
+ offer(writer, event);
+ }
}
- }
- @Override
- public CurrentUser getUser() {
- return currentUser;
- }
- });
+ @Override
+ public CurrentUser getUser() {
+ return currentUser;
+ }
+ });
+ }
}
private void removeEventListenerRegistration() {
diff --git a/java/com/google/gerrit/sshd/commands/VersionCommand.java b/java/com/google/gerrit/sshd/commands/VersionCommand.java
index 8fac979..f8771fb 100644
--- a/java/com/google/gerrit/sshd/commands/VersionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/VersionCommand.java
@@ -25,6 +25,7 @@
@Override
protected void run() throws Failure {
+ enableGracefulStop();
String v = Version.getVersion();
if (v == null) {
throw new Failure(1, "fatal: version unavailable");
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 6c9fbed..1779a18 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -88,6 +88,7 @@
import com.google.gerrit.server.ssh.NoSshKeyCache;
import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
import com.google.gerrit.server.submit.SubscriptionGraph;
+import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener;
import com.google.gerrit.server.util.ReplicaUtil;
import com.google.inject.AbstractModule;
import com.google.inject.Guice;
@@ -177,6 +178,7 @@
factory(GarbageCollection.Factory.class);
install(new AuditModule());
install(new SubscriptionGraph.Module());
+ install(new SuperprojectUpdateSubmissionListener.Module());
bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
diff --git a/java/com/google/gerrit/testing/TestCommentHelper.java b/java/com/google/gerrit/testing/TestCommentHelper.java
index bd859db..5865a3c 100644
--- a/java/com/google/gerrit/testing/TestCommentHelper.java
+++ b/java/com/google/gerrit/testing/TestCommentHelper.java
@@ -17,6 +17,7 @@
import static java.util.stream.Collectors.toList;
import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.extensions.api.GerritApi;
import com.google.gerrit.extensions.api.changes.DraftInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -46,6 +47,10 @@
return populate(new DraftInput(), "file", message);
}
+ public DraftInput newDraft(String message, String inReplyTo) {
+ return populate(new DraftInput(), "file", createLineRange(), message, inReplyTo);
+ }
+
public DraftInput newDraft(String path, Side side, int line, String message) {
DraftInput d = new DraftInput();
return populate(d, path, side, line, message);
@@ -55,24 +60,29 @@
gApi.changes().id(changeId).revision(revId).createDraft(in);
}
+ public void addDraft(String changeId, DraftInput in) throws Exception {
+ gApi.changes().id(changeId).current().createDraft(in);
+ }
+
public Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
- return gApi.changes().id(changeId).comments().values().stream()
+ return gApi.changes().id(changeId).commentsRequest().get().values().stream()
.flatMap(Collection::stream)
.collect(toList());
}
public static <C extends Comment> C populate(C c, String path, String message) {
- return populate(c, path, createLineRange(), message);
+ return populate(c, path, createLineRange(), message, null);
}
- private static <C extends Comment> C populate(C c, String path, Range range, String message) {
+ private static <C extends Comment> C populate(
+ C c, String path, Range range, String message, String inReplyTo) {
int line = range.startLine;
c.path = path;
c.side = Side.REVISION;
c.parent = null;
c.line = line != 0 ? line : null;
c.message = message;
- c.unresolved = false;
+ c.inReplyTo = inReplyTo;
if (line != 0) c.range = range;
return c;
}
@@ -85,7 +95,6 @@
c.parent = null;
c.line = line != 0 ? line : null;
c.message = message;
- c.unresolved = false;
if (line != 0) c.range = range;
return c;
}
@@ -138,13 +147,30 @@
addRobotComment(targetChangeId, robotCommentInput, "robot comment test");
}
+ public void addRobotComment(Change.Id targetChangeId, RobotCommentInput robotCommentInput)
+ throws Exception {
+ addRobotComment(targetChangeId, robotCommentInput, "robot comment test");
+ }
+
public void addRobotComment(
String targetChangeId, RobotCommentInput robotCommentInput, String message) throws Exception {
+ ReviewInput reviewInput = createReviewInput(robotCommentInput, message);
+ gApi.changes().id(targetChangeId).current().review(reviewInput);
+ }
+
+ public void addRobotComment(
+ Change.Id targetChangeId, RobotCommentInput robotCommentInput, String message)
+ throws Exception {
+ ReviewInput reviewInput = createReviewInput(robotCommentInput, message);
+ gApi.changes().id(targetChangeId.get()).current().review(reviewInput);
+ }
+
+ private ReviewInput createReviewInput(RobotCommentInput robotCommentInput, String message) {
ReviewInput reviewInput = new ReviewInput();
reviewInput.robotComments =
Collections.singletonMap(robotCommentInput.path, ImmutableList.of(robotCommentInput));
reviewInput.message = message;
reviewInput.tag = ChangeMessagesUtil.AUTOGENERATED_TAG_PREFIX;
- gApi.changes().id(targetChangeId).current().review(reviewInput);
+ return reviewInput;
}
}
diff --git a/java/com/google/gerrit/truth/MapSubject.java b/java/com/google/gerrit/truth/MapSubject.java
index 95a0e0c..4eba753 100644
--- a/java/com/google/gerrit/truth/MapSubject.java
+++ b/java/com/google/gerrit/truth/MapSubject.java
@@ -17,6 +17,7 @@
import static com.google.common.truth.Truth.assertAbout;
import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IntegerSubject;
import com.google.common.truth.IterableSubject;
import com.google.common.truth.Subject;
import java.util.Map;
@@ -51,4 +52,9 @@
isNotNull();
return check("values()").that(map.values());
}
+
+ public IntegerSubject size() {
+ isNotNull();
+ return check("size()").that(map.size());
+ }
}
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/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 90a2cbf..5ee292ff 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -2,8 +2,8 @@
package gerrit;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.rules.StoredValues;
diff --git a/java/gerrit/PRED_files_1.java b/java/gerrit/PRED_files_1.java
new file mode 100644
index 0000000..ac45449
--- /dev/null
+++ b/java/gerrit/PRED_files_1.java
@@ -0,0 +1,106 @@
+// 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 gerrit;
+
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.rules.StoredValues;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.ListTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.StructureTerm;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
+
+/** Exports list of Strings that each represent a file name in the current patchset. */
+public class PRED_files_1 extends Predicate.P1 {
+ private static final SymbolTerm file = SymbolTerm.intern("file", 3);
+
+ PRED_files_1(Term a1, Operation n) {
+ arg1 = a1;
+ cont = n;
+ }
+
+ @Override
+ public Operation exec(Prolog engine) throws PrologException {
+ engine.setB0();
+ Term a1 = arg1.dereference();
+ Term listHead = Prolog.Nil;
+
+ try (RevWalk revWalk = new RevWalk(StoredValues.REPOSITORY.get(engine))) {
+ RevCommit commit = revWalk.parseCommit(StoredValues.getPatchSet(engine).commitId());
+ List<PatchListEntry> patches = StoredValues.PATCH_LIST.get(engine).getPatches();
+ Set<String> submodules =
+ getAllSubmodulePaths(StoredValues.REPOSITORY.get(engine), commit, patches);
+ for (PatchListEntry entry : patches) {
+ if (Patch.isMagic(entry.getNewName())) {
+ continue;
+ }
+ SymbolTerm fileNameTerm = SymbolTerm.create(entry.getNewName());
+ SymbolTerm changeType = SymbolTerm.create(entry.getChangeType().getCode());
+ SymbolTerm fileType;
+ if (submodules.contains(entry.getNewName())) {
+ fileType = SymbolTerm.create("SUBMODULE");
+ } else {
+ fileType = SymbolTerm.create("REGULAR");
+ }
+ listHead =
+ new ListTerm(new StructureTerm(file, fileNameTerm, changeType, fileType), listHead);
+ }
+ } catch (IOException ex) {
+ return engine.fail();
+ }
+ if (!a1.unify(listHead, engine.trail)) {
+ return engine.fail();
+ }
+ return cont;
+ }
+
+ /** Returns the paths for all {@code GITLINK} files. */
+ private static Set<String> getAllSubmodulePaths(
+ Repository repository, RevCommit commit, List<PatchListEntry> patches)
+ throws PrologException, IOException {
+ Set<String> submodules = new HashSet<>();
+ try (TreeWalk treeWalk = new TreeWalk(repository)) {
+ treeWalk.addTree(commit.getTree());
+ Set<String> allPaths =
+ patches.stream()
+ .map(PatchListEntry::getNewName)
+ .filter(f -> !Patch.isMagic(f))
+ .collect(Collectors.toSet());
+ treeWalk.setFilter(PathFilterGroup.createFromStrings(allPaths));
+
+ while (treeWalk.next()) {
+ if (treeWalk.getFileMode() == FileMode.GITLINK) {
+ submodules.add(treeWalk.getPathString());
+ }
+ }
+ return submodules;
+ }
+ }
+}
diff --git a/java/gerrit/PRED_get_legacy_label_types_1.java b/java/gerrit/PRED_get_legacy_label_types_1.java
index 956e821..dfed17b 100644
--- a/java/gerrit/PRED_get_legacy_label_types_1.java
+++ b/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -14,7 +14,7 @@
package gerrit;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.server.rules.StoredValues;
import com.googlecode.prolog_cafe.exceptions.PrologException;
diff --git a/javatests/com/google/gerrit/acceptance/DaemonOverridesTestLibModulesIT.java b/javatests/com/google/gerrit/acceptance/DaemonOverridesTestLibModulesIT.java
new file mode 100644
index 0000000..f3a2324
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/DaemonOverridesTestLibModulesIT.java
@@ -0,0 +1,62 @@
+// 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.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.server.ModuleImpl;
+import com.google.gerrit.server.audit.AuditModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.name.Named;
+import com.google.inject.name.Names;
+import org.junit.Test;
+
+public class DaemonOverridesTestLibModulesIT extends AbstractDaemonTest {
+ private static final String TEST_MODULE = "test-module";
+
+ @Inject
+ @Named(value = TEST_MODULE)
+ private String testModuleClassName;
+
+ public abstract static class TestModule extends AuditModule {
+ @Override
+ protected void configure() {
+ super.configure();
+ bind(String.class).annotatedWith(Names.named(TEST_MODULE)).toInstance(getClass().getName());
+ }
+ }
+
+ @ModuleImpl(name = TEST_MODULE)
+ public static class DefaultModule extends TestModule {}
+
+ @ModuleImpl(name = TEST_MODULE)
+ public static class OverriddenModule extends TestModule {}
+
+ @Override
+ public Module createAuditModule() {
+ return new DefaultModule();
+ }
+
+ @Override
+ public Module createModule() {
+ return new OverriddenModule();
+ }
+
+ @Test
+ public void testSysModuleShouldOverrideTheDefaultOneWithSameModuleAnnotation() {
+ assertThat(testModuleClassName).isEqualTo(OverriddenModule.class.getName());
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
index 448629c..538009a 100644
--- a/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
+++ b/javatests/com/google/gerrit/acceptance/TestGroupBackendTest.java
@@ -16,8 +16,10 @@
import static com.google.common.truth.Truth.assertThat;
+import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.account.ListGroupMembership;
import com.google.gerrit.server.account.UniversalGroupBackend;
import com.google.gerrit.server.group.testing.TestGroupBackend;
import com.google.inject.Inject;
@@ -67,4 +69,16 @@
testGroupBackend.create(testUUID);
assertThat(testGroupBackend.get(testUUID)).isNotNull();
}
+
+ @Test
+ public void returnsMembershipsForUser() throws Exception {
+ testGroupBackend.create(testUUID);
+ testGroupBackend.setMembershipsOf(
+ admin.id(), new ListGroupMembership(ImmutableList.of(testUUID)));
+ assertThat(
+ testGroupBackend
+ .membershipsOf(identifiedUserFactory.create(admin.id()))
+ .getKnownGroups())
+ .containsExactly(testUUID);
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 60c2543..1b55652 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -76,16 +76,16 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule.Action;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.StorageException;
@@ -366,6 +366,7 @@
assertThat(accountInfo.name).isEqualTo(input.name);
assertThat(accountInfo.email).isEqualTo(input.email);
assertThat(accountInfo.status).isNull();
+ assertThat(accountInfo.tags).isNull();
Account.Id accountId = Account.id(accountInfo._accountId);
accountIndexedCounter.assertReindexOf(accountId, 1);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
index c4bb47a..4c3c77f 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AgreementsIT.java
@@ -28,11 +28,11 @@
import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.BooleanProjectConfig;
+import com.google.gerrit.entities.ContributorAgreement;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.extensions.api.changes.CherryPickInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.SubmitInput;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
index d04eebd..80431ee 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/AbandonIT.java
@@ -31,7 +31,7 @@
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.client.ChangeStatus;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index e7c0f89..ccfa60e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -47,7 +47,6 @@
import static com.google.gerrit.extensions.client.ReviewerState.CC;
import static com.google.gerrit.extensions.client.ReviewerState.REMOVED;
import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
-import static com.google.gerrit.git.ObjectIds.abbreviateName;
import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -89,15 +88,14 @@
import com.google.gerrit.common.FooterConstants;
import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Address;
-import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.StorageException;
@@ -121,7 +119,6 @@
import com.google.gerrit.extensions.api.changes.RevisionApi;
import com.google.gerrit.extensions.api.changes.StarsInput;
import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.extensions.api.projects.BranchApi;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.api.projects.ConfigInput;
import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -142,14 +139,10 @@
import com.google.gerrit.extensions.common.CommitInfo;
import com.google.gerrit.extensions.common.GitPerson;
import com.google.gerrit.extensions.common.LabelInfo;
-import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.common.MergePatchSetInput;
import com.google.gerrit.extensions.common.RevisionInfo;
import com.google.gerrit.extensions.common.TrackingIdInfo;
-import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -183,7 +176,6 @@
import com.google.inject.AbstractModule;
import com.google.inject.Inject;
import com.google.inject.name.Named;
-import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.ArrayList;
@@ -1507,19 +1499,19 @@
assertThat(commit.author.email).isEqualTo(user.email());
assertThat(commit.committer.email).isEqualTo(user.email());
- // check that the author/committer was added as reviewer
- Collection<AccountInfo> reviewers = change.reviewers.get(REVIEWER);
+ // check that the author/committer was added as cc
+ Collection<AccountInfo> reviewers = change.reviewers.get(CC);
assertThat(reviewers).isNotNull();
assertThat(reviewers).hasSize(1);
assertThat(reviewers.iterator().next()._accountId).isEqualTo(user.id().get());
- assertThat(change.reviewers.get(CC)).isNull();
+ assertThat(change.reviewers.get(REVIEWER)).isNull();
List<Message> messages = sender.getMessages();
assertThat(messages).hasSize(1);
Message m = messages.get(0);
assertThat(m.from().name()).isEqualTo("Administrator (Code Review)");
assertThat(m.rcpt()).containsExactly(user.getNameEmail());
- assertThat(m.body()).contains("I'd like you to do a code review");
+ assertThat(m.body()).contains("has uploaded this change for review");
assertThat(m.body()).contains("Change subject: " + PushOneCommit.SUBJECT + "\n");
assertMailReplyTo(m, admin.email());
}
@@ -3189,407 +3181,6 @@
}
@Test
- public void createMergePatchSet() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- String changeId = createChange().getChangeId();
-
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
- currentMaster.assertOkStatus();
- String parent = currentMaster.getCommit().getName();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- String subject = "update change by merge ps2";
- in.subject = subject;
-
- TestWorkInProgressStateChangedListener wipStateChangedListener =
- new TestWorkInProgressStateChangedListener();
- try (Registration registration =
- extensionRegistry.newRegistration().add(wipStateChangedListener)) {
- ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
- assertThat(changeInfo.subject).isEqualTo(in.subject);
- assertThat(changeInfo.containsGitConflicts).isNull();
- assertThat(changeInfo.workInProgress).isNull();
- }
- assertThat(wipStateChangedListener.invoked).isFalse();
-
- // To get the revisions, we must retrieve the change with more change options.
- ChangeInfo changeInfo =
- gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
- assertThat(changeInfo.revisions).hasSize(2);
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isEqualTo(parent);
-
- // Verify the message that has been posted on the change.
- List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
- assertThat(messages).hasSize(2);
- assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
-
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
- .contains(subject);
- }
-
- @Test
- public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- PushOneCommit.Result result = createChange();
- String changeId = result.getChangeId();
- String subject = result.getChange().change().getSubject();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result pushResult =
- pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
- pushResult.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = null;
-
- // Ensure subject carries over
- gApi.changes().id(changeId).createMergePatchSet(in);
- ChangeInfo changeInfo = gApi.changes().id(changeId).get();
- assertThat(changeInfo.subject).isEqualTo(subject);
- }
-
- @Test
- public void createMergePatchSet_Conflict() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- String changeId = createChange().getChangeId();
-
- String fileName = "shared.txt";
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster =
- pushFactory
- .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
- .to("refs/heads/master");
- currentMaster.assertOkStatus();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "update change by merge ps2";
- ResourceConflictException thrown =
- assertThrows(
- ResourceConflictException.class,
- () -> gApi.changes().id(changeId).createMergePatchSet(in));
- assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
- }
-
- @Test
- public void createMergePatchSet_ConflictAllowed() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- String changeId = createChange().getChangeId();
-
- String fileName = "shared.txt";
- String sourceSubject = "source change";
- String sourceContent = "source content";
- String targetSubject = "target change";
- String targetContent = "target content";
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster =
- pushFactory
- .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
- .to("refs/heads/master");
- currentMaster.assertOkStatus();
- String parent = currentMaster.getCommit().getName();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- mergeInput.allowConflicts = true;
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "update change by merge ps2";
-
- TestWorkInProgressStateChangedListener wipStateChangedListener =
- new TestWorkInProgressStateChangedListener();
- try (Registration registration =
- extensionRegistry.newRegistration().add(wipStateChangedListener)) {
- ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
- assertThat(changeInfo.subject).isEqualTo(in.subject);
- assertThat(changeInfo.containsGitConflicts).isTrue();
- assertThat(changeInfo.workInProgress).isTrue();
- }
- assertThat(wipStateChangedListener.invoked).isTrue();
- assertThat(wipStateChangedListener.wip).isTrue();
-
- // To get the revisions, we must retrieve the change with more change options.
- ChangeInfo changeInfo =
- gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
- assertThat(changeInfo.revisions).hasSize(2);
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isEqualTo(parent);
-
- // Verify that the file content in the created patch set is correct.
- // We expect that it has conflict markers to indicate the conflict.
- BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
- ByteArrayOutputStream os = new ByteArrayOutputStream();
- bin.writeTo(os);
- String fileContent = new String(os.toByteArray(), UTF_8);
- String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
- String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
- assertThat(fileContent)
- .isEqualTo(
- "<<<<<<< TARGET BRANCH ("
- + targetSha1
- + " "
- + targetSubject
- + ")\n"
- + targetContent
- + "\n"
- + "=======\n"
- + sourceContent
- + "\n"
- + ">>>>>>> SOURCE BRANCH ("
- + sourceSha1
- + " "
- + sourceSubject
- + ")\n");
-
- // Verify the message that has been posted on the change.
- List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
- assertThat(messages).hasSize(2);
- assertThat(Iterables.getLast(messages).message)
- .isEqualTo(
- "Uploaded patch set 2.\n\n"
- + "The following files contain Git conflicts:\n"
- + "* "
- + fileName
- + "\n");
- }
-
- @Test
- public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- String changeId = createChange().getChangeId();
-
- String fileName = "shared.txt";
- String sourceSubject = "source change";
- String sourceContent = "source content";
- String targetSubject = "target change";
- String targetContent = "target content";
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster =
- pushFactory
- .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
- .to("refs/heads/master");
- currentMaster.assertOkStatus();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- mergeInput.allowConflicts = true;
- mergeInput.strategy = "simple-two-way-in-core";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "update change by merge ps2";
-
- BadRequestException ex =
- assertThrows(
- BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
- assertThat(ex)
- .hasMessageThat()
- .isEqualTo(
- "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
- }
-
- @Test
- public void createMergePatchSetInheritParent() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- PushOneCommit.Result r = createChange();
- String changeId = r.getChangeId();
- String parent = r.getCommit().getParent(0).getName();
-
- // advance master branch
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
- currentMaster.assertOkStatus();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "update change by merge ps2 inherit parent of ps1";
- in.inheritParent = true;
- gApi.changes().id(changeId).createMergePatchSet(in);
- ChangeInfo changeInfo =
- gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
-
- assertThat(changeInfo.revisions).hasSize(2);
- assertThat(changeInfo.subject).isEqualTo(in.subject);
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isEqualTo(parent);
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isNotEqualTo(currentMaster.getCommit().getName());
- }
-
- @Test
- public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("foo");
- createBranch("bar");
-
- // Create a merged commit on 'foo' branch.
- merge(createChange("refs/for/foo"));
-
- // Create the base change on 'bar' branch.
- testRepo.reset(initialHead);
- String baseChange = createChange("refs/for/bar").getChangeId();
- gApi.changes().id(baseChange).setPrivate(true, "set private");
-
- // Create the destination change on 'master' branch.
- requestScopeOperations.setApiUser(user.id());
- testRepo.reset(initialHead);
- String changeId = createChange().getChangeId();
-
- UnprocessableEntityException thrown =
- assertThrows(
- UnprocessableEntityException.class,
- () ->
- gApi.changes()
- .id(changeId)
- .createMergePatchSet(createMergePatchSetInput(baseChange)));
- assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
- }
-
- @Test
- public void createMergePatchSetBaseOnChange() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("foo");
- createBranch("bar");
-
- // Create a merged commit on 'foo' branch.
- merge(createChange("refs/for/foo"));
-
- // Create the base change on 'bar' branch.
- testRepo.reset(initialHead);
- PushOneCommit.Result result = createChange("refs/for/bar");
- String baseChange = result.getChangeId();
- String expectedParent = result.getCommit().getName();
-
- // Create the destination change on 'master' branch.
- testRepo.reset(initialHead);
- String changeId = createChange().getChangeId();
-
- gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
-
- ChangeInfo changeInfo =
- gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
- assertThat(changeInfo.revisions).hasSize(2);
- assertThat(changeInfo.subject).isEqualTo("create ps2");
- assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
- .isEqualTo(expectedParent);
- }
-
- @Test
- public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
- RevCommit initialHead = projectOperations.project(project).getHead("master");
- createBranch("dev");
-
- // create a change for master
- String changeId = createChange().getChangeId();
-
- String fileName = "shared.txt";
- String sourceSubject = "source change";
- String sourceContent = "source content";
- String targetSubject = "target change";
- String targetContent = "target content";
- testRepo.reset(initialHead);
- PushOneCommit.Result currentMaster =
- pushFactory
- .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
- .to("refs/heads/master");
- currentMaster.assertOkStatus();
-
- // push a commit into dev branch
- testRepo.reset(initialHead);
- PushOneCommit.Result changeA =
- pushFactory
- .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
- .to("refs/heads/dev");
- changeA.assertOkStatus();
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "dev";
- mergeInput.strategy = "unsupported-strategy";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "update change by merge ps2";
-
- BadRequestException ex =
- assertThrows(
- BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
- assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
- }
-
- private MergePatchSetInput createMergePatchSetInput(String baseChange) {
- MergeInput mergeInput = new MergeInput();
- mergeInput.source = "foo";
- MergePatchSetInput in = new MergePatchSetInput();
- in.merge = mergeInput;
- in.subject = "create ps2";
- in.inheritParent = false;
- in.baseChange = baseChange;
- return in;
- }
-
- @Test
public void checkLabelsForUnsubmittedChange() throws Exception {
PushOneCommit.Result r = createChange();
ChangeInfo change = gApi.changes().id(r.getChangeId()).get();
@@ -4178,7 +3769,7 @@
.startsWith(subject);
List<CommentInfo> comments =
- Iterables.getOnlyElement(gApi.changes().id(id).comments().values());
+ Iterables.getOnlyElement(gApi.changes().id(id).commentsRequest().get().values());
assertThat(Iterables.getOnlyElement(comments).message).isEqualTo(ci.message);
}
@@ -4709,10 +4300,6 @@
return pushTo("refs/for/master%wip");
}
- private BranchApi createBranch(String branch) throws Exception {
- return createBranch(BranchNameKey.create(project, branch));
- }
-
private ThrowableSubject assertThatQueryException(String query) throws Exception {
try {
query(query);
@@ -4726,17 +4313,4 @@
private interface AddReviewerCaller {
void call(String changeId, String reviewer) throws RestApiException;
}
-
- private static class TestWorkInProgressStateChangedListener
- implements WorkInProgressStateChangedListener {
- boolean invoked;
- Boolean wip;
-
- @Override
- public void onWorkInProgressStateChanged(Event event) {
- this.invoked = true;
- this.wip =
- event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
- }
- }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index 40dd70e..fd9af0e 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -19,8 +19,8 @@
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.SubmitRequirementInfo;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
new file mode 100644
index 0000000..aee7f6f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/CreateMergePatchSetIT.java
@@ -0,0 +1,641 @@
+package com.google.gerrit.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
+import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
+import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
+import static com.google.gerrit.git.ObjectIds.abbreviateName;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.MergePatchSetInput;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.inject.Inject;
+import java.io.ByteArrayOutputStream;
+import java.util.List;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CreateMergePatchSetIT extends AbstractDaemonTest {
+
+ @Inject private ProjectOperations projectOperations;
+ @Inject private RequestScopeOperations requestScopeOperations;
+ @Inject private ExtensionRegistry extensionRegistry;
+
+ @Before
+ public void setUp() {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ }
+
+ @Test
+ public void createMergePatchSet() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+ String parent = currentMaster.getCommit().getName();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ String subject = "update change by merge ps2";
+ in.subject = subject;
+
+ TestWorkInProgressStateChangedListener wipStateChangedListener =
+ new TestWorkInProgressStateChangedListener();
+ try (ExtensionRegistry.Registration registration =
+ extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+ ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+ assertThat(changeInfo.subject).isEqualTo(in.subject);
+ assertThat(changeInfo.containsGitConflicts).isNull();
+ assertThat(changeInfo.workInProgress).isNull();
+ }
+ assertThat(wipStateChangedListener.invoked).isFalse();
+
+ // To get the revisions, we must retrieve the change with more change options.
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(parent);
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+ assertThat(messages).hasSize(2);
+ assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.message)
+ .contains(subject);
+ }
+
+ @Test
+ public void createMergePatchSet_SubjectCarriesOverByDefault() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ PushOneCommit.Result result = createChange();
+ String changeId = result.getChangeId();
+ String subject = result.getChange().change().getSubject();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result pushResult =
+ pushFactory.create(user.newIdent(), testRepo).to("refs/heads/dev");
+ pushResult.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = null;
+
+ // Ensure subject carries over
+ gApi.changes().id(changeId).createMergePatchSet(in);
+ ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+ assertThat(changeInfo.subject).isEqualTo(subject);
+ }
+
+ @Test
+ public void createMergePatchSet_Conflict() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ String fileName = "shared.txt";
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster =
+ pushFactory
+ .create(admin.newIdent(), testRepo, "change 1", fileName, "content 1")
+ .to("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change 2", fileName, "content 2")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "update change by merge ps2";
+ ResourceConflictException thrown =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(thrown).hasMessageThat().isEqualTo("merge conflict(s):\n" + fileName);
+ }
+
+ @Test
+ public void createMergePatchSet_ConflictAllowed() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ String fileName = "shared.txt";
+ String sourceSubject = "source change";
+ String sourceContent = "source content";
+ String targetSubject = "target change";
+ String targetContent = "target content";
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster =
+ pushFactory
+ .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+ .to("refs/heads/master");
+ currentMaster.assertOkStatus();
+ String parent = currentMaster.getCommit().getName();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ mergeInput.allowConflicts = true;
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "update change by merge ps2";
+
+ TestWorkInProgressStateChangedListener wipStateChangedListener =
+ new TestWorkInProgressStateChangedListener();
+ try (ExtensionRegistry.Registration registration =
+ extensionRegistry.newRegistration().add(wipStateChangedListener)) {
+ ChangeInfo changeInfo = gApi.changes().id(changeId).createMergePatchSet(in);
+ assertThat(changeInfo.subject).isEqualTo(in.subject);
+ assertThat(changeInfo.containsGitConflicts).isTrue();
+ assertThat(changeInfo.workInProgress).isTrue();
+ }
+ assertThat(wipStateChangedListener.invoked).isTrue();
+ assertThat(wipStateChangedListener.wip).isTrue();
+
+ // To get the revisions, we must retrieve the change with more change options.
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(parent);
+
+ // Verify that the file content in the created patch set is correct.
+ // We expect that it has conflict markers to indicate the conflict.
+ BinaryResult bin = gApi.changes().id(changeId).current().file(fileName).content();
+ ByteArrayOutputStream os = new ByteArrayOutputStream();
+ bin.writeTo(os);
+ String fileContent = new String(os.toByteArray(), UTF_8);
+ String sourceSha1 = abbreviateName(changeA.getCommit(), 6);
+ String targetSha1 = abbreviateName(currentMaster.getCommit(), 6);
+ assertThat(fileContent)
+ .isEqualTo(
+ "<<<<<<< TARGET BRANCH ("
+ + targetSha1
+ + " "
+ + targetSubject
+ + ")\n"
+ + targetContent
+ + "\n"
+ + "=======\n"
+ + sourceContent
+ + "\n"
+ + ">>>>>>> SOURCE BRANCH ("
+ + sourceSha1
+ + " "
+ + sourceSubject
+ + ")\n");
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+ assertThat(messages).hasSize(2);
+ assertThat(Iterables.getLast(messages).message)
+ .isEqualTo(
+ "Uploaded patch set 2.\n\n"
+ + "The following files contain Git conflicts:\n"
+ + "* "
+ + fileName
+ + "\n");
+ }
+
+ @Test
+ public void createMergePatchSet_ConflictAllowedNotSupportedByMergeStrategy() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ String fileName = "shared.txt";
+ String sourceSubject = "source change";
+ String sourceContent = "source content";
+ String targetSubject = "target change";
+ String targetContent = "target content";
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster =
+ pushFactory
+ .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+ .to("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ mergeInput.allowConflicts = true;
+ mergeInput.strategy = "simple-two-way-in-core";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "update change by merge ps2";
+
+ BadRequestException ex =
+ assertThrows(
+ BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(ex)
+ .hasMessageThat()
+ .isEqualTo(
+ "merge with conflicts is not supported with merge strategy: " + mergeInput.strategy);
+ }
+
+ @Test
+ public void createMergePatchSetInheritParent() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ PushOneCommit.Result r = createChange();
+ String changeId = r.getChangeId();
+ String parent = r.getCommit().getParent(0).getName();
+
+ // advance master branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "update change by merge ps2 inherit parent of ps1";
+ in.inheritParent = true;
+ gApi.changes().id(changeId).createMergePatchSet(in);
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.subject).isEqualTo(in.subject);
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(parent);
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isNotEqualTo(currentMaster.getCommit().getName());
+ }
+
+ @Test
+ public void createMergePatchSetCannotBaseOnInvisibleChange() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "foo"));
+ createBranch(BranchNameKey.create(project, "bar"));
+
+ // Create a merged commit on 'foo' branch.
+ merge(createChange("refs/for/foo"));
+
+ // Create the base change on 'bar' branch.
+ testRepo.reset(initialHead);
+ String baseChange = createChange("refs/for/bar").getChangeId();
+ gApi.changes().id(baseChange).setPrivate(true, "set private");
+
+ // Create the destination change on 'master' branch.
+ requestScopeOperations.setApiUser(user.id());
+ testRepo.reset(initialHead);
+ String changeId = createChange().getChangeId();
+
+ UnprocessableEntityException thrown =
+ assertThrows(
+ UnprocessableEntityException.class,
+ () ->
+ gApi.changes()
+ .id(changeId)
+ .createMergePatchSet(createMergePatchSetInput(baseChange)));
+ assertThat(thrown).hasMessageThat().contains("Read not permitted for " + baseChange);
+ }
+
+ @Test
+ public void createMergePatchSetBaseOnChange() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "foo"));
+ createBranch(BranchNameKey.create(project, "bar"));
+
+ // Create a merged commit on 'foo' branch.
+ merge(createChange("refs/for/foo"));
+
+ // Create the base change on 'bar' branch.
+ testRepo.reset(initialHead);
+ PushOneCommit.Result result = createChange("refs/for/bar");
+ String baseChange = result.getChangeId();
+ String expectedParent = result.getCommit().getName();
+
+ // Create the destination change on 'master' branch.
+ testRepo.reset(initialHead);
+ String changeId = createChange().getChangeId();
+
+ gApi.changes().id(changeId).createMergePatchSet(createMergePatchSetInput(baseChange));
+
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.subject).isEqualTo("create ps2");
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(expectedParent);
+ }
+
+ @Test
+ public void createMergePatchSetWithUnupportedMergeStrategy() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ String fileName = "shared.txt";
+ String sourceSubject = "source change";
+ String sourceContent = "source content";
+ String targetSubject = "target change";
+ String targetContent = "target content";
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster =
+ pushFactory
+ .create(admin.newIdent(), testRepo, targetSubject, fileName, targetContent)
+ .to("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, sourceSubject, fileName, sourceContent)
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ mergeInput.strategy = "unsupported-strategy";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "update change by merge ps2";
+
+ BadRequestException ex =
+ assertThrows(
+ BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(ex).hasMessageThat().isEqualTo("invalid merge strategy: " + mergeInput.strategy);
+ }
+
+ @Test
+ public void createMergePatchSetWithOtherAuthor() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+ String parent = currentMaster.getCommit().getName();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ String subject = "update change by merge ps2";
+ in.subject = subject;
+ in.author = new AccountInput();
+ in.author.name = "Other Author";
+ in.author.email = "otherauthor@example.com";
+ gApi.changes().id(changeId).createMergePatchSet(in);
+
+ // To get the revisions, we must retrieve the change with more change options.
+ ChangeInfo changeInfo =
+ gApi.changes().id(changeId).get(ALL_REVISIONS, CURRENT_COMMIT, CURRENT_REVISION);
+ assertThat(changeInfo.revisions).hasSize(2);
+ assertThat(changeInfo.revisions.get(changeInfo.currentRevision).commit.parents.get(0).commit)
+ .isEqualTo(parent);
+
+ // Verify the message that has been posted on the change.
+ List<ChangeMessageInfo> messages = gApi.changes().id(changeId).messages();
+ assertThat(messages).hasSize(2);
+ assertThat(Iterables.getLast(messages).message).isEqualTo("Uploaded patch set 2.");
+
+ CommitInfo commitInfo = changeInfo.revisions.get(changeInfo.currentRevision).commit;
+ assertThat(commitInfo.message).contains(subject);
+ assertThat(commitInfo.author.name).isEqualTo("Other Author");
+ assertThat(commitInfo.author.email).isEqualTo("otherauthor@example.com");
+ }
+
+ @Test
+ public void createMergePatchSetWithSpecificAuthorButNoForgeAuthorPermission() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ String subject = "update change by merge ps2";
+ in.subject = subject;
+ in.author = new AccountInput();
+ in.author.name = "Foo";
+ in.author.email = "foo@example.com";
+
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .remove(
+ TestProjectUpdate.permissionKey(Permission.FORGE_AUTHOR)
+ .ref("refs/*")
+ .group(REGISTERED_USERS))
+ .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ AuthException ex =
+ assertThrows(
+ AuthException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(ex).hasMessageThat().isEqualTo("not permitted: forge author on refs/heads/master");
+ }
+
+ @Test
+ public void createMergePatchSetWithMissingNameFailsWithBadRequestException() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ String subject = "update change by merge ps2";
+ in.subject = subject;
+ in.author = new AccountInput();
+ in.author.name = "Foo";
+
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ BadRequestException ex =
+ assertThrows(
+ BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+ }
+
+ @Test
+ public void createMergePatchSetWithMissingEmailFailsWithBadRequestException() throws Exception {
+ RevCommit initialHead = projectOperations.project(project).getHead("master");
+ createBranch(BranchNameKey.create(project, "dev"));
+
+ // create a change for master
+ String changeId = createChange().getChangeId();
+
+ testRepo.reset(initialHead);
+ PushOneCommit.Result currentMaster = pushTo("refs/heads/master");
+ currentMaster.assertOkStatus();
+
+ // push a commit into dev branch
+ testRepo.reset(initialHead);
+ PushOneCommit.Result changeA =
+ pushFactory
+ .create(user.newIdent(), testRepo, "change A", "A.txt", "A content")
+ .to("refs/heads/dev");
+ changeA.assertOkStatus();
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "dev";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ String subject = "update change by merge ps2";
+ in.subject = subject;
+ in.author = new AccountInput();
+ in.author.email = "Foo";
+
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.FORGE_AUTHOR).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ BadRequestException ex =
+ assertThrows(
+ BadRequestException.class, () -> gApi.changes().id(changeId).createMergePatchSet(in));
+ assertThat(ex).hasMessageThat().isEqualTo("Author must specify name and email");
+ }
+
+ private MergePatchSetInput createMergePatchSetInput(String baseChange) {
+ MergeInput mergeInput = new MergeInput();
+ mergeInput.source = "foo";
+ MergePatchSetInput in = new MergePatchSetInput();
+ in.merge = mergeInput;
+ in.subject = "create ps2";
+ in.inheritParent = false;
+ in.baseChange = baseChange;
+ return in;
+ }
+
+ private static class TestWorkInProgressStateChangedListener
+ implements WorkInProgressStateChangedListener {
+ boolean invoked;
+ Boolean wip;
+
+ @Override
+ public void onWorkInProgressStateChanged(Event event) {
+ this.invoked = true;
+ this.wip =
+ event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
index d5089ff..31198d5 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginFieldsIT.java
@@ -19,6 +19,7 @@
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.server.change.ChangeAttributeFactory;
import com.google.inject.AbstractModule;
+import java.util.Arrays;
import org.junit.Test;
@NoHttpd
@@ -50,6 +51,18 @@
}
@Test
+ public void querySingleChangeWithBulkAttribute() throws Exception {
+ getSingleChangeWithPluginDefinedBulkAttribute(
+ id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()));
+ }
+
+ @Test
+ public void getSingleChangeWithBulkAttribute() throws Exception {
+ getSingleChangeWithPluginDefinedBulkAttribute(
+ id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.toString()).get())));
+ }
+
+ @Test
public void queryChangeWithOption() throws Exception {
getChangeWithOption(
id -> pluginInfoFromSingletonList(gApi.changes().query(id.toString()).get()),
@@ -65,6 +78,53 @@
(id, opts) -> pluginInfoFromChangeInfo(gApi.changes().id(id.get()).get(opts)));
}
+ @Test
+ public void queryChangeWithOptionBulkAttribute() throws Exception {
+ getChangeWithPluginDefinedBulkAttributeOption(
+ id -> pluginInfosFromChangeInfos(gApi.changes().query(id.toString()).get()),
+ (id, opts) ->
+ pluginInfosFromChangeInfos(
+ gApi.changes().query(id.toString()).withPluginOptions(opts).get()));
+ }
+
+ @Test
+ public void getChangeWithOptionBulkAttribute() throws Exception {
+ getChangeWithPluginDefinedBulkAttributeOption(
+ id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())),
+ (id, opts) ->
+ pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get(opts))));
+ }
+
+ @Test
+ public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+ getMultipleChangesWithPluginDefinedBulkAttribute(
+ () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+ }
+
+ @Test
+ public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+ getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+ () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+ }
+
+ @Test
+ public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+ getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+ () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+ }
+
+ @Test
+ public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+ getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+ () -> pluginInfosFromChangeInfos(gApi.changes().query("status:open").get()));
+ }
+
+ @Test
+ public void getChangeWithPluginDefinedException() throws Exception {
+ getChangeWithPluginDefinedBulkAttributeWithException(
+ id -> pluginInfosFromChangeInfos(Arrays.asList(gApi.changes().id(id.get()).get())));
+ }
+
static class SimpleAttributeWithExplicitExportModule extends AbstractModule {
@Override
public void configure() {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
new file mode 100644
index 0000000..f4cf96d
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/change/PluginOperatorsIT.java
@@ -0,0 +1,103 @@
+// 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.acceptance.api.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.restapi.change.QueryChanges;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.List;
+import org.junit.Test;
+
+public class PluginOperatorsIT extends AbstractDaemonTest {
+ @Inject private Provider<QueryChanges> queryChangesProvider;
+
+ @Test
+ public void getChangeWithIsOperator() throws Exception {
+ QueryChanges queryChanges = queryChangesProvider.get();
+ queryChanges.addQuery("is:changeNumberEven_myplugin");
+
+ String oddChangeId = createChange().getChangeId();
+ String evenChangeId = createChange().getChangeId();
+ assertThat(getChanges(queryChanges)).hasSize(0);
+
+ try (AutoCloseable ignored = installPlugin("myplugin", IsOperatorModule.class)) {
+ List<?> changes = getChanges(queryChanges);
+ assertThat(changes).hasSize(1);
+
+ ChangeInfo c = (ChangeInfo) changes.get(0);
+ String outputChangeId = c.changeId;
+ assertThat(outputChangeId).isEqualTo(evenChangeId);
+ assertThat(outputChangeId).isNotEqualTo(oddChangeId);
+ }
+
+ assertThat(getChanges(queryChanges)).hasSize(0);
+ }
+
+ protected static class IsOperatorModule extends AbstractModule {
+ @Override
+ public void configure() {
+ bind(ChangeQueryBuilder.ChangeIsOperandFactory.class)
+ .annotatedWith(Exports.named("changeNumberEven"))
+ .to(SampleIsOperand.class);
+ }
+ }
+
+ private static class SampleIsOperand implements ChangeQueryBuilder.ChangeIsOperandFactory {
+ @Override
+ public Predicate<ChangeData> create(ChangeQueryBuilder builder) throws QueryParseException {
+ return new IsSamplePredicate();
+ }
+ }
+
+ private static class IsSamplePredicate extends OperatorPredicate<ChangeData>
+ implements Matchable<ChangeData> {
+
+ public IsSamplePredicate() {
+ super("is", "changeNumberEven");
+ }
+
+ @Override
+ public boolean match(ChangeData changeData) {
+ int id = changeData.getId().get();
+ return id % 2 == 0;
+ }
+
+ @Override
+ public int getCost() {
+ return 0;
+ }
+ }
+
+ private List<?> getChanges(QueryChanges queryChanges)
+ throws AuthException, PermissionBackendException, BadRequestException {
+ return queryChanges.apply(TopLevelResource.INSTANCE).value();
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
index f043c9b..97b7148 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PrivateChangeIT.java
@@ -24,8 +24,8 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 448f347..33ec556 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -29,9 +29,9 @@
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.common.ChangeInfo;
@@ -153,16 +153,16 @@
// Create hidden project.
Project.NameKey hiddenProject = projectOperations.newProject().create();
+ TestRepository<InMemoryRepository> hiddenRepo = cloneProject(hiddenProject, admin);
+ // Create 2 hidden changes.
+ createChange(hiddenRepo);
+ createChange(hiddenRepo);
+ // Actually hide project
projectOperations
.project(hiddenProject)
.forUpdate()
.add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
.update();
- TestRepository<InMemoryRepository> hiddenRepo = cloneProject(hiddenProject, admin);
-
- // Create 2 hidden changes.
- createChange(hiddenRepo);
- createChange(hiddenRepo);
// Create a change query that matches all changes (visible and hidden changes).
// The index returns the changes ordered by last updated timestamp:
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index b855e72..a3a089f 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -26,8 +26,8 @@
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -250,6 +250,17 @@
}
@Test
+ public void revertChangeWithWip() throws Exception {
+ PushOneCommit.Result r = createChange();
+ gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+ gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+ RevertInput in = createWipRevertInput();
+ ChangeInfo revertChange = gApi.changes().id(r.getChangeId()).revert(in).get();
+ assertThat(revertChange.workInProgress).isTrue();
+ }
+
+ @Test
public void revertWithDefaultTopic() throws Exception {
PushOneCommit.Result result = createChange();
gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
@@ -321,6 +332,18 @@
}
@Test
+ public void revertNotificationsSupressedOnWip() throws Exception {
+ PushOneCommit.Result r = createChange();
+ gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+ gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());
+ gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
+
+ sender.clear();
+ gApi.changes().id(r.getChangeId()).revert(createWipRevertInput()).get();
+ assertThat(sender.getMessages()).isEmpty();
+ }
+
+ @Test
public void suppressRevertNotifications() throws Exception {
PushOneCommit.Result r = createChange();
gApi.changes().id(r.getChangeId()).addReviewer(user.email());
@@ -659,6 +682,49 @@
}
@Test
+ public void revertSubmissionWipNotificationsAreSupressed() throws Exception {
+ String changeId1 = createChange("first change", "a.txt", "message").getChangeId();
+ approve(changeId1);
+ gApi.changes().id(changeId1).addReviewer(user.email());
+ String changeId2 = createChange("second change", "b.txt", "other").getChangeId();
+ approve(changeId2);
+ gApi.changes().id(changeId2).addReviewer(user.email());
+
+ gApi.changes().id(changeId2).current().submit();
+
+ sender.clear();
+
+ RevertInput revertInput = createWipRevertInput();
+ // Setting the Notifications to ALL will be overridden because the WIP flag overrides the
+ // notifications to OWNER
+ revertInput.notify = NotifyHandling.ALL;
+ gApi.changes().id(changeId2).revertSubmission(revertInput);
+
+ assertThat(sender.getMessages()).isEmpty();
+ }
+
+ @Test
+ public void revertSubmissionWipMarksAllChangesAsWip() throws Exception {
+ String changeId1 = createChange("first change", "a.txt", "message").getChangeId();
+ approve(changeId1);
+ gApi.changes().id(changeId1).addReviewer(user.email());
+ String changeId2 = createChange("second change", "b.txt", "other").getChangeId();
+ approve(changeId2);
+ gApi.changes().id(changeId2).addReviewer(user.email());
+
+ gApi.changes().id(changeId2).current().submit();
+
+ sender.clear();
+
+ RevertInput revertInput = createWipRevertInput();
+ RevertSubmissionInfo revertSubmissionInfo =
+ gApi.changes().id(changeId2).revertSubmission(revertInput);
+
+ assertThat(revertSubmissionInfo.revertChanges.stream().allMatch(r -> r.workInProgress))
+ .isTrue();
+ }
+
+ @Test
public void revertSubmissionIdenticalTreeIsAllowed() throws Exception {
String unrelatedChange = createChange("change1", "a.txt", "message").getChangeId();
approve(unrelatedChange);
@@ -1294,4 +1360,10 @@
}
return results;
}
+
+ private RevertInput createWipRevertInput() {
+ RevertInput input = new RevertInput();
+ input.workInProgress = true;
+ return input;
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 3d8a034..58ea6ea 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -39,7 +39,7 @@
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.CherryPickInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index acebe67..0d94ce6 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -56,10 +56,10 @@
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -85,6 +85,7 @@
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.GroupIncludeCache;
import com.google.gerrit.server.account.GroupsSnapshotReader;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.auth.ldap.FakeLdapGroupBackend;
import com.google.gerrit.server.group.InternalGroup;
import com.google.gerrit.server.group.PeriodicGroupIndexer;
@@ -916,7 +917,9 @@
@Test
public void defaultGroupsCreated() throws Exception {
Iterable<String> names = gApi.groups().list().getAsMap().keySet();
- assertThat(names).containsAtLeast("Administrators", "Non-Interactive Users").inOrder();
+ assertThat(names)
+ .containsAtLeast("Administrators", ServiceUserClassifier.SERVICE_USERS)
+ .inOrder();
}
@Test
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 3fc6e44..59493be 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -27,8 +27,8 @@
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.config.AccessCheckInfo;
@@ -162,28 +162,37 @@
String project;
String permission;
int want;
+ List<String> expectedDebugLogs;
- static TestCase project(String mail, String project, int want) {
+ static TestCase project(String mail, String project, int want, List<String> expectedDebugLogs) {
TestCase t = new TestCase();
t.input = new AccessCheckInput();
t.input.account = mail;
t.project = project;
t.want = want;
+ t.expectedDebugLogs = expectedDebugLogs;
return t;
}
- static TestCase projectRef(String mail, String project, String ref, int want) {
+ static TestCase projectRef(
+ String mail, String project, String ref, int want, List<String> expectedDebugLogs) {
TestCase t = new TestCase();
t.input = new AccessCheckInput();
t.input.account = mail;
t.input.ref = ref;
t.project = project;
t.want = want;
+ t.expectedDebugLogs = expectedDebugLogs;
return t;
}
static TestCase projectRefPerm(
- String mail, String project, String ref, String permission, int want) {
+ String mail,
+ String project,
+ String ref,
+ String permission,
+ int want,
+ List<String> expectedDebugLogs) {
TestCase t = new TestCase();
t.input = new AccessCheckInput();
t.input.account = mail;
@@ -191,6 +200,7 @@
t.input.permission = permission;
t.project = project;
t.want = want;
+ t.expectedDebugLogs = expectedDebugLogs;
return t;
}
}
@@ -217,27 +227,98 @@
normalProject.get(),
"refs/heads/master",
Permission.VIEW_PRIVATE_CHANGES,
- 403),
- TestCase.project(user.email(), normalProject.get(), 200),
- TestCase.project(user.email(), secretProject.get(), 403),
+ 403,
+ ImmutableList.of(
+ "'user' can perform 'read' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/*'",
+ "'user' cannot perform 'viewPrivateChanges' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/heads/master'")),
+ TestCase.project(
+ user.email(),
+ normalProject.get(),
+ 200,
+ ImmutableList.of(
+ "'user' can perform 'read' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/*'")),
+ TestCase.project(
+ user.email(),
+ secretProject.get(),
+ 403,
+ ImmutableList.of(
+ "'user' cannot perform 'read' with force=false on project '"
+ + secretProject.get()
+ + "' for ref 'refs/*' because this permission is blocked")),
TestCase.projectRef(
- user.email(), secretRefProject.get(), "refs/heads/secret/master", 403),
+ user.email(),
+ secretRefProject.get(),
+ "refs/heads/secret/master",
+ 403,
+ ImmutableList.of(
+ "'user' can perform 'read' with force=false on project '"
+ + secretRefProject.get()
+ + "' for ref 'refs/heads/*'",
+ "'user' cannot perform 'read' with force=false on project '"
+ + secretRefProject.get()
+ + "' for ref 'refs/heads/secret/master' because this permission is blocked")),
TestCase.projectRef(
- privilegedUser.email(), secretRefProject.get(), "refs/heads/secret/master", 200),
- TestCase.projectRef(privilegedUser.email(), normalProject.get(), null, 200),
- TestCase.projectRef(privilegedUser.email(), secretProject.get(), null, 200),
+ privilegedUser.email(),
+ secretRefProject.get(),
+ "refs/heads/secret/master",
+ 200,
+ ImmutableList.of(
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + secretRefProject.get()
+ + "' for ref 'refs/heads/*'",
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + secretRefProject.get()
+ + "' for ref 'refs/heads/secret/master'")),
+ TestCase.projectRef(
+ privilegedUser.email(),
+ normalProject.get(),
+ null,
+ 200,
+ ImmutableList.of(
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/*'")),
+ TestCase.projectRef(
+ privilegedUser.email(),
+ secretProject.get(),
+ null,
+ 200,
+ ImmutableList.of(
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + secretProject.get()
+ + "' for ref 'refs/*'")),
TestCase.projectRefPerm(
privilegedUser.email(),
normalProject.get(),
"refs/heads/master",
Permission.VIEW_PRIVATE_CHANGES,
- 200),
+ 200,
+ ImmutableList.of(
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/*'",
+ "'privilegedUser' can perform 'viewPrivateChanges' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/heads/master'")),
TestCase.projectRefPerm(
privilegedUser.email(),
normalProject.get(),
"refs/heads/master",
Permission.FORGE_SERVER,
- 200));
+ 200,
+ ImmutableList.of(
+ "'privilegedUser' can perform 'read' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/*'",
+ "'privilegedUser' can perform 'forgeServerAsCommitter' with force=false on project '"
+ + normalProject.get()
+ + "' for ref 'refs/heads/master'")));
for (TestCase tc : inputs) {
String in = newGson().toJson(tc.input);
@@ -273,6 +354,14 @@
default:
assertWithMessage(String.format("unknown code %d", want)).fail();
}
+
+ if (!info.debugLogs.equals(tc.expectedDebugLogs)) {
+ assertWithMessage(
+ String.format(
+ "check.access(%s, %s) = %s, want %s",
+ tc.project, in, info.debugLogs, tc.expectedDebugLogs))
+ .fail();
+ }
}
}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
index e67770c..80e04c0 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CommitIT.java
@@ -24,8 +24,8 @@
import com.google.gerrit.acceptance.PushOneCommit.Result;
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.changes.CherryPickInput;
import com.google.gerrit.extensions.api.changes.IncludedInInfo;
import com.google.gerrit.extensions.api.changes.ReviewInput;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
index 6442645..a22b558 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/DashboardIT.java
@@ -24,7 +24,7 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.api.projects.DashboardInfo;
import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 840d3e0..e99a6f5 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -39,8 +39,8 @@
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.annotations.Exports;
diff --git a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
index 1539334..2bdbe50 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/SetParentIT.java
@@ -23,7 +23,7 @@
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
new file mode 100644
index 0000000..7197425
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/revision/PortedCommentsIT.java
@@ -0,0 +1,1912 @@
+// 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.acceptance.api.revision;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.collect.MoreCollectors.onlyElement;
+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.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Correspondence;
+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;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+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 {
+
+ @Inject private ChangeOperations changeOps;
+ @Inject private AccountOperations accountOps;
+ @Inject private RequestScopeOperations requestScopeOps;
+
+ @Test
+ public void onlyCommentsBeforeTargetPatchsetArePorted() 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();
+ PatchSet.Id patchset3Id = changeOps.change(changeId).newPatchset().create();
+ // Add comments.
+ String comment1Uuid = newComment(patchset1Id).create();
+ newComment(patchset2Id).create();
+ newComment(patchset3Id).create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThatList(portedComments).comparingElementsUsing(hasUuid()).containsExactly(comment1Uuid);
+ }
+
+ @Test
+ public void commentsOnAnyPatchsetBeforeTargetPatchsetArePorted() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ changeOps.change(changeId).newPatchset().create();
+ PatchSet.Id patchset3Id = changeOps.change(changeId).newPatchset().create();
+ PatchSet.Id patchset4Id = changeOps.change(changeId).newPatchset().create();
+ // Add comments.
+ String comment1Uuid = newComment(patchset1Id).create();
+ String comment3Uuid = newComment(patchset3Id).create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset4Id));
+
+ assertThat(portedComments)
+ .comparingElementsUsing(hasUuid())
+ .containsExactly(comment1Uuid, comment3Uuid);
+ }
+
+ @Test
+ public void severalCommentsFromEarlierPatchsetArePorted() 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 comment1Uuid = newComment(patchset1Id).create();
+ String comment2Uuid = newComment(patchset1Id).create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThat(portedComments)
+ .comparingElementsUsing(hasUuid())
+ .containsExactly(comment1Uuid, comment2Uuid);
+ }
+
+ @Test
+ public void completeCommentThreadIsPorted() 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 rootCommentUuid = newComment(patchset1Id).create();
+ String child1CommentUuid = newComment(patchset1Id).parentUuid(rootCommentUuid).create();
+ String child2CommentUuid = newComment(patchset1Id).parentUuid(child1CommentUuid).create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThat(portedComments)
+ .comparingElementsUsing(hasUuid())
+ .containsExactly(rootCommentUuid, child1CommentUuid, child2CommentUuid);
+ }
+
+ @Test
+ 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.
+ newComment(patchset1Id).resolved().create();
+ String comment2Uuid = newComment(patchset1Id).unresolved().create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(comment2Uuid);
+ }
+
+ @Test
+ public void resolvedAndUnresolvedDraftCommentsArePorted() 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 comment1Uuid = 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(comment1Uuid, 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 = newComment(patchset1Id).resolved().create();
+ String childComment1Uuid =
+ newComment(patchset1Id).parentUuid(rootComment1Uuid).unresolved().create();
+ String rootComment2Uuid = newComment(patchset1Id).unresolved().create();
+ newComment(patchset1Id).parentUuid(rootComment2Uuid).resolved().create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThat(portedComments)
+ .comparingElementsUsing(hasUuid())
+ .containsExactly(rootComment1Uuid, childComment1Uuid);
+ }
+
+ @Test
+ public void unresolvedStateOfLastCommentByDateMattersForBranchedThreads() 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. 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 =
+ newComment(patchset1Id)
+ .parentUuid(rootCommentUuid)
+ .resolved()
+ .createdOn(now.plusSeconds(5))
+ .create();
+ String childComment2Uuid =
+ newComment(patchset1Id)
+ .parentUuid(rootCommentUuid)
+ .unresolved()
+ .createdOn(now.plusSeconds(10))
+ .create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThat(portedComments)
+ .comparingElementsUsing(hasUuid())
+ .containsExactly(rootCommentUuid, childComment1Uuid, childComment2Uuid);
+ }
+
+ @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.
+ Change.Id changeId = changeOps.newChange().create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add draft comment.
+ newDraftComment(patchset1Id).author(accountId).create();
+
+ // Draft comments are only visible to their author.
+ requestScopeOps.setApiUser(accountId);
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThatList(portedComments).isEmpty();
+ }
+
+ @Test
+ public void publishedCommentsAreNotPortedViaApiForDraftComments() 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 comment.
+ newComment(patchset1Id).author(accountId).create();
+
+ List<CommentInfo> portedComments =
+ flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
+
+ assertThatList(portedComments).isEmpty();
+ }
+
+ @Test
+ public void draftCommentCanBePorted() 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 draft comment.
+ newComment(patchset1Id).author(accountId).create();
+
+ List<CommentInfo> portedComments =
+ flatten(getPortedDraftCommentsOfUser(patchset2Id, accountId));
+
+ assertThatList(portedComments).isEmpty();
+ }
+
+ @Test
+ public void portedDraftCommentOfOtherUserIsNotVisible() throws Exception {
+ Account.Id userId = accountOps.newAccount().create();
+ Account.Id otherUserId = 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 draft comment.
+ newComment(patchset1Id).author(otherUserId).create();
+
+ List<CommentInfo> portedComments = flatten(getPortedDraftCommentsOfUser(patchset2Id, userId));
+
+ assertThatList(portedComments).isEmpty();
+ }
+
+ @Test
+ public void publishedCommentsOfAllTypesArePorted() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comments.
+ String rangeCommentUuid =
+ newComment(patchset1Id)
+ .message("Range comment")
+ .fromLine(1)
+ .charOffset(2)
+ .toLine(2)
+ .charOffset(1)
+ .ofFile("myFile")
+ .create();
+ String lineCommentUuid =
+ newComment(patchset1Id).message("Line comment").onLine(1).ofFile("myFile").create();
+ String fileCommentUuid =
+ newComment(patchset1Id).message("File comment").onFileLevelOf("myFile").create();
+ String patchsetLevelCommentUuid =
+ newComment(patchset1Id).message("Patchset-level comment").onPatchsetLevel().create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThat(portedComments)
+ .comparingElementsUsing(hasUuid())
+ .containsExactly(
+ rangeCommentUuid, lineCommentUuid, fileCommentUuid, patchsetLevelCommentUuid);
+ }
+
+ @Test
+ public void commentOnParentCommitIsPorted() 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 commentUuid = newComment(patchset1Id).onParentCommit().create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+ }
+
+ @Test
+ public void commentOnInvalidParentIsPorted() 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 commentUuid = newComment(patchset1Id).onSecondParentCommit().create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+ }
+
+ @Test
+ public void commentsOnInvalidPositionArePorted() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comments.
+ String commentUuid1 = newComment(patchset1Id).onFileLevelOf("not-existing file").create();
+ String commentUuid2 = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThat(portedComments)
+ .comparingElementsUsing(hasUuid())
+ .containsExactly(commentUuid1, commentUuid2);
+ }
+
+ @Test
+ public void commentsOnInvalidPositionKeepTheirInvalidPosition() 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.
+ newComment(patchset1Id).onFileLevelOf("not-existing file").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("not-existing file");
+ }
+
+ @Test
+ public void portedCommentHasOriginalUuid() 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 comment.
+ String commentUuid = newComment(patchset1Id).create();
+
+ List<CommentInfo> portedComments = flatten(getPortedComments(patchset2Id));
+
+ assertThatList(portedComments).onlyElement().uuid().isEqualTo(commentUuid);
+ }
+
+ @Test
+ public void portedCommentHasOriginalPatchset() 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 comment.
+ String commentUuid = newComment(patchset1Id).create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).patchSet().isEqualTo(patchset1Id.get());
+ }
+
+ @Test
+ public void portedDraftCommentHasPatchsetFilled() throws Exception {
+ // Set up change and patchsets.
+ Account.Id authorId = accountOps.newAccount().create();
+ Change.Id changeId = changeOps.newChange().create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comment.
+ String commentUuid = newDraftComment(patchset1Id).author(authorId).create();
+
+ Map<String, List<CommentInfo>> portedComments =
+ getPortedDraftCommentsOfUser(patchset2Id, authorId);
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+
+ // We explicitly need to request that the patchset field is filled, which we could have missed
+ // for drafts. -> Test that aspect. Don't verify the actual patchset number as that's already
+ // covered by the previous test.
+ assertThat(portedComment).patchSet().isNotNull();
+ }
+
+ @Test
+ public void portedCommentHasOriginalPatchsetCommitId() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().create();
+ TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comment.
+ String commentUuid = newComment(patchset1.patchsetId()).create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).commitId().isEqualTo(patchset1.commitId().name());
+ }
+
+ @Test
+ public void portedCommentHasOriginalMessage() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().create();
+ TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comment.
+ String commentUuid = newComment(patchset1.patchsetId()).message("My comment text").create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).message().isEqualTo("My comment text");
+ }
+
+ @Test
+ public void portedReplyStillRefersToParentComment() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().create();
+ TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comments.
+ String rootCommentUuid = newComment(patchset1.patchsetId()).create();
+ String childCommentUuid =
+ newComment(patchset1.patchsetId()).parentUuid(rootCommentUuid).create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, childCommentUuid);
+
+ assertThat(portedComment).inReplyTo().isEqualTo(rootCommentUuid);
+ }
+
+ @Test
+ public void portedPublishedCommentHasOriginalAuthor() throws Exception {
+ // Set up change and patchsets.
+ Account.Id authorId = accountOps.newAccount().create();
+ Change.Id changeId = changeOps.newChange().create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).author(authorId).create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).author().id().isEqualTo(authorId.get());
+ }
+
+ @Test
+ public void anonymousUsersGetAuthExceptionForPortedDrafts() throws Exception {
+ Change.Id changeId = changeOps.newChange().create();
+ PatchSet.Id patchsetId = changeOps.change(changeId).currentPatchset().get().patchsetId();
+
+ requestScopeOps.setApiUserAnonymous();
+ AuthException thrown =
+ assertThrows(
+ AuthException.class,
+ () ->
+ gApi.changes()
+ .id(patchsetId.changeId().get())
+ .revision(patchsetId.get())
+ .portedDrafts());
+ assertThat(thrown)
+ .hasMessageThat()
+ .contains("requires authentication; only authenticated users can have drafts");
+ }
+
+ @Test
+ public void portedDraftCommentHasNoAuthor() throws Exception {
+ // Set up change and patchsets.
+ Account.Id authorId = accountOps.newAccount().create();
+ Change.Id changeId = changeOps.newChange().create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comment.
+ String commentUuid = newDraftComment(patchset1Id).author(authorId).create();
+
+ Map<String, List<CommentInfo>> portedComments =
+ getPortedDraftCommentsOfUser(patchset2Id, authorId);
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+
+ // Authors of draft comments are never set.
+ assertThat(portedComment).author().isNull();
+ }
+
+ @Test
+ public void portedCommentHasOriginalTag() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().create();
+ TestPatchset patchset1 = changeOps.change(changeId).currentPatchset().get();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comment.
+ String commentUuid = newComment(patchset1.patchsetId()).tag("My comment tag").create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).tag().isEqualTo("My comment tag");
+ }
+
+ @Test
+ public void portedCommentHasUpdatedTimestamp() 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 comment.
+ String commentUuid = newComment(patchset1Id).create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).updated().isNotNull();
+ }
+
+ @Test
+ public void portedCommentDoesNotHaveChangeMessageId() 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 comment.
+ String commentUuid = newComment(patchset1Id).create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ // There's currently no use case for linking ported comments to specific change messages. Hence,
+ // there's no reason to fill this field, which requires additional computations.
+ // Besides, we also don't fill this field for the comments requested for a specific patchset.
+ assertThat(portedComment).changeMessageId().isNull();
+ }
+
+ @Test
+ public void pathOfPortedCommentIsOnlyIndicatedInMap() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id = changeOps.change(changeId).newPatchset().create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("myFile");
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).path().isNull();
+ }
+
+ @Test
+ public void portedRangeCommentCanHandleAddedLines() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(3)
+ .charOffset(2)
+ .toLine(4)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).range().startLine().isEqualTo(5);
+ assertThat(portedComment).range().startCharacter().isEqualTo(2);
+ assertThat(portedComment).range().endLine().isEqualTo(6);
+ assertThat(portedComment).range().endCharacter().isEqualTo(5);
+ }
+
+ @Test
+ public void portedRangeCommentCanHandleDeletedLines() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(3)
+ .charOffset(2)
+ .toLine(4)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).range().startLine().isEqualTo(2);
+ assertThat(portedComment).range().startCharacter().isEqualTo(2);
+ assertThat(portedComment).range().endLine().isEqualTo(3);
+ assertThat(portedComment).range().endCharacter().isEqualTo(5);
+ }
+
+ @Test
+ public void portedRangeCommentCanHandlePureRename() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(3)
+ .charOffset(2)
+ .toLine(4)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("newFileName");
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).range().startLine().isEqualTo(3);
+ assertThat(portedComment).range().startCharacter().isEqualTo(2);
+ assertThat(portedComment).range().endLine().isEqualTo(4);
+ assertThat(portedComment).range().endCharacter().isEqualTo(5);
+ }
+
+ @Test
+ public void portedRangeCommentCanHandleRenameWithLineShift() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .delete()
+ .file("newFileName")
+ .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(3)
+ .charOffset(2)
+ .toLine(4)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("newFileName");
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).range().startLine().isEqualTo(5);
+ assertThat(portedComment).range().startCharacter().isEqualTo(2);
+ assertThat(portedComment).range().endLine().isEqualTo(6);
+ assertThat(portedComment).range().endCharacter().isEqualTo(5);
+ }
+
+ @Test
+ public void portedRangeCommentAdditionallyAppearsOnCopyAtIndependentPosition() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ // Gerrit currently only identifies a copy if a rename also happens at the same time. Modify the
+ // renamed file slightly different than the copied file so that the end location of the comment
+ // is different. Modify the renamed file less so that Gerrit/Git picks it as the renamed one.
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .delete()
+ .file("renamedFiled")
+ .content("Line 1\nLine 1.1\nLine 2\nLine 3\nLine 4\n")
+ .file("copiedFile")
+ .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(3)
+ .charOffset(2)
+ .toLine(4)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("renamedFiled", "copiedFile");
+ CommentInfo portedCommentOnRename = getOnlyElement(portedComments.get("renamedFiled"));
+ assertThat(portedCommentOnRename).uuid().isEqualTo(commentUuid);
+ assertThat(portedCommentOnRename).range().startLine().isEqualTo(4);
+ assertThat(portedCommentOnRename).range().startCharacter().isEqualTo(2);
+ assertThat(portedCommentOnRename).range().endLine().isEqualTo(5);
+ assertThat(portedCommentOnRename).range().endCharacter().isEqualTo(5);
+ CommentInfo portedCommentOnCopy = getOnlyElement(portedComments.get("copiedFile"));
+ assertThat(portedCommentOnCopy).uuid().isEqualTo(commentUuid);
+ assertThat(portedCommentOnCopy).range().startLine().isEqualTo(5);
+ assertThat(portedCommentOnCopy).range().startCharacter().isEqualTo(2);
+ assertThat(portedCommentOnCopy).range().endLine().isEqualTo(6);
+ assertThat(portedCommentOnCopy).range().endCharacter().isEqualTo(5);
+ }
+
+ @Test
+ public void lineOfPortedRangeCommentFollowsContract() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(3)
+ .charOffset(2)
+ .toLine(4)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ // Line is equal to the end line, which is at 6 when ported.
+ assertThat(portedComment).line().isEqualTo(6);
+ }
+
+ @Test
+ public void portedRangeCommentBecomesFileCommentOnConflict() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine two\nLine three\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(2)
+ .charOffset(2)
+ .toLine(3)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("myFile");
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).range().isNull();
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedRangeCommentEndingOnLineJustBeforeModificationCanBePorted() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 2\nLine three\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(1)
+ .charOffset(2)
+ .toLine(2)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).range().startLine().isEqualTo(1);
+ assertThat(portedComment).range().startCharacter().isEqualTo(2);
+ assertThat(portedComment).range().endLine().isEqualTo(2);
+ assertThat(portedComment).range().endCharacter().isEqualTo(5);
+ }
+
+ @Test
+ public void portedRangeCommentEndingAtStartOfModifiedLineCanBePorted() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 2\nLine three\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(1)
+ .charOffset(2)
+ .toLine(3)
+ .charOffset(0)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).range().startLine().isEqualTo(1);
+ assertThat(portedComment).range().startCharacter().isEqualTo(2);
+ assertThat(portedComment).range().endLine().isEqualTo(3);
+ assertThat(portedComment).range().endCharacter().isEqualTo(0);
+ }
+
+ @Test
+ public void portedRangeCommentEndingWithinModifiedLineBecomesFileComment() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 2\nLine three\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(1)
+ .charOffset(2)
+ .toLine(3)
+ .charOffset(4)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).range().isNull();
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedRangeCommentWithinModifiedLineBecomesFileComment() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 2\nLine three\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(3)
+ .charOffset(2)
+ .toLine(3)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).range().isNull();
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedRangeCommentStartingWithinLastModifiedLineBecomesFileComment()
+ throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line one\nLine two\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(2)
+ .charOffset(2)
+ .toLine(4)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).range().isNull();
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedRangeCommentStartingOnLineJustAfterModificationCanBePorted() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine two\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(3)
+ .charOffset(2)
+ .toLine(4)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).range().startLine().isEqualTo(3);
+ assertThat(portedComment).range().startCharacter().isEqualTo(2);
+ assertThat(portedComment).range().endLine().isEqualTo(4);
+ assertThat(portedComment).range().endCharacter().isEqualTo(5);
+ }
+
+ // We could actually do better in such a situation but that involves some careful improvements
+ // which would need to be covered with even more tests (e.g. several modifications could be within
+ // the comment range; several comments could surround it; other modifications could have occurred
+ // in the file so that start is shifted too but different than end). That's why we go for the
+ // simple solution now (-> just map to file comment).
+ @Test
+ public void portedRangeCommentStartingBeforeButEndingAfterModifiedLineBecomesFileComment()
+ throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 2\nLine three\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(2)
+ .charOffset(2)
+ .toLine(4)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).range().isNull();
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedRangeCommentBecomesPatchsetLevelCommentOnFileDeletion() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps.change(changeId).newPatchset().file("myFile").delete().create();
+ // Add comment.
+ String commentUuid =
+ newComment(patchset1Id)
+ .fromLine(3)
+ .charOffset(2)
+ .toLine(4)
+ .charOffset(5)
+ .ofFile("myFile")
+ .create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+ assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).range().isNull();
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void overlappingRangeCommentsArePortedToNewPosition() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 1.1\nLine 2\n")
+ .create();
+ // Add comment.
+ String commentUuid1 =
+ newComment(patchset1Id)
+ .fromLine(2)
+ .charOffset(0)
+ .toLine(2)
+ .charOffset(3)
+ .ofFile("myFile")
+ .create();
+ String commentUuid2 =
+ newComment(patchset1Id)
+ .fromLine(2)
+ .charOffset(1)
+ .toLine(2)
+ .charOffset(4)
+ .ofFile("myFile")
+ .create();
+
+ CommentInfo portedComment1 = getPortedComment(patchset2Id, commentUuid1);
+ assertThat(portedComment1).range().startLine().isEqualTo(3);
+ CommentInfo portedComment2 = getPortedComment(patchset2Id, commentUuid2);
+ assertThat(portedComment2).range().startLine().isEqualTo(3);
+ }
+
+ @Test
+ public void portedLineCommentCanHandleAddedLines() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isEqualTo(5);
+ }
+
+ @Test
+ public void portedLineCommentCanHandleDeletedLines() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isEqualTo(2);
+ }
+
+ @Test
+ public void portedLineCommentCanHandlePureRename() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("newFileName");
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).line().isEqualTo(3);
+ }
+
+ @Test
+ public void portedLineCommentCanHandleRenameWithLineShift() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ PatchSet.Id patchset3Id =
+ changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset3Id);
+
+ assertThatMap(portedComments).keys().containsExactly("newFileName");
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).line().isEqualTo(5);
+ }
+
+ @Test
+ public void portedLineCommentAdditionallyAppearsOnCopyAtIndependentPosition() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ // Gerrit currently only identifies a copy if a rename also happens at the same time. Modify the
+ // renamed file slightly different than the copied file so that the end location of the comment
+ // is different. Modify the renamed file less so that Gerrit/Git picks it as the renamed one.
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .delete()
+ .file("renamedFiled")
+ .content("Line 1\nLine 1.1\nLine 2\nLine 3\nLine 4\n")
+ .file("copiedFile")
+ .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("renamedFiled", "copiedFile");
+ CommentInfo portedCommentOnRename = getOnlyElement(portedComments.get("renamedFiled"));
+ assertThat(portedCommentOnRename).uuid().isEqualTo(commentUuid);
+ assertThat(portedCommentOnRename).line().isEqualTo(4);
+ CommentInfo portedCommentOnCopy = getOnlyElement(portedComments.get("copiedFile"));
+ assertThat(portedCommentOnCopy).uuid().isEqualTo(commentUuid);
+ assertThat(portedCommentOnCopy).line().isEqualTo(5);
+ }
+
+ @Test
+ public void portedLineCommentBecomesFileCommentOnConflict() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine two\nLine three\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("myFile");
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedLineCommentOnLineJustBeforeModificationCanBePorted() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 2\nLine three\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isEqualTo(2);
+ }
+
+ @Test
+ public void portedLineCommentOnStartLineOfModificationBecomesFileComment() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 2\nSome completely\ndifferent\ncontent\n")
+ .create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedLineCommentOnLastLineOfModificationBecomesFileComment() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 2\nSome completely\ndifferent\ncontent\n")
+ .create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(4).ofFile("myFile").create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedLineCommentOnLineJustAfterModificationCanBePorted() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 2\nLine three\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(4).ofFile("myFile").create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isEqualTo(4);
+ }
+
+ @Test
+ public void portedLineCommentBecomesPatchsetLevelCommentOnFileDeletion() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps.change(changeId).newPatchset().file("myFile").delete().create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(3).ofFile("myFile").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+ assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).range().isNull();
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void overlappingLineCommentsArePortedToNewPosition() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\nLine 2\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 1.1\nLine 2\n")
+ .create();
+ // Add comment.
+ String commentUuid1 = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+ String commentUuid2 = newComment(patchset1Id).onLine(2).ofFile("myFile").create();
+
+ CommentInfo portedComment1 = getPortedComment(patchset2Id, commentUuid1);
+ assertThat(portedComment1).line().isEqualTo(3);
+ CommentInfo portedComment2 = getPortedComment(patchset2Id, commentUuid2);
+ assertThat(portedComment2).line().isEqualTo(3);
+ }
+
+ @Test
+ public void portedFileCommentIsObliviousToAdjustedFileContent() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedFileCommentCanHandleRename() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("newFileName");
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedFileCommentAdditionallyAppearsOnCopy() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .renameTo("renamedFiled")
+ .file("copiedFile")
+ .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ newComment(patchset1Id).onFileLevelOf("myFile").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly("renamedFiled", "copiedFile");
+ CommentInfo portedCommentOnCopy = getOnlyElement(portedComments.get("copiedFile"));
+ assertThat(portedCommentOnCopy).line().isNull();
+ }
+
+ @Test
+ public void portedFileCommentBecomesPatchsetLevelCommentOnFileDeletion() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps.change(changeId).newPatchset().file("myFile").delete().create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onFileLevelOf("myFile").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+ assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).range().isNull();
+ assertThat(portedComment).line().isNull();
+ }
+
+ @Test
+ public void portedPatchsetLevelCommentIsObliviousToAdjustedFileContent() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ // Add comment.
+ newComment(patchset1Id).onPatchsetLevel().create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+ }
+
+ @Test
+ public void portedPatchsetLevelCommentIsObliviousToRename() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().file("myFile").content("Line 1\nLine 2\nLine 3\nLine 4\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps.change(changeId).newPatchset().file("myFile").renameTo("newFileName").create();
+ // Add comment.
+ newComment(patchset1Id).onPatchsetLevel().create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(patchset2Id);
+
+ assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+ }
+
+ @Test
+ public void commentOnCommitMessageIsPortedToNewPosition() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId =
+ changeOps.newChange().commitMessage("Summary line\n\nText 1\nText 2").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps
+ .change(changeId)
+ .newPatchset()
+ .commitMessage("Summary line\n\nText 1\nText 1.1\nText 2")
+ .create();
+ // Add comment.
+ String commentUuid =
+ 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)
+ .ofFile(Patch.COMMIT_MSG)
+ .create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isEqualTo(11);
+ }
+
+ @Test
+ public void commentOnParentIsPortedToNewPosition() throws Exception {
+ // Set up change and patchsets.
+ Change.Id parentChangeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+ Change.Id childChangeId =
+ changeOps
+ .newChange()
+ .childOf()
+ .change(parentChangeId)
+ .file("myFile")
+ .content("Line one\n")
+ .create();
+ PatchSet.Id childPatchset1Id =
+ changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+ PatchSet.Id parentPatchset2Id =
+ changeOps
+ .change(parentChangeId)
+ .newPatchset()
+ .file("myFile")
+ .content("Line 0\nLine 1\n")
+ .create();
+ PatchSet.Id childPatchset2Id =
+ changeOps.change(childChangeId).newPatchset().parent().patchset(parentPatchset2Id).create();
+ // Add comment.
+ String commentUuid =
+ newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("myFile").create();
+
+ CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isEqualTo(2);
+ }
+
+ @Test
+ public void commentOnFirstParentIsPortedToNewPosition() throws Exception {
+ // Set up change and patchsets.
+ Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+ Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+ Change.Id childChangeId =
+ changeOps
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .file("file1")
+ .content("Line one\n")
+ .create();
+ PatchSet.Id childPatchset1Id =
+ changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+ PatchSet.Id parent1Patchset2Id =
+ changeOps
+ .change(parent1ChangeId)
+ .newPatchset()
+ .file("file1")
+ .content("Line 0\nLine 1\n")
+ .create();
+ PatchSet.Id childPatchset2Id =
+ changeOps
+ .change(childChangeId)
+ .newPatchset()
+ .parents()
+ .patchset(parent1Patchset2Id)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("file1").create();
+
+ CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isEqualTo(2);
+ }
+
+ @Test
+ public void commentOnSecondParentIsPortedToNewPosition() throws Exception {
+ // Set up change and patchsets.
+ Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+ Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+ Change.Id childChangeId =
+ changeOps
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .file("file2")
+ .content("Line one\n")
+ .create();
+ PatchSet.Id childPatchset1Id =
+ changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+ PatchSet.Id parent2Patchset2Id =
+ changeOps
+ .change(parent1ChangeId)
+ .newPatchset()
+ .file("file2")
+ .content("Line 0\nLine 1\n")
+ .create();
+ PatchSet.Id childPatchset2Id =
+ changeOps
+ .change(childChangeId)
+ .newPatchset()
+ .parents()
+ .change(parent1ChangeId)
+ .and()
+ .patchset(parent2Patchset2Id)
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(childPatchset1Id).onSecondParentCommit().onLine(1).ofFile("file2").create();
+
+ CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isEqualTo(2);
+ }
+
+ @Test
+ public void commentOnAutoMergeCommitIsPortedToNewPosition() throws Exception {
+ // Set up change and patchsets. Use the same file so that there's a meaningful auto-merge
+ // commit/diff.
+ Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+ Change.Id parent2ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+ Change.Id childChangeId =
+ changeOps
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+ PatchSet.Id childPatchset1Id =
+ changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+ PatchSet.Id parent1Patchset2Id =
+ changeOps
+ .change(parent1ChangeId)
+ .newPatchset()
+ .file("file1")
+ .content("Line 0\nLine 1\n")
+ .create();
+ PatchSet.Id parent2Patchset2Id =
+ changeOps
+ .change(parent1ChangeId)
+ .newPatchset()
+ .file("file1")
+ .content("Line zero\nLine 1\n")
+ .create();
+ PatchSet.Id childPatchset2Id =
+ changeOps
+ .change(childChangeId)
+ .newPatchset()
+ .parents()
+ .patchset(parent1Patchset2Id)
+ .and()
+ .patchset(parent2Patchset2Id)
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(childPatchset1Id).onAutoMergeCommit().onLine(1).ofFile("file1").create();
+
+ CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+ // Merging the parents creates a conflict in the file. -> Several lines are added due to
+ // conflict markers in the auto-merge commit. We don't care about the exact number, just that
+ // the comment moved down several lines (instead of just one in each parent) and that the
+ // porting logic hence used the auto-merge commit for its computation.
+ assertThat(portedComment).line().isGreaterThan(2);
+ }
+
+ @Test
+ public void commentOnFirstParentIsPortedToSingleParentWhenPatchsetChangedToNonMergeCommit()
+ throws Exception {
+ // Set up change and patchsets.
+ Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+ Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+ Change.Id childChangeId =
+ changeOps
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+ PatchSet.Id childPatchset1Id =
+ changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+ PatchSet.Id parent1PatchsetId2 =
+ changeOps
+ .change(parent1ChangeId)
+ .newPatchset()
+ .file("file1")
+ .content("Line 0\nLine 1\n")
+ .create();
+ PatchSet.Id childPatchset2Id =
+ changeOps
+ .change(childChangeId)
+ .newPatchset()
+ .parent()
+ .patchset(parent1PatchsetId2)
+ .create();
+ // Add comment.
+ String commentUuid =
+ newComment(childPatchset1Id).onParentCommit().onLine(1).ofFile("file1").create();
+
+ CommentInfo portedComment = getPortedComment(childPatchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isEqualTo(2);
+ assertThat(portedComment).side().isEqualTo(Side.PARENT);
+ assertThat(portedComment).parent().isEqualTo(1);
+ }
+
+ @Test
+ public void commentOnSecondParentBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
+ throws Exception {
+ // Set up change and patchsets.
+ Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+ Change.Id parent2ChangeId = changeOps.newChange().file("file2").content("Line 1\n").create();
+ Change.Id childChangeId =
+ changeOps
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+ PatchSet.Id childPatchset1Id =
+ changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+ PatchSet.Id childPatchset2Id =
+ changeOps.change(childChangeId).newPatchset().parent().change(parent1ChangeId).create();
+ // Add comment.
+ String commentUuid =
+ newComment(childPatchset1Id).onSecondParentCommit().onLine(1).ofFile("file2").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(childPatchset2Id);
+ assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).line().isNull();
+ assertThat(portedComment).side().isNull();
+ assertThat(portedComment).parent().isNull();
+ }
+
+ @Test
+ // TODO(ghareeb): Adjust implementation in CommentsUtil to use the new auto-merge code instead of
+ // PatchListCache#getOldId which returns the wrong result if a change isn't a merge commit.
+ @Ignore
+ public void
+ commentOnAutoMergeCommitBecomesPatchsetLevelCommentWhenPatchsetChangedToNonMergeCommit()
+ throws Exception {
+ // Set up change and patchsets.
+ Change.Id parent1ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+ Change.Id parent2ChangeId = changeOps.newChange().file("file1").content("Line 1\n").create();
+ Change.Id childChangeId =
+ changeOps
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+ PatchSet.Id childPatchset1Id =
+ changeOps.change(childChangeId).currentPatchset().get().patchsetId();
+ PatchSet.Id childPatchset2Id =
+ changeOps.change(childChangeId).newPatchset().parent().change(parent1ChangeId).create();
+ // Add comment.
+ String commentUuid =
+ newComment(childPatchset1Id).onAutoMergeCommit().onLine(1).ofFile("file1").create();
+
+ Map<String, List<CommentInfo>> portedComments = getPortedComments(childPatchset2Id);
+ assertThatMap(portedComments).keys().containsExactly(Patch.PATCHSET_LEVEL);
+ CommentInfo portedComment = extractSpecificComment(portedComments, commentUuid);
+ assertThat(portedComment).line().isNull();
+ assertThat(portedComment).side().isNull();
+ assertThat(portedComment).parent().isNull();
+ }
+
+ @Test
+ public void whitespaceOnlyModificationsAreAlsoConsideredWhenPorting() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().file("myFile").content("Line 1\n").create();
+ PatchSet.Id patchset1Id = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchset2Id =
+ changeOps.change(changeId).newPatchset().file("myFile").content("\nLine 1\n").create();
+ // Add comment.
+ String commentUuid = newComment(patchset1Id).onLine(1).ofFile("myFile").create();
+
+ CommentInfo portedComment = getPortedComment(patchset2Id, commentUuid);
+
+ assertThat(portedComment).line().isEqualTo(2);
+ }
+
+ @Test
+ public void deletedCommentContentIsNotCachedInPortedComments() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().create();
+ PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchsetId2 = changeOps.change(changeId).newPatchset().create();
+ // Add comment.
+ String commentUuid = newComment(patchsetId1).message("Confidential content").create();
+
+ getPortedComment(patchsetId2, commentUuid);
+ gApi.changes()
+ .id(changeId.get())
+ .revision(patchsetId1.get())
+ .comment(commentUuid)
+ .delete(new DeleteCommentInput());
+ CommentInfo portedComment = getPortedComment(patchsetId2, commentUuid);
+
+ assertThat(portedComment).message().doesNotContain("Confidential content");
+ }
+
+ @Test
+ public void setOfPortedCommentsCanChangeOnRepeatedCalls() throws Exception {
+ // Set up change and patchsets.
+ Change.Id changeId = changeOps.newChange().create();
+ PatchSet.Id patchsetId1 = changeOps.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id patchsetId2 = changeOps.change(changeId).newPatchset().create();
+ // Add comment.
+ String commentUuid1 = newComment(patchsetId1).unresolved().create();
+
+ ImmutableList<CommentInfo> pastPortedComments = flatten(getPortedComments(patchsetId2));
+ // Set the existing comment thread to resolved, so it won't be ported anymore.
+ newComment(patchsetId1).parentUuid(commentUuid1).resolved().create();
+ // Create a new comment which should show up as ported comment.
+ String commentUuid2 = newComment(patchsetId1).create();
+ ImmutableList<CommentInfo> portedComments = flatten(getPortedComments(patchsetId2));
+
+ // Ensure that results are not cached between calls. This should not be necessary as the diffs
+ // are already cached. If we need to also cache the ported comments in the future, we'll need to
+ // identify ALL situations when the set of ported comments changes.
+ assertThat(portedComments).isNotEqualTo(pastPortedComments);
+ assertThat(portedComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid2);
+ }
+
+ 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);
+ return extractSpecificComment(portedComments, commentUuid);
+ }
+
+ private Map<String, List<CommentInfo>> getPortedComments(PatchSet.Id patchsetId)
+ throws RestApiException {
+ return gApi.changes()
+ .id(patchsetId.changeId().get())
+ .revision(patchsetId.get())
+ .portedComments();
+ }
+
+ private Map<String, List<CommentInfo>> getPortedDraftCommentsOfUser(
+ PatchSet.Id patchsetId, Account.Id accountId) throws RestApiException {
+ // Draft comments are only visible to their author.
+ requestScopeOps.setApiUser(accountId);
+ return gApi.changes().id(patchsetId.changeId().get()).revision(patchsetId.get()).portedDrafts();
+ }
+
+ private static CommentInfo extractSpecificComment(
+ Map<String, List<CommentInfo>> portedComments, String commentUuid) {
+ return portedComments.values().stream()
+ .flatMap(Collection::stream)
+ .filter(comment -> comment.id.equals(commentUuid))
+ .collect(onlyElement());
+ }
+
+ /**
+ * Returns all comments in one list. The map keys (= file paths) are simply ignored. The returned
+ * comments won't have the file path attribute set for them as they came from a map with that
+ * attribute as key (= established Gerrit behavior).
+ */
+ private static ImmutableList<CommentInfo> flatten(
+ Map<String, List<CommentInfo>> commentsPerFile) {
+ return commentsPerFile.values().stream()
+ .flatMap(Collection::stream)
+ .collect((toImmutableList()));
+ }
+
+ // Unfortunately, we don't get an absolutely helpful error message when using this correspondence
+ // as CommentInfo doesn't have a toString() implementation. Even if we added it, the string
+ // representation would be quite unwieldy due to the huge number of comment attributes.
+ // Interestingly, using Correspondence#formattingDiffsUsing didn't improve anything.
+ private static Correspondence<CommentInfo, String> hasUuid() {
+ return NullAwareCorrespondence.transforming(comment -> comment.id, "hasUuid");
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 5684b1f..4350072 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -36,16 +36,12 @@
import com.google.gerrit.common.RawInputUtil;
import com.google.gerrit.extensions.api.changes.FileApi;
import com.google.gerrit.extensions.api.changes.RebaseInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
-import com.google.gerrit.extensions.client.Comment;
import com.google.gerrit.extensions.client.DiffPreferencesInfo;
import com.google.gerrit.extensions.common.ChangeType;
import com.google.gerrit.extensions.common.DiffInfo;
import com.google.gerrit.extensions.common.FileInfo;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.testing.ConfigSuite;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
@@ -64,6 +60,7 @@
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.Before;
+import org.junit.Ignore;
import org.junit.Test;
public class RevisionDiffIT extends AbstractDaemonTest {
@@ -121,6 +118,12 @@
assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
}
+ @Ignore
+ @Test
+ public void diffWithRootCommit() throws Exception {
+ // TODO(ghareeb): Implement this test
+ }
+
@Test
public void patchsetLevelFileDiffIsEmpty() throws Exception {
PushOneCommit.Result result = createChange();
@@ -449,6 +452,57 @@
}
@Test
+ public void diffWithThreeParentsMergeCommitChange() throws Exception {
+ // Create a merge commit of 3 files: foo, bar, baz. The merge commit is pointing to 3 different
+ // parents: the merge commit contains foo of parent1, bar of parent2 and baz of parent3.
+ PushOneCommit.Result r =
+ createNParentsMergeCommitChange("refs/for/master", ImmutableList.of("foo", "bar", "baz"));
+
+ DiffInfo diff;
+
+ // parent 1
+ Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files(1);
+ assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "bar", "baz");
+ diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(1).get();
+ assertThat(diff.diffHeader).isNull();
+ diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(1).get();
+ assertThat(diff.diffHeader).hasSize(4);
+ diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(1).get();
+ assertThat(diff.diffHeader).hasSize(4);
+
+ // parent 2
+ changedFiles = gApi.changes().id(r.getChangeId()).current().files(2);
+ assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "baz");
+ diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(2).get();
+ assertThat(diff.diffHeader).hasSize(4);
+ diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(2).get();
+ assertThat(diff.diffHeader).isNull();
+ diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(2).get();
+ assertThat(diff.diffHeader).hasSize(4);
+
+ // parent 3
+ changedFiles = gApi.changes().id(r.getChangeId()).current().files(3);
+ assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");
+ diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "foo").withParent(3).get();
+ assertThat(diff.diffHeader).hasSize(4);
+ diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "bar").withParent(3).get();
+ assertThat(diff.diffHeader).hasSize(4);
+ diff = getDiffRequest(r.getChangeId(), r.getCommit().name(), "baz").withParent(3).get();
+ assertThat(diff.diffHeader).isNull();
+ }
+
+ @Test
+ public void diffWithThreeParentsMergeCommitAgainstAutoMergeIsNotSupported() throws Exception {
+ PushOneCommit.Result r =
+ createNParentsMergeCommitChange("refs/for/master", ImmutableList.of("foo", "bar", "baz"));
+
+ // Diff against auto-merge returns COMMIT_MSG and MERGE_LIST only
+ // todo(ghareeb): We could throw an exception in this case for better handling at the client.
+ Map<String, FileInfo> changedFiles = gApi.changes().id(r.getChangeId()).current().files();
+ assertThat(changedFiles.keySet()).containsExactly(COMMIT_MSG, MERGE_LIST);
+ }
+
+ @Test
public void diffBetweenPatchSetsOfMergeCommitCanBeRetrievedForCommitMessageAndMergeList()
throws Exception {
PushOneCommit.Result result = createMergeCommitChange("refs/for/master", "my_file.txt");
@@ -676,12 +730,6 @@
String baseFileContent = FILE_CONTENT.concat("Line 101");
ObjectId commit2 = addCommit(commit1, FILE_NAME, baseFileContent);
rebaseChangeOn(changeId, commit2);
- // Add a comment so that file contents are not 'skipped'. To be able to add a comment, touch
- // (= modify) the file in the change.
- addModifiedPatchSet(
- changeId, FILE_NAME, fileContent -> fileContent.replace("Line 2\n", "Line two\n"));
- CommentInput comment = createCommentInput(3, 0, 4, 0, "Comment to not skip file content.");
- addCommentTo(changeId, CURRENT, FILE_NAME, comment);
String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
String newBaseFileContent = baseFileContent.concat("\n");
ObjectId commit3 = addCommit(commit2, FILE_NAME, newBaseFileContent);
@@ -2517,17 +2565,14 @@
}
@Test
- public void diffOfUnmodifiedFileWithWholeFileContextReturnsFileContents() throws Exception {
+ public void diffOfUnmodifiedFileReturnsAllFileContents() throws Exception {
addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
addModifiedPatchSet(
changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
DiffInfo diffInfo =
- getDiffRequest(changeId, CURRENT, FILE_NAME)
- .withBase(previousPatchSetId)
- .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
- .get();
+ getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
// We don't list the full file contents here as that is not the focus of this test.
assertThat(diffInfo)
.content()
@@ -2538,40 +2583,16 @@
}
@Test
- public void diffOfUnmodifiedFileWithCommentAndWholeFileContextReturnsFileContents()
+ // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
+ // filter such files in the GetFiles REST endpoint.
+ @Ignore
+ public void diffOfFileWithOnlyRebaseHunksConsideringWhitespaceReturnsFileContents()
throws Exception {
addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
- CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
- addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
- addModifiedPatchSet(
- changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
-
- DiffInfo diffInfo =
- getDiffRequest(changeId, CURRENT, FILE_NAME)
- .withBase(previousPatchSetId)
- .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
- .get();
- // We don't list the full file contents here as that is not the focus of this test.
- assertThat(diffInfo)
- .content()
- .element(0)
- .commonLines()
- .containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
- .inOrder();
- }
-
- @Test
- public void
- diffOfFileWithOnlyRebaseHunksAndWithCommentAndConsideringWhitespaceReturnsFileContents()
- throws Exception {
- addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
- String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
String newBaseFileContent = FILE_CONTENT.replace("Line 70\n", "Line seventy\n");
ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
rebaseChangeOn(changeId, commit2);
- CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
- addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
DiffInfo diffInfo =
getDiffRequest(changeId, CURRENT, FILE_NAME)
@@ -2585,18 +2606,23 @@
.commonLines()
.containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
.inOrder();
+ // It's crucial that the line changed in the rebase is reported correctly.
+ assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 70");
+ assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line seventy");
+ assertThat(diffInfo).content().element(1).isDueToRebase();
}
@Test
- public void diffOfFileWithOnlyRebaseHunksAndWithCommentAndIgnoringWhitespaceReturnsFileContents()
+ // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
+ // filter such files in the GetFiles REST endpoint.
+ @Ignore
+ public void diffOfFileWithOnlyRebaseHunksAndIgnoringWhitespaceReturnsFileContents()
throws Exception {
addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
String newBaseFileContent = FILE_CONTENT.replace("Line 70\n", "Line seventy\n");
ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
rebaseChangeOn(changeId, commit2);
- CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
- addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
DiffInfo diffInfo =
getDiffRequest(changeId, CURRENT, FILE_NAME)
@@ -2610,12 +2636,18 @@
.commonLines()
.containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
.inOrder();
+ // It's crucial that the line changed in the rebase is reported correctly.
+ assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 70");
+ assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line seventy");
+ assertThat(diffInfo).content().element(1).isDueToRebase();
}
@Test
- public void
- diffOfFileWithMultilineRebaseHunkAddingNewlineAtEndOfFileAndWithCommentReturnsFileContents()
- throws Exception {
+ // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
+ // filter such files in the GetFiles REST endpoint.
+ @Ignore
+ public void diffOfFileWithMultilineRebaseHunkAddingNewlineAtEndOfFileReturnsFileContents()
+ throws Exception {
String baseFileContent = FILE_CONTENT.concat("Line 101");
ObjectId commit2 = addCommit(commit1, FILE_NAME, baseFileContent);
rebaseChangeOn(changeId, commit2);
@@ -2624,8 +2656,6 @@
String newBaseFileContent = baseFileContent.concat("\nLine 102\nLine 103\n");
ObjectId commit3 = addCommit(commit2, FILE_NAME, newBaseFileContent);
rebaseChangeOn(changeId, commit3);
- CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
- addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
DiffInfo diffInfo =
getDiffRequest(changeId, CURRENT, FILE_NAME)
@@ -2639,19 +2669,27 @@
.commonLines()
.containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
.inOrder();
+ // It's crucial that the lines changed in the rebase are reported correctly.
+ assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 101");
+ assertThat(diffInfo)
+ .content()
+ .element(1)
+ .linesOfB()
+ .containsExactly("Line 101", "Line 102", "Line 103", "");
+ assertThat(diffInfo).content().element(1).isDueToRebase();
}
@Test
- public void
- diffOfFileWithMultilineRebaseHunkRemovingNewlineAtEndOfFileAndWithCommentReturnsFileContents()
- throws Exception {
+ // TODO(ghareeb): Don't exclude diffs which only contain rebase hunks within the diff caches. Only
+ // filter such files in the GetFiles REST endpoint.
+ @Ignore
+ public void diffOfFileWithMultilineRebaseHunkRemovingNewlineAtEndOfFileReturnsFileContents()
+ throws Exception {
addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
- String newBaseFileContent = FILE_CONTENT.concat("Line 101\nLine 103\nLine 104");
+ String newBaseFileContent = FILE_CONTENT.concat("Line 101\nLine 102\nLine 103");
ObjectId commit2 = addCommit(commit1, FILE_NAME, newBaseFileContent);
rebaseChangeOn(changeId, commit2);
- CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
- addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
DiffInfo diffInfo =
getDiffRequest(changeId, CURRENT, FILE_NAME)
@@ -2665,6 +2703,14 @@
.commonLines()
.containsAtLeast("Line 1", "Line two", "Line 3", "Line 4", "Line 5")
.inOrder();
+ // It's crucial that the lines changed in the rebase are reported correctly.
+ assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 100", "");
+ assertThat(diffInfo)
+ .content()
+ .element(1)
+ .linesOfB()
+ .containsExactly("Line 100", "Line 101", "Line 102", "Line 103");
+ assertThat(diffInfo).content().element(1).isDueToRebase();
}
@Test
@@ -2674,49 +2720,10 @@
DiffInfo diffInfo =
getDiffRequest(changeId, CURRENT, "a_non-existent_file.txt")
.withBase(initialPatchSetId)
- .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
.get();
assertThat(diffInfo).content().isEmpty();
}
- // This behavior is likely a bug. A fix might not be easy as it might break syntax highlighting.
- // TODO: Fix this issue or remove the broken parameter (at least in the documentation).
- @Test
- public void contextParameterIsIgnored() throws Exception {
- addModifiedPatchSet(
- changeId, FILE_NAME, content -> content.replace("Line 20\n", "Line twenty\n"));
-
- DiffInfo diffInfo =
- getDiffRequest(changeId, CURRENT, FILE_NAME)
- .withBase(initialPatchSetId)
- .withContext(5)
- .get();
- assertThat(diffInfo).content().element(0).commonLines().hasSize(19);
- assertThat(diffInfo).content().element(1).linesOfA().containsExactly("Line 20");
- assertThat(diffInfo).content().element(1).linesOfB().containsExactly("Line twenty");
- assertThat(diffInfo).content().element(2).commonLines().hasSize(81);
- }
-
- // This behavior is likely a bug. A fix might not be easy as it might break syntax highlighting.
- // TODO: Fix this issue or remove the broken parameter (at least in the documentation).
- @Test
- public void contextParameterIsIgnoredForUnmodifiedFileWithComment() throws Exception {
- addModifiedPatchSet(
- changeId, FILE_NAME, content -> content.replace("Line 20\n", "Line twenty\n"));
- String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
- CommentInput comment = createCommentInput(20, 0, 21, 0, "Should be 'Line 20'.");
- addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
- addModifiedPatchSet(
- changeId, FILE_NAME2, content -> content.replace("2nd line\n", "Second line\n"));
-
- DiffInfo diffInfo =
- getDiffRequest(changeId, CURRENT, FILE_NAME)
- .withBase(previousPatchSetId)
- .withContext(5)
- .get();
- assertThat(diffInfo).content().element(0).commonLines().hasSize(101);
- }
-
@Test
public void requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult() throws Exception {
addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
@@ -2726,40 +2733,13 @@
gApi.changes().id(changeId).edit().publish();
DiffInfo diffInfo =
- getDiffRequest(changeId, CURRENT, FILE_NAME)
- .withBase(previousPatchSetId)
- .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
- .get();
+ getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
// This behavior has been present in Gerrit for quite some time. It differs from the results
- // returned for other cases (e.g. requesting the diff with whole file context for an unmodified
- // file; requesting the diff with whole file context for a non-existent file). However, it's not
- // completely clear what should be returned. The closest would be the result of a file deletion
- // but that might also be misleading for users as actually a file rename occurred. In fact,
- // requesting the diff result for the old file name of a renamed file is not a reasonable use
- // case at all. We at least guarantee that we don't run into an internal error.
- assertThat(diffInfo).content().element(0).commonLines().isNull();
- assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
- }
-
- @Test
- public void requestingDiffForOldFileNameOfRenamedFileWithCommentOnOldFileYieldsReasonableResult()
- throws Exception {
- addModifiedPatchSet(changeId, FILE_NAME, content -> content.replace("Line 2\n", "Line two\n"));
- String previousPatchSetId = gApi.changes().id(changeId).get().currentRevision;
- CommentInput comment = createCommentInput(2, 0, 3, 0, "Should be 'Line 2'.");
- addCommentTo(changeId, previousPatchSetId, FILE_NAME, comment);
- String newFilePath = "a_new_file.txt";
- gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath);
- gApi.changes().id(changeId).edit().publish();
-
- DiffInfo diffInfo =
- getDiffRequest(changeId, CURRENT, FILE_NAME)
- .withBase(previousPatchSetId)
- .withContext(DiffPreferencesInfo.WHOLE_FILE_CONTEXT)
- .get();
- // See comment for requestingDiffForOldFileNameOfRenamedFileYieldsReasonableResult().
- // This test should additionally ensure that we also don't run into an internal error when
- // a comment is present.
+ // returned for other cases (e.g. requesting the diff for an unmodified file; requesting the
+ // diff for a non-existent file). After a rename, the original file doesn't exist anymore.
+ // Hence, the most reasonable thing would be to match the behavior of requesting the diff for a
+ // non-existent file, which returns an empty diff.
+ // This test at least guarantees that we don't run into an internal error.
assertThat(diffInfo).content().element(0).commonLines().isNull();
assertThat(diffInfo).content().element(0).numberOfSkippedLines().isGreaterThan(0);
}
@@ -2781,26 +2761,6 @@
assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");
}
- private static CommentInput createCommentInput(
- int startLine, int startCharacter, int endLine, int endCharacter, String message) {
- CommentInput comment = new CommentInput();
- comment.range = new Comment.Range();
- comment.range.startLine = startLine;
- comment.range.startCharacter = startCharacter;
- comment.range.endLine = endLine;
- comment.range.endCharacter = endCharacter;
- comment.message = message;
- return comment;
- }
-
- private void addCommentTo(
- String changeId, String previousPatchSetId, String fileName, CommentInput comment)
- throws RestApiException {
- ReviewInput reviewInput = new ReviewInput();
- reviewInput.comments = ImmutableMap.of(fileName, ImmutableList.of(comment));
- gApi.changes().id(changeId).revision(previousPatchSetId).review(reviewInput);
- }
-
private void assertDiffForNewFile(
PushOneCommit.Result pushResult, String path, String expectedContentSideB) throws Exception {
DiffInfo diff =
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index d9d2f65..839b051 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -50,12 +50,12 @@
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.BranchOrderSection;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.ChangeApi;
import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -96,6 +96,7 @@
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.restapi.change.GetRevisionActions;
+import com.google.gerrit.testing.FakeEmailSender;
import com.google.inject.Inject;
import java.io.ByteArrayOutputStream;
import java.sql.Timestamp;
@@ -871,7 +872,7 @@
String changeId = project.get() + "~master~" + result.getChangeId();
// 'user' cherry-picks the change to a new branch, the source change's author/committer('admin')
- // will be added as a reviewer of the newly created change.
+ // will be added as cc of the newly created change.
requestScopeOperations.setApiUser(user.id());
CherryPickInput input = new CherryPickInput();
input.message = "it goes to a new branch";
@@ -881,7 +882,7 @@
input.notify = NotifyHandling.ALL;
sender.clear();
gApi.changes().id(changeId).current().cherryPick(input);
- assertNotifyTo(admin);
+ assertNotifyCc(admin);
// Disable the notification. 'admin' as a reviewer should not be notified any more.
input.destination = "branch-2";
@@ -1892,6 +1893,56 @@
assertThat(approvals).hasSize(2);
}
+ @Test
+ public void uploaderNotAddedAsReviewer() throws Exception {
+ PushOneCommit.Result result = createChange();
+ amendChangeWithUploader(result, project, user);
+ assertThat(result.getChange().reviewers().all()).isEmpty();
+ }
+
+ @Test
+ public void notificationsOnPushNewPatchset() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).addReviewer(user.email());
+ sender.clear();
+
+ // check that reviewer is notified.
+ amendChange(r.getChangeId());
+ List<FakeEmailSender.Message> messages = sender.getMessages();
+ FakeEmailSender.Message m = Iterables.getOnlyElement(messages);
+ assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+ assertThat(m.body()).contains("I'd like you to reexamine a change.");
+ }
+
+ @Test
+ @GerritConfig(name = "change.sendNewPatchsetEmails", value = "false")
+ public void notificationsOnPushNewPatchsetNotSentWithSendNewPatchsetEmailsAsFalse()
+ throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).addReviewer(user.email());
+ sender.clear();
+
+ // check that reviewer is not notified
+ amendChange(r.getChangeId());
+ assertThat(sender.getMessages()).isEmpty();
+ }
+
+ @Test
+ @GerritConfig(name = "change.sendNewPatchsetEmails", value = "false")
+ public void notificationsOnPushNewPatchsetAlwaysSentToProjectWatchers() throws Exception {
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+ watch(project.get());
+ sender.clear();
+
+ // check that watcher is notified
+ amendChange(r.getChangeId());
+ List<FakeEmailSender.Message> messages = sender.getMessages();
+ FakeEmailSender.Message m = Iterables.getOnlyElement(messages);
+ assertThat(m.rcpt()).containsExactly(user.getNameEmail());
+ assertThat(m.body()).contains(admin.fullName() + " has uploaded a new patch set (#2).");
+ }
+
private static void assertCherryPickResult(
ChangeInfo changeInfo, CherryPickInput input, String srcChangeId) throws Exception {
assertThat(changeInfo.changeId).isEqualTo(srcChangeId);
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 27b866b..b855485 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -14,9 +14,11 @@
package com.google.gerrit.acceptance.api.revision;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.collect.MoreCollectors.onlyElement;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
@@ -32,8 +34,11 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.UseClockStep;
import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
import com.google.gerrit.extensions.client.Comment;
import com.google.gerrit.extensions.client.Side;
@@ -67,6 +72,7 @@
public class RobotCommentsIT extends AbstractDaemonTest {
@Inject private TestCommentHelper testCommentHelper;
+ @Inject private ChangeOperations changeOperations;
private static final String PLAIN_TEXT_CONTENT_TYPE = "text/plain";
private static final String GERRIT_COMMIT_MESSAGE_TYPE = "text/x-gerrit-commit-message";
@@ -320,6 +326,58 @@
}
@Test
+ public void robotCommentInvalidInReplyTo() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ RobotCommentInput input = TestCommentHelper.createRobotCommentInput(PATCHSET_LEVEL);
+ input.inReplyTo = "invalid";
+ BadRequestException ex =
+ assertThrows(
+ BadRequestException.class, () -> testCommentHelper.addRobotComment(changeId, input));
+ assertThat(ex.getMessage()).contains("inReplyTo");
+ }
+
+ @Test
+ public void canCreateRobotCommentWithRobotCommentAsParent() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentRobotCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+ ReviewInput.RobotCommentInput robotCommentInput =
+ TestCommentHelper.createRobotCommentInputWithMandatoryFields(COMMIT_MSG);
+ robotCommentInput.message = "comment reply";
+ robotCommentInput.inReplyTo = parentRobotCommentUuid;
+ testCommentHelper.addRobotComment(changeId, robotCommentInput);
+
+ RobotCommentInfo resultComment =
+ Iterables.getOnlyElement(
+ gApi.changes().id(changeId.get()).current().robotCommentsAsList().stream()
+ .filter(c -> c.message.equals("comment reply"))
+ .collect(toImmutableSet()));
+ assertThat(resultComment.inReplyTo).isEqualTo(parentRobotCommentUuid);
+ }
+
+ @Test
+ public void canCreateRobotCommentWithHumanCommentAsParent() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String changeIdString = changeOperations.change(changeId).get().changeId();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().create();
+
+ ReviewInput.RobotCommentInput robotCommentInput =
+ TestCommentHelper.createRobotCommentInputWithMandatoryFields(COMMIT_MSG);
+ robotCommentInput.message = "comment reply";
+ robotCommentInput.inReplyTo = parentCommentUuid;
+ testCommentHelper.addRobotComment(changeIdString, robotCommentInput);
+
+ RobotCommentInfo resultComment =
+ Iterables.getOnlyElement(
+ gApi.changes().id(changeIdString).current().robotCommentsAsList().stream()
+ .filter(c -> c.message.equals("comment reply"))
+ .collect(toImmutableSet()));
+ assertThat(resultComment.inReplyTo).isEqualTo(parentCommentUuid);
+ }
+
+ @Test
public void hugeRobotCommentIsRejected() {
int defaultSizeLimit = 1 << 20;
fixReplacementInfo.replacement = getStringFor(defaultSizeLimit + 1);
@@ -996,10 +1054,7 @@
fixReplacementInfo.range = createRange(3, 1, 3, 3);
testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
- List<RobotCommentInfo> robotCommentInfos = getRobotComments();
-
- List<String> fixIds = getFixIds(robotCommentInfos);
- String fixId = Iterables.getOnlyElement(fixIds);
+ String fixId = Iterables.getOnlyElement(getFixIds(getRobotComments()));
gApi.changes().id(changeId).current().applyFix(fixId);
@@ -1008,6 +1063,187 @@
}
@Test
+ public void fixOnCommitMessageCanBeApplied() throws Exception {
+ // Set a dedicated commit message.
+ String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n";
+ gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+ gApi.changes().id(changeId).edit().publish();
+
+ withFixRobotCommentInput.path = Patch.COMMIT_MSG;
+ fixReplacementInfo.path = Patch.COMMIT_MSG;
+ fixReplacementInfo.replacement = "Modified line\n";
+ fixReplacementInfo.range = createRange(7, 0, 8, 0);
+
+ testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+ String fixId = Iterables.getOnlyElement(getFixIds(getRobotComments()));
+
+ gApi.changes().id(changeId).current().applyFix(fixId);
+
+ String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+ assertThat(commitMessage).isEqualTo("Modified line\nLine 2 of commit message\n");
+ }
+
+ @Test
+ public void fixOnHeaderPartOfCommitMessageCannotBeApplied() throws Exception {
+ // Set a dedicated commit message.
+ String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n";
+ gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+ gApi.changes().id(changeId).edit().publish();
+
+ withFixRobotCommentInput.path = Patch.COMMIT_MSG;
+ fixReplacementInfo.path = Patch.COMMIT_MSG;
+ fixReplacementInfo.replacement = "Modified line\n";
+ fixReplacementInfo.range = createRange(1, 0, 2, 0);
+
+ testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+ String fixId = Iterables.getOnlyElement(getFixIds(getRobotComments()));
+
+ ResourceConflictException exception =
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeId).current().applyFix(fixId));
+ assertThat(exception).hasMessageThat().contains("header");
+ }
+
+ @Test
+ public void fixContainingSeveralModificationsOfCommitMessageCanBeApplied() throws Exception {
+ // Set a dedicated commit message.
+ String originalCommitMessage =
+ "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n";
+ gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+ gApi.changes().id(changeId).edit().publish();
+
+ FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+ fixReplacementInfo1.path = Patch.COMMIT_MSG;
+ fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+ fixReplacementInfo1.replacement = "Modified line 1\n";
+
+ FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+ fixReplacementInfo2.path = Patch.COMMIT_MSG;
+ fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+ fixReplacementInfo2.replacement = "Modified line 3\n";
+
+ FixSuggestionInfo fixSuggestionInfo =
+ createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+ withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+ withFixRobotCommentInput.path = Patch.COMMIT_MSG;
+
+ testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+ String fixId = Iterables.getOnlyElement(getFixIds(getRobotComments()));
+
+ gApi.changes().id(changeId).current().applyFix(fixId);
+
+ String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+ assertThat(commitMessage)
+ .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n");
+ }
+
+ @Test
+ public void fixModifyingTheCommitMessageAndAFileCanBeApplied() throws Exception {
+ // Set a dedicated commit message.
+ String originalCommitMessage = "Line 1 of commit message\nLine 2 of commit message\n";
+ gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+ gApi.changes().id(changeId).edit().publish();
+
+ FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+ fixReplacementInfo1.path = Patch.COMMIT_MSG;
+ fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+ fixReplacementInfo1.replacement = "Modified line 1\n";
+
+ FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+ fixReplacementInfo2.path = FILE_NAME2;
+ fixReplacementInfo2.range = createRange(1, 0, 2, 0);
+ fixReplacementInfo2.replacement = "File modification\n";
+
+ FixSuggestionInfo fixSuggestionInfo =
+ createFixSuggestionInfo(fixReplacementInfo1, fixReplacementInfo2);
+ withFixRobotCommentInput.fixSuggestions = ImmutableList.of(fixSuggestionInfo);
+
+ testCommentHelper.addRobotComment(changeId, withFixRobotCommentInput);
+ String fixId = Iterables.getOnlyElement(getFixIds(getRobotComments()));
+
+ gApi.changes().id(changeId).current().applyFix(fixId);
+
+ String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+ assertThat(commitMessage).isEqualTo("Modified line 1\nLine 2 of commit message\n");
+ Optional<BinaryResult> file = gApi.changes().id(changeId).edit().getFile(FILE_NAME2);
+ BinaryResultSubject.assertThat(file)
+ .value()
+ .asString()
+ .isEqualTo("File modification\n2nd line\n3rd line\n");
+ }
+
+ @Test
+ public void twoFixesOnCommitMessageCanBeAppliedOneAfterTheOther() throws Exception {
+ // Set a dedicated commit message.
+ String originalCommitMessage =
+ "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n";
+ gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+ gApi.changes().id(changeId).edit().publish();
+
+ FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+ fixReplacementInfo1.path = Patch.COMMIT_MSG;
+ fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+ fixReplacementInfo1.replacement = "Modified line 1\n";
+ FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+ FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+ fixReplacementInfo2.path = Patch.COMMIT_MSG;
+ fixReplacementInfo2.range = createRange(9, 0, 10, 0);
+ fixReplacementInfo2.replacement = "Modified line 3\n";
+ FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+ RobotCommentInput robotCommentInput1 =
+ TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo1);
+ RobotCommentInput robotCommentInput2 =
+ TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo2);
+ testCommentHelper.addRobotComment(changeId, robotCommentInput1);
+ testCommentHelper.addRobotComment(changeId, robotCommentInput2);
+ List<String> fixIds = getFixIds(getRobotComments());
+
+ gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+ gApi.changes().id(changeId).current().applyFix(fixIds.get(1));
+
+ String commitMessage = gApi.changes().id(changeId).edit().getCommitMessage();
+ assertThat(commitMessage)
+ .isEqualTo("Modified line 1\nLine 2 of commit message\nModified line 3\n");
+ }
+
+ @Test
+ public void twoConflictingFixesOnCommitMessageCanNotBeAppliedOneAfterTheOther() throws Exception {
+ // Set a dedicated commit message.
+ String originalCommitMessage =
+ "Line 1 of commit message\nLine 2 of commit message\nLine 3 of commit message\n";
+ gApi.changes().id(changeId).edit().modifyCommitMessage(originalCommitMessage);
+ gApi.changes().id(changeId).edit().publish();
+
+ FixReplacementInfo fixReplacementInfo1 = new FixReplacementInfo();
+ fixReplacementInfo1.path = Patch.COMMIT_MSG;
+ fixReplacementInfo1.range = createRange(7, 0, 8, 0);
+ fixReplacementInfo1.replacement = "Modified line 1\n";
+ FixSuggestionInfo fixSuggestionInfo1 = createFixSuggestionInfo(fixReplacementInfo1);
+
+ FixReplacementInfo fixReplacementInfo2 = new FixReplacementInfo();
+ fixReplacementInfo2.path = Patch.COMMIT_MSG;
+ fixReplacementInfo2.range = createRange(7, 0, 10, 0);
+ fixReplacementInfo2.replacement = "Differently modified line 1\n";
+ FixSuggestionInfo fixSuggestionInfo2 = createFixSuggestionInfo(fixReplacementInfo2);
+
+ RobotCommentInput robotCommentInput1 =
+ TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo1);
+ RobotCommentInput robotCommentInput2 =
+ TestCommentHelper.createRobotCommentInput(FILE_NAME, fixSuggestionInfo2);
+ testCommentHelper.addRobotComment(changeId, robotCommentInput1);
+ testCommentHelper.addRobotComment(changeId, robotCommentInput2);
+ List<String> fixIds = getFixIds(getRobotComments());
+
+ gApi.changes().id(changeId).current().applyFix(fixIds.get(0));
+ assertThrows(
+ ResourceConflictException.class,
+ () -> gApi.changes().id(changeId).current().applyFix(fixIds.get(1)));
+ }
+
+ @Test
public void applyingFixTwiceIsIdempotent() throws Exception {
fixReplacementInfo.path = FILE_NAME;
fixReplacementInfo.replacement = "Modified content";
diff --git a/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java b/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java
index 0956de4..ac10e96 100644
--- a/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java
@@ -19,10 +19,19 @@
import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
import com.google.gerrit.acceptance.TestPlugin;
import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.server.config.GerritInstanceId;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.events.EventListener;
+import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.inject.AbstractModule;
import com.google.inject.Inject;
+import com.google.inject.Key;
import com.google.inject.Scopes;
+import java.util.ArrayList;
+import java.util.List;
import org.junit.Test;
@TestPlugin(
@@ -35,6 +44,8 @@
@Override
protected void configure() {
bind(InstanceIdLoader.class).in(Scopes.SINGLETON);
+ bind(TestEventListener.class).in(Scopes.SINGLETON);
+ DynamicSet.bind(binder(), EventListener.class).to(TestEventListener.class);
}
}
@@ -47,6 +58,27 @@
}
}
+ public static class TestEventListener implements EventListener {
+ private final List<Event> events = new ArrayList<>();
+
+ @Override
+ public void onEvent(Event event) {
+ events.add(event);
+ }
+
+ public List<Event> getEvents() {
+ return events;
+ }
+ }
+
+ public static class TestEvent extends Event {
+
+ protected TestEvent(String instanceId) {
+ super("test");
+ this.instanceId = instanceId;
+ }
+ }
+
@Test
@GerritConfig(name = "gerrit.instanceId", value = "testInstanceId")
public void shouldReturnInstanceIdWhenDefined() {
@@ -58,6 +90,21 @@
assertThat(getInstanceIdLoader().gerritInstanceId).isNull();
}
+ @Test
+ @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId")
+ public void shouldPreserveEventInstanceIdWhenDefined() throws PermissionBackendException {
+ EventDispatcher dispatcher =
+ plugin.getSysInjector().getInstance(new Key<DynamicItem<EventDispatcher>>() {}).get();
+ String eventInstanceId = "eventInstanceId";
+ TestEventListener eventListener = plugin.getSysInjector().getInstance(TestEventListener.class);
+ TestEvent testEvent = new TestEvent(eventInstanceId);
+
+ dispatcher.postEvent(testEvent);
+ List<Event> receivedEvents = eventListener.getEvents();
+ assertThat(receivedEvents).hasSize(1);
+ assertThat(receivedEvents.get(0).instanceId).isEqualTo(eventInstanceId);
+ }
+
private InstanceIdLoader getInstanceIdLoader() {
return plugin.getSysInjector().getInstance(InstanceIdLoader.class);
}
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index d361247..5dbbe96 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -40,10 +40,10 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.AddReviewerInput;
import com.google.gerrit.extensions.api.changes.FileContentInput;
@@ -1016,11 +1016,7 @@
}
private String urlDiff(String changeId, String fileName) {
- return "/changes/"
- + changeId
- + "/revisions/0/files/"
- + fileName
- + "/diff?context=ALL&intraline";
+ return "/changes/" + changeId + "/revisions/0/files/" + fileName + "/diff?intraline";
}
private String urlDiff(String changeId, String revisionId, String fileName) {
@@ -1030,7 +1026,7 @@
+ revisionId
+ "/files/"
+ fileName
- + "/diff?context=ALL&intraline";
+ + "/diff?intraline";
}
private <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
index 3b80312..88d0937 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
@@ -24,7 +24,7 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.inject.Inject;
import org.eclipse.jgit.lib.ObjectId;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index 4c3c9d3..763e7b1 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -63,14 +63,14 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -87,12 +87,14 @@
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeMessageInfo;
import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.extensions.common.EditInfo;
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.TopicEditedListener;
import com.google.gerrit.git.ObjectIds;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -487,26 +489,34 @@
@Test
public void pushForMasterWithTopic() throws Exception {
- String topic = "my/topic";
- // specify topic as option
- PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
- r.assertOkStatus();
- r.assertChange(Change.Status.NEW, topic);
+ TopicValidator topicValidator = new TopicValidator();
+ try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
+ String topic = "my/topic";
+ // specify topic as option
+ PushOneCommit.Result r = pushTo("refs/for/master%topic=" + topic);
+ r.assertOkStatus();
+ r.assertChange(Change.Status.NEW, topic);
+ assertThat(topicValidator.count()).isEqualTo(1);
+ }
}
@Test
public void pushForMasterWithTopicOption() throws Exception {
- String topicOption = "topic=myTopic";
- List<String> pushOptions = new ArrayList<>();
- pushOptions.add(topicOption);
+ TopicValidator topicValidator = new TopicValidator();
+ try (Registration registration = extensionRegistry.newRegistration().add(topicValidator)) {
+ String topicOption = "topic=myTopic";
+ List<String> pushOptions = new ArrayList<>();
+ pushOptions.add(topicOption);
- PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
- push.setPushOptions(pushOptions);
- PushOneCommit.Result r = push.to("refs/for/master");
+ PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+ push.setPushOptions(pushOptions);
+ PushOneCommit.Result r = push.to("refs/for/master");
- r.assertOkStatus();
- r.assertChange(Change.Status.NEW, "myTopic");
- r.assertPushOptions(pushOptions);
+ r.assertOkStatus();
+ r.assertChange(Change.Status.NEW, "myTopic");
+ r.assertPushOptions(pushOptions);
+ assertThat(topicValidator.count()).isEqualTo(1);
+ }
}
@Test
@@ -1114,7 +1124,7 @@
String changeId = GitUtil.getChangeId(testRepo, c).get();
assertThat(getOwnerEmail(changeId)).isEqualTo(admin.email());
- assertThat(getReviewerEmails(changeId, ReviewerState.REVIEWER))
+ assertThat(getReviewerEmails(changeId, ReviewerState.CC))
.containsExactly(user.email(), user2.email());
assertThat(sender.getMessages()).hasSize(1);
@@ -1139,7 +1149,7 @@
pushHead(testRepo, "refs/for/master");
assertThat(getOwnerEmail(r.getChangeId())).isEqualTo(admin.email());
- assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.REVIEWER))
+ assertThat(getReviewerEmails(r.getChangeId(), ReviewerState.CC))
.containsExactly(user.email(), user2.email());
assertThat(sender.getMessages()).hasSize(1);
@@ -1171,27 +1181,20 @@
// Push this commit as "Administrator" (requires Forge Committer Identity)
pushHead(testRepo, "refs/for/master%l=Code-Review+1", false);
- // Expected Code-Review votes:
- // 1. 0 from User (committer):
- // When the committer is forged, the committer is automatically added as
- // reviewer, hence we expect a dummy 0 vote for the committer.
- // 2. +1 from Administrator (uploader):
- // On push Code-Review+1 was specified, hence we expect a +1 vote from
- // the uploader.
+ // Expected Code-Review vote:
+ // +1 from Administrator (uploader):
+ // On push Code-Review+1 was specified, hence we expect a +1 vote from the uploader. When the
+ // committer is forged, the committer is automatically added as cc, but that doesn't add votes
+ // (as opposted to being added as reviewer that adds a dummy +0 vote). We ensure there are no
+ // votes from the committer.
ChangeInfo ci =
get(GitUtil.getChangeId(testRepo, c).get(), DETAILED_LABELS, MESSAGES, DETAILED_ACCOUNTS);
LabelInfo cr = ci.labels.get("Code-Review");
- assertThat(cr.all).hasSize(2);
- int indexAdmin = admin.fullName().equals(cr.all.get(0).name) ? 0 : 1;
- int indexUser = indexAdmin == 0 ? 1 : 0;
- assertThat(cr.all.get(indexAdmin).name).isEqualTo(admin.fullName());
- assertThat(cr.all.get(indexAdmin).value.intValue()).isEqualTo(1);
- assertThat(cr.all.get(indexUser).name).isEqualTo(user.fullName());
- assertThat(cr.all.get(indexUser).value.intValue()).isEqualTo(0);
+ ApprovalInfo approvalInfo = Iterables.getOnlyElement(cr.all);
+ assertThat(approvalInfo.name).isEqualTo(admin.fullName());
+ assertThat(approvalInfo.value.intValue()).isEqualTo(1);
assertThat(Iterables.getLast(ci.messages).message)
.isEqualTo("Uploaded patch set 1: Code-Review+1.");
- // Check that the user who pushed the change was added as a reviewer since they added a vote
- assertThatUserIsOnlyReviewer(ci, admin);
}
@Test
@@ -1557,6 +1560,27 @@
}
@Test
+ public void pushWithLinkFooter() throws Exception {
+ String changeId = "I0123456789abcdef0123456789abcdef01234567";
+ String url = cfg.getString("gerrit", null, "canonicalWebUrl");
+ if (!url.endsWith("/")) {
+ url += "/";
+ }
+ createCommit(testRepo, "test commit\n\nLink: " + url + "id/" + changeId);
+ pushForReviewOk(testRepo);
+
+ List<ChangeMessageInfo> messages = getMessages(changeId);
+ assertThat(messages.get(0).message).isEqualTo("Uploaded patch set 1.");
+ }
+
+ @Test
+ public void pushWithWrongHostLinkFooter() throws Exception {
+ String changeId = "I0123456789abcdef0123456789abcdef01234567";
+ createCommit(testRepo, "test commit\n\nLink: https://wronghost/id/" + changeId);
+ pushForReviewRejected(testRepo, "missing Change-Id in message footer");
+ }
+
+ @Test
public void pushWithChangeIdAboveFooterWithCreateNewChangeForAllNotInTarget() throws Exception {
enableCreateNewChangeForAllNotInTarget();
testPushWithChangeIdAboveFooter();
@@ -2351,6 +2375,19 @@
}
}
+ private static class TopicValidator implements TopicEditedListener {
+ private final AtomicInteger count = new AtomicInteger();
+
+ @Override
+ public void onTopicEdited(Event event) {
+ count.incrementAndGet();
+ }
+
+ public int count() {
+ return count.get();
+ }
+ }
+
@Test
public void skipValidation() throws Exception {
String master = "refs/heads/master";
@@ -2710,7 +2747,7 @@
}
private Collection<CommentInfo> getPublishedComments(String changeId) throws Exception {
- return gApi.changes().id(changeId).comments().values().stream()
+ return gApi.changes().id(changeId).commentsRequest().get().values().stream()
.flatMap(Collection::stream)
.collect(toList());
}
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index f2accd4..23bcdec 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -26,12 +26,12 @@
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.EmailHeader;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
index df21625..415aa79 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmoduleSubscription.java
@@ -22,9 +22,9 @@
import com.google.common.collect.Iterables;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.SubscribeSection;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubscribeSection;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.server.git.meta.MetaDataUpdate;
import com.google.gerrit.server.project.ProjectConfig;
diff --git a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
index 86fce9c..27962da 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushAccountIT.java
@@ -33,9 +33,9 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.common.AccountInfo;
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index b1c07ad..78be4ab 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -17,6 +17,7 @@
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
import static com.google.gerrit.git.testing.PushResultSubject.assertThat;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static java.util.stream.Collectors.toList;
@@ -26,11 +27,11 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.client.ProjectState;
import com.google.gerrit.extensions.common.ChangeInput;
@@ -81,14 +82,17 @@
.update();
}
+ @SuppressWarnings("TruthIncompatibleType")
@Test
public void mixingMagicAndRegularPush() throws Exception {
testRepo.branch("HEAD").commit().create();
PushResult r = push("HEAD:refs/heads/master", "HEAD:refs/for/master");
String msg = "cannot combine normal pushes and magic pushes";
- assertThat(r.getRemoteUpdate("refs/heads/master")).isNotEqualTo(Status.OK);
- assertThat(r.getRemoteUpdate("refs/for/master")).isNotEqualTo(Status.OK);
+ assertThat(r.getRemoteUpdate("refs/heads/master"))
+ .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
+ assertThat(r.getRemoteUpdate("refs/for/master"))
+ .isNotEqualTo(/* expected: RemoteRefUpdate, actual: Status */ Status.OK);
assertThat(r.getRemoteUpdate("refs/for/master").getMessage()).isEqualTo(msg);
}
@@ -146,6 +150,22 @@
}
@Test
+ public void createDeniedIfUserCantRead() throws Exception {
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+ .add(allow(Permission.PUSH).ref("refs/*").group(REGISTERED_USERS))
+ .update();
+ testRepo.branch("HEAD").commit().create();
+ PushResult r = push("HEAD:refs/for/master");
+ assertThat(r)
+ .onlyRef("refs/for/master")
+ .isRejected("prohibited by Gerrit: not permitted: read on refs/heads/master");
+ assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
+ }
+
+ @Test
public void groupRefsByMessage() throws Exception {
try (Repository repo = repoManager.openRepository(project);
TestRepository<Repository> tr = new TestRepository<>(repo)) {
@@ -344,7 +364,7 @@
.update();
String project2 = name("project2");
- gApi.projects().create(project2);
+ projectOperations.newProject().name(project2).create();
ObjectId oldId = forceFetch("refs/meta/config");
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index 8b9d173..c6e610f 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -35,19 +35,20 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.DraftInput;
import com.google.gerrit.extensions.api.groups.GroupInput;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
@@ -118,7 +119,7 @@
@Before
public void setUp() throws Exception {
admins = adminGroupUuid();
- nonInteractiveUsers = groupUuid("Non-Interactive Users");
+ nonInteractiveUsers = groupUuid(ServiceUserClassifier.SERVICE_USERS);
setUpPermissions();
setUpChanges();
}
diff --git a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
index d7952e4..9c5afd2 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefOperationValidationIT.java
@@ -31,7 +31,7 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.server.events.RefReceivedEvent;
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
index 0efc4f9..1c8ca93 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsIT.java
@@ -24,6 +24,12 @@
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.project.SubmitRuleOptions;
+import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.testing.ConfigSuite;
import com.google.inject.Inject;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
@@ -47,6 +53,7 @@
}
@Inject private ProjectOperations projectOperations;
+ @Inject private SubmitRuleEvaluator.Factory evaluatorFactory;
@Test
@GerritConfig(name = "submodule.enableSuperProjectSubscriptions", value = "false")
@@ -631,6 +638,124 @@
expectToHaveSubmoduleState(superRepo, "master", subKey, badId);
}
+ @Test
+ public void blockSubmissionForChangesModifyingSpecifiedSubmodule() throws Exception {
+ ObjectId commitId = getCommitWithSubmoduleUpdate();
+
+ CherryPickInput cherryPickInput = new CherryPickInput();
+ cherryPickInput.destination = "branch";
+ cherryPickInput.allowConflicts = true;
+
+ // The rule will fail if the next change has a submodule file modification with subKey.
+ modifySubmitRulesToBlockSubmoduleChanges(String.format("file('%s','M','SUBMODULE')", subKey));
+
+ // Cherry-pick the newly created commit which contains a submodule update, to branch "branch".
+ ChangeApi changeApi =
+ gApi.projects().name(superKey.get()).commit(commitId.getName()).cherryPick(cherryPickInput);
+
+ // Add another file to this change for good measure.
+ PushOneCommit.Result result =
+ amendChange(changeApi.get().changeId, "subject", "newFile", "content");
+
+ assertThat(getStatus(result.getChange())).isEqualTo("NOT_READY");
+ assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isFalse();
+ }
+
+ @Test
+ public void blockSubmissionWithSubmodules() throws Exception {
+ ObjectId commitId = getCommitWithSubmoduleUpdate();
+ CherryPickInput cherryPickInput = new CherryPickInput();
+ cherryPickInput.destination = "branch";
+ cherryPickInput.allowConflicts = true;
+
+ // The rule will fail if the next change has any submodule file.
+ modifySubmitRulesToBlockSubmoduleChanges("file(_,_,'SUBMODULE')");
+
+ // Cherry-pick the newly created commit which contains a submodule update, to branch "branch".
+ ChangeApi changeApi =
+ gApi.projects().name(superKey.get()).commit(commitId.getName()).cherryPick(cherryPickInput);
+
+ // Add another file to this change for good measure.
+ PushOneCommit.Result result =
+ amendChange(changeApi.get().changeId, "subject", "newFile", "content");
+
+ assertThat(getStatus(result.getChange())).isEqualTo("NOT_READY");
+ assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isFalse();
+ }
+
+ @Test
+ public void doNotBlockSubmissionWithoutSubmodules() throws Exception {
+ modifySubmitRulesToBlockSubmoduleChanges("file(_,_,'SUBMODULE')");
+
+ PushOneCommit.Result result =
+ createChange(superRepo, "refs/heads/master", "subject", "newFile", "content", null);
+
+ assertThat(getStatus(result.getChange())).isEqualTo("OK");
+ assertThat(gApi.changes().id(result.getChangeId()).get().submittable).isTrue();
+ }
+
+ private ObjectId getCommitWithSubmoduleUpdate() throws Exception {
+ allowMatchingSubmoduleSubscription(subKey, "refs/heads/*", superKey, "refs/heads/*");
+ // Create branch "branch" for the parent and the submodule
+ pushChangeTo(superRepo, "branch");
+ pushChangeTo(subRepo, "branch");
+
+ // Make the superRepo a parent repo of the subRepo, for both branches.
+ createSubmoduleSubscription(superRepo, "master", subKey, "master");
+ createSubmoduleSubscription(superRepo, "branch", subKey, "branch");
+ pushChangeTo(subRepo, "master");
+ pushChangeTo(subRepo, "branch");
+
+ // This push creates a new commit in subRepo, master branch, which makes superRepo update their
+ // submodule.
+ pushChangeTo(subRepo, "master");
+
+ // Fetch the commit from superRepo that Gerrit created automatically to fulfill the submodule
+ // subscription.
+ return superRepo
+ .git()
+ .fetch()
+ .setRemote("origin")
+ .call()
+ .getAdvertisedRef("refs/heads/" + "master")
+ .getObjectId();
+ }
+
+ private void modifySubmitRulesToBlockSubmoduleChanges(String filePrologQuery) throws Exception {
+ String newContent =
+ String.format(
+ "submit_rule(submit(R)) :-\n"
+ + " gerrit:includes_file(%s),\n"
+ + " !,\n"
+ + " R = label('All-Submodules-Resolved', need(_)).\n"
+ + "submit_rule(submit(label('All-Submodules-Resolved', ok(A)))) :-\n"
+ + " gerrit:commit_author(A).",
+ filePrologQuery);
+
+ try (Repository repo = repoManager.openRepository(superKey);
+ TestRepository<Repository> testRepo = new TestRepository<>(repo)) {
+ testRepo
+ .branch(RefNames.REFS_CONFIG)
+ .commit()
+ .author(admin.newIdent())
+ .committer(admin.newIdent())
+ .add("rules.pl", newContent)
+ .message("Modify rules.pl")
+ .create();
+ }
+ projectCache.evict(superKey);
+ }
+
+ private String getStatus(ChangeData cd) throws Exception {
+
+ try (AutoCloseable changeIndex = disableChangeIndex()) {
+ try (AutoCloseable accountIndex = disableAccountIndex()) {
+ SubmitRuleEvaluator ruleEvaluator = evaluatorFactory.create(SubmitRuleOptions.defaults());
+ return ruleEvaluator.evaluate(cd).iterator().next().status.toString();
+ }
+ }
+ }
+
private ObjectId directUpdateRef(Project.NameKey project, String ref) throws Exception {
try (Repository serverRepo = repoManager.openRepository(project);
TestRepository<Repository> tr = new TestRepository<>(serverRepo)) {
diff --git a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
index 0715b7e..8367f60 100644
--- a/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/SubmoduleSubscriptionsWholeTopicMergeIT.java
@@ -24,8 +24,8 @@
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.client.ChangeStatus;
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index 57b93f6..64bd25c 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -17,6 +17,7 @@
"//java/com/google/gerrit/index",
"//java/com/google/gerrit/index/project",
"//java/com/google/gerrit/server/schema",
+ "//lib/errorprone:annotations",
],
)
diff --git a/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
new file mode 100644
index 0000000..093711f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/Schema_184IT.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2009 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.acceptance.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.schema.NoteDbSchemaVersion;
+import com.google.gerrit.server.schema.Schema_184;
+import com.google.gerrit.testing.TestUpdateUI;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class Schema_184IT extends AbstractDaemonTest {
+ private static final AccountGroup.NameKey SERVICE_USERS =
+ AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS);
+ private static final AccountGroup.NameKey NON_INTERACTIVE_USERS =
+ AccountGroup.nameKey("Non-Interactive Users");
+
+ @Inject private GroupOperations groupOperations;
+ @Inject private NoteDbSchemaVersion.Arguments args;
+
+ @Test
+ public void groupGetsRenamed() throws Exception {
+ groupOperations
+ .group(groupCache.get(SERVICE_USERS).get().getGroupUUID())
+ .forUpdate()
+ .name(NON_INTERACTIVE_USERS.get())
+ .update();
+ assertThat(hasGroup(NON_INTERACTIVE_USERS)).isTrue();
+
+ Schema_184 upgrade = new Schema_184();
+ upgrade.upgrade(args, new TestUpdateUI());
+ assertThat(hasGroup(SERVICE_USERS)).isTrue();
+ assertThat(hasGroup(NON_INTERACTIVE_USERS)).isFalse();
+ }
+
+ @Test
+ public void upgradeIsIdempotent() throws Exception {
+ groupOperations
+ .group(groupCache.get(SERVICE_USERS).get().getGroupUUID())
+ .forUpdate()
+ .name(NON_INTERACTIVE_USERS.get())
+ .update();
+ Schema_184 upgrade = new Schema_184();
+ upgrade.upgrade(args, new TestUpdateUI());
+ upgrade.upgrade(args, new TestUpdateUI());
+ assertThat(hasGroup(SERVICE_USERS)).isTrue();
+ assertThat(hasGroup(NON_INTERACTIVE_USERS)).isFalse();
+ }
+
+ private boolean hasGroup(AccountGroup.NameKey key) {
+ // We have to evict here because the schema migration doesn't have the cache available.
+ // That's OK because that also means it won't cache an old state in production.
+ groupCache.evict(key);
+ return groupCache.get(key).isPresent();
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java b/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java
index af947f8..0780832 100644
--- a/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/PluginsCapabilityIT.java
@@ -21,7 +21,7 @@
import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
import com.google.gerrit.acceptance.TestPlugin;
import com.google.gerrit.acceptance.rest.CreateTestPlugin.Input;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.extensions.api.access.AccessSectionInfo;
import com.google.gerrit.extensions.api.access.PermissionInfo;
import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 09680fb..cb34bdb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -31,7 +31,7 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.config.GerritConfig;
-import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.events.ChangeIndexedListener;
import com.google.gerrit.httpd.restapi.ParameterParser;
@@ -54,9 +54,9 @@
import com.google.inject.Inject;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
import org.apache.http.message.BasicHeader;
import org.junit.Rule;
import org.junit.Test;
@@ -337,7 +337,7 @@
assertThat(LoggingContext.getInstance().getTags().isEmpty()).isTrue();
assertForceLogging(false);
try (TraceContext traceContext = TraceContext.open().forceLogging().addTag("foo", "bar")) {
- SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+ Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
assertThat(tagMap.keySet()).containsExactly("foo");
assertThat(tagMap.get("foo")).containsExactly("bar");
assertForceLogging(true);
@@ -348,7 +348,7 @@
() -> {
// Verify that the tags and force logging flag have been propagated to the new
// thread.
- SortedMap<String, SortedSet<Object>> threadTagMap =
+ Map<String, ? extends Set<Object>> threadTagMap =
LoggingContext.getInstance().getTags().asMap();
expect.that(threadTagMap.keySet()).containsExactly("foo");
expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index c0feda9..0b0f2ec 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -14,6 +14,7 @@
package com.google.gerrit.acceptance.rest.account;
+import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertThat;
import com.google.common.collect.Iterables;
@@ -35,6 +36,12 @@
assertThat(accountInfo.displayName).isEqualTo(testAccount.displayName());
assertThat(accountInfo.email).isEqualTo(testAccount.email());
assertThat(accountInfo.inactive).isNull();
+ if (testAccount.tags().isEmpty()) {
+ assertThat(accountInfo.tags).isNull();
+ } else {
+ assertThat(accountInfo.tags.stream().map(Enum::name).collect(toImmutableList()))
+ .containsExactlyElementsIn(testAccount.tags());
+ }
}
/**
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index 53e871f..ac82a78 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -41,8 +41,8 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.DuplicateKeyException;
import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index 9202f42..b999abd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -19,11 +19,20 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.extensions.common.AccountDetailInfo;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.inject.Inject;
import org.junit.Test;
public class GetAccountDetailIT extends AbstractDaemonTest {
+ @Inject private GroupOperations groupOperations;
+ @Inject private AccountOperations accountOperations;
+
@Test
public void getDetail() throws Exception {
RestResponse r = adminRestSession.get("/accounts/" + admin.username() + "/detail/");
@@ -32,4 +41,21 @@
Account account = getAccount(admin.id());
assertThat(info.registeredOn).isEqualTo(account.registeredOn());
}
+
+ @Test
+ public void getDetailForServiceUser() throws Exception {
+ Account.Id serviceUser = accountOperations.newAccount().create();
+ groupOperations
+ .group(
+ groupCache
+ .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
+ .get()
+ .getGroupUUID())
+ .forUpdate()
+ .addMember(serviceUser)
+ .update();
+ RestResponse r = adminRestSession.get("/accounts/" + serviceUser.get() + "/detail/");
+ AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
+ assertThat(info.tags).containsExactly(AccountInfo.Tag.SERVICE_USER);
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
index 782638a..1e61d0a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountIT.java
@@ -24,6 +24,7 @@
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.inject.Inject;
import org.junit.Test;
@@ -67,6 +68,15 @@
assertThat(accountInfo.inactive).isTrue();
}
+ @Test
+ public void getServiceUserAccount() throws Exception {
+ TestAccount serviceUser =
+ accountCreator.create(
+ "robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+ assertThat(serviceUser.tags()).containsExactly("SERVICE_USER");
+ testGetAccount(serviceUser.id().toString(), serviceUser);
+ }
+
private void testGetAccount(String id, TestAccount expectedAccount) throws Exception {
assertAccountInfo(expectedAccount, gApi.accounts().id(id).get());
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index 00c7fb8..5c596dc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -37,13 +37,13 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RobotComment;
import com.google.gerrit.extensions.api.changes.DraftInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -506,7 +506,8 @@
assertThat(m.author._accountId).isEqualTo(user.id().get());
CommentInfo c =
- Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).comments().get(di.path));
+ Iterables.getOnlyElement(
+ gApi.changes().id(r.getChangeId()).commentsRequest().get().get(di.path));
assertThat(c.author._accountId).isEqualTo(user.id().get());
assertThat(c.message).isEqualTo(di.message);
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
index 2c9107c..b70cab8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/WatchedProjectsIT.java
@@ -249,4 +249,30 @@
public void postWithoutBody() throws Exception {
adminRestSession.post("/accounts/" + admin.username() + "/watched.projects").assertOK();
}
+
+ @Test
+ public void nullProjectThrowsBadRequestException() {
+ List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+ ProjectWatchInfo pwi = new ProjectWatchInfo();
+ pwi.project = null;
+ projectsToWatch.add(pwi);
+ Throwable t =
+ assertThrows(
+ BadRequestException.class,
+ () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+ assertThat(t.getMessage()).isEqualTo("project name must be specified");
+ }
+
+ @Test
+ public void emptyProjectThrowsBadRequestException() {
+ List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
+ ProjectWatchInfo pwi = new ProjectWatchInfo();
+ pwi.project = " ";
+ projectsToWatch.add(pwi);
+ Throwable t =
+ assertThrows(
+ BadRequestException.class,
+ () -> gApi.accounts().self().setWatchedProjects(projectsToWatch));
+ assertThat(t.getMessage()).isEqualTo("project name must be specified");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 574e919..2e702c10 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -163,6 +163,8 @@
RestCall.put("/changes/%s/revisions/%s/drafts"),
RestCall.get("/changes/%s/revisions/%s/comments"),
RestCall.get("/changes/%s/revisions/%s/robotcomments"),
+ RestCall.get("/changes/%s/revisions/%s/ported_comments"),
+ RestCall.get("/changes/%s/revisions/%s/ported_drafts"),
RestCall.builder(GET, "/changes/%s/revisions/%s/fixes")
// GET /changes/<change>/revisions/<revision>/fixes is not implemented
.expectedResponseCode(SC_NOT_FOUND)
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
index e9f5143..f1c0110 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ProjectsRestApiBindingsIT.java
@@ -29,8 +29,8 @@
import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
import com.google.gerrit.acceptance.rest.util.RestCall;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.projects.BranchInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 75950e2..085d23d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -55,15 +55,18 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.SubmitInput;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -105,6 +108,7 @@
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
import org.eclipse.jgit.junit.TestRepository;
@@ -1337,6 +1341,36 @@
}
}
+ @Test
+ public void submitThatAddsUsersAsReviewersEnsuresTheyAreNotAddedToAttentionSet()
+ throws Exception {
+ PushOneCommit.Result r = createChange("refs/heads/master", "file1", "content");
+
+ // Someone else approves, because if admin reviews, they will be added to the reviewers (and the
+ // bug won't be reproduced).
+ requestScopeOperations.setApiUser(accountCreator.admin2().id());
+ change(r).current().review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+
+ requestScopeOperations.setApiUser(admin.id());
+
+ change(r).attention(admin.email()).remove(new AttentionSetInput("remove"));
+ change(r).current().submit();
+
+ AttentionSetUpdate attentionSet =
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+
+ assertThat(attentionSet.account()).isEqualTo(admin.id());
+ assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet.reason()).isEqualTo("remove");
+ }
+
+ private List<AttentionSetUpdate> getAttentionSetUpdatesForUser(
+ PushOneCommit.Result r, TestAccount account) {
+ return r.getChange().attentionSet().stream()
+ .filter(a -> a.account().get() == account.id().get())
+ .collect(Collectors.toList());
+ }
+
private void assertSubmitter(PushOneCommit.Result change) throws Throwable {
ChangeInfo info = get(change.getChangeId(), ListChangesOption.MESSAGES);
assertThat(info.messages).isNotNull();
@@ -1405,6 +1439,12 @@
assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isTrue();
}
+ protected void assertSubmitDisabled(String changeId) throws Throwable {
+ RevisionResource rsrc = parseCurrentRevisionResource(changeId);
+ UiAction.Description desc = submitHandler.getDescription(rsrc);
+ assertWithMessage("enabled bit on submit action").that(desc.isEnabled()).isFalse();
+ }
+
protected void assertChangeMergedEvents(String... expected) throws Throwable {
eventRecorder.assertChangeMergedEvents(project.get(), "refs/heads/master", expected);
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
index f77552d..9c496fa 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByMerge.java
@@ -20,6 +20,7 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.TestProjectInput;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.client.InheritableBoolean;
import com.google.inject.Inject;
import org.eclipse.jgit.revwalk.RevCommit;
@@ -117,4 +118,49 @@
assertThat(head.getParent(0)).isEqualTo(change1.getCommit());
assertThat(head.getParent(1)).isEqualTo(change2.getCommit());
}
+
+ @Test
+ public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
+ // Create a change
+ PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+ PushOneCommit.Result changeResult = change.to("refs/for/master");
+ PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+ // Create a successor change.
+ PushOneCommit change2 =
+ pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+ PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+ // Create new patch set for first change.
+ testRepo.reset(changeResult.getCommit().name());
+ amendChange(changeResult.getChangeId());
+
+ // Approve both changes
+ approve(changeResult.getChangeId());
+ approve(change2Result.getChangeId());
+
+ // submit button is disabled.
+ assertSubmitDisabled(change2Result.getChangeId());
+
+ submitWithConflict(
+ change2Result.getChangeId(),
+ "Failed to submit 2 changes due to the following problems:\n"
+ + "Change "
+ + change2Result.getChange().getId()
+ + ": Depends on change that was not submitted."
+ + " Commit "
+ + change2Result.getCommit().name()
+ + " depends on commit "
+ + changeResult.getCommit().name()
+ + ", which is outdated patch set "
+ + patchSetId.get()
+ + " of change "
+ + changeResult.getChange().getId()
+ + ". The latest patch set is "
+ + changeResult.getPatchSetId().get()
+ + ".");
+
+ assertRefUpdatedEvents();
+ assertChangeMergedEvents();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
index fff67f3..5eb19df 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmitByRebase.java
@@ -28,8 +28,9 @@
import com.google.gerrit.acceptance.TestProjectInput;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -384,4 +385,49 @@
gApi.changes().id(change2.getChangeId()).current().rebase();
submit(change2.getChangeId());
}
+
+ @Test
+ public void dependencyOnOutdatedPatchSetPreventsRebase() throws Throwable {
+ // Create a change
+ PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+ PushOneCommit.Result changeResult = change.to("refs/for/master");
+ PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+ // Create a successor change.
+ PushOneCommit change2 =
+ pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+ PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+ // Create new patch set for first change.
+ testRepo.reset(changeResult.getCommit().name());
+ amendChange(changeResult.getChangeId());
+
+ // Approve both changes
+ approve(changeResult.getChangeId());
+ approve(change2Result.getChangeId());
+
+ // submit button is disabled.
+ assertSubmitDisabled(change2Result.getChangeId());
+
+ submitWithConflict(
+ change2Result.getChangeId(),
+ "Failed to submit 2 changes due to the following problems:\n"
+ + "Change "
+ + change2Result.getChange().getId()
+ + ": Depends on change that was not submitted."
+ + " Commit "
+ + change2Result.getCommit().name()
+ + " depends on commit "
+ + changeResult.getCommit().name()
+ + ", which is outdated patch set "
+ + patchSetId.get()
+ + " of change "
+ + changeResult.getChange().getId()
+ + ". The latest patch set is "
+ + changeResult.getPatchSetId().get()
+ + ".");
+
+ assertRefUpdatedEvents();
+ assertChangeMergedEvents();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 2d47dd8..36cd3cb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -27,7 +27,7 @@
import com.google.gerrit.acceptance.UseClockStep;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.AssigneeInput;
import com.google.gerrit.extensions.client.ReviewerState;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index 1532b33..9e944a2 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -15,35 +15,56 @@
package com.google.gerrit.acceptance.rest.change;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.extensions.restapi.testing.AttentionSetUpdateSubject.assertThat;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import com.google.common.collect.ImmutableList;
+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.GitUtil;
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.config.GerritConfig;
+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.AttentionSetUpdate.Operation;
+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;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
import com.google.gerrit.extensions.api.changes.HashtagsInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
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.account.ServiceUserClassifier;
+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;
+import java.util.regex.Pattern;
import java.util.stream.Collectors;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
import org.junit.Before;
import org.junit.Test;
@@ -51,8 +72,14 @@
@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 {
Instant now = Instant.now();
@@ -87,42 +114,50 @@
@Test
public void addUser() throws Exception {
PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
int accountId =
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "first"))._accountId;
- assertThat(accountId).isEqualTo(user.id().get());
+ change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "first"))._accountId;
+ assertThat(accountId).isEqualTo(admin.id().get());
AttentionSetUpdate expectedAttentionSetUpdate =
AttentionSetUpdate.createFromRead(
- fakeClock.now(), user.id(), AttentionSetUpdate.Operation.ADD, "first");
+ fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.ADD, "first");
assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
// Second add is ignored.
accountId =
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "second"))._accountId;
- assertThat(accountId).isEqualTo(user.id().get());
+ change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "second"))._accountId;
+ assertThat(accountId).isEqualTo(admin.id().get());
assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+ // Only one email since the second add was ignored.
+ String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+ assertThat(emailBody)
+ .contains(
+ String.format(
+ "%s requires the attention of %s to this change.\n The reason is: first.",
+ user.fullName(), admin.fullName()));
}
@Test
public void addMultipleUsers() throws Exception {
PushOneCommit.Result r = createChange();
Instant timestamp1 = fakeClock.now();
- int accountId1 =
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"))._accountId;
- assertThat(accountId1).isEqualTo(user.id().get());
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
fakeClock.advance(Duration.ofSeconds(42));
Instant timestamp2 = fakeClock.now();
int accountId2 =
change(r)
- .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "admin"))
+ .addToAttentionSet(new AttentionSetInput(admin.id().toString(), "manual update"))
._accountId;
assertThat(accountId2).isEqualTo(admin.id().get());
AttentionSetUpdate expectedAttentionSetUpdate1 =
AttentionSetUpdate.createFromRead(
- timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "user");
+ timestamp1, user.id(), AttentionSetUpdate.Operation.ADD, "Reviewer was added");
AttentionSetUpdate expectedAttentionSetUpdate2 =
AttentionSetUpdate.createFromRead(
- timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "admin");
+ timestamp2, admin.id(), AttentionSetUpdate.Operation.ADD, "manual update");
assertThat(r.getChange().attentionSet())
.containsExactly(expectedAttentionSetUpdate1, expectedAttentionSetUpdate2);
}
@@ -130,7 +165,11 @@
@Test
public void removeUser() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "added"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+ sender.clear();
+ requestScopeOperations.setApiUser(user.id());
+
fakeClock.advance(Duration.ofSeconds(42));
change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
AttentionSetUpdate expectedAttentionSetUpdate =
@@ -142,11 +181,22 @@
fakeClock.advance(Duration.ofSeconds(42));
change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed again"));
assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+
+ // Only one email since the second remove was ignored.
+ String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+ assertThat(emailBody)
+ .contains(
+ user.fullName()
+ + " removed themselves from the attention set of this change.\n"
+ + " The reason is: removed.");
}
@Test
public void removeUserWithInvalidUserInput() throws Exception {
PushOneCommit.Result r = createChange();
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+
BadRequestException exception =
assertThrows(
BadRequestException.class,
@@ -155,7 +205,9 @@
.attention(user.id().toString())
.remove(new AttentionSetInput("invalid user", "reason")));
assertThat(exception.getMessage())
- .isEqualTo("The user specified in the input body couldn't be found.");
+ .isEqualTo(
+ "invalid user doesn't exist or is not active on the change as an owner, "
+ + "uploader, reviewer, or cc so they can't be added to the attention set");
exception =
assertThrows(
@@ -170,57 +222,49 @@
}
@Test
- public void removeUnrelatedUser() throws Exception {
- PushOneCommit.Result r = createChange();
- change(r).attention(user.id().toString()).remove(new AttentionSetInput("foo"));
- assertThat(r.getChange().attentionSet()).isEmpty();
- }
-
- @Test
public void abandonRemovesUsers() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "admin"));
change(r).abandon();
AttentionSetUpdate userUpdate =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(userUpdate.account()).isEqualTo(user.id());
- assertThat(userUpdate.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(userUpdate.reason()).isEqualTo("Change was abandoned");
+ assertThat(userUpdate).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(userUpdate).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(userUpdate).hasReasonThat().isEqualTo("Change was abandoned");
AttentionSetUpdate adminUpdate =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
- assertThat(adminUpdate.account()).isEqualTo(admin.id());
- assertThat(adminUpdate.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(adminUpdate.reason()).isEqualTo("Change was abandoned");
+ assertThat(adminUpdate).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(adminUpdate).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(adminUpdate).hasReasonThat().isEqualTo("Change was abandoned");
}
@Test
public void workInProgressRemovesUsers() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
change(r).setWorkInProgress();
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("Change was marked work in progress");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Change was marked work in progress");
}
@Test
public void submitRemovesUsersForAllSubmittedChanges() throws Exception {
PushOneCommit.Result r1 = createChange("refs/heads/master", "file1", "content");
- change(r1)
- .current()
- .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r1).current().review(ReviewInput.approve().reviewer(user.email()));
PushOneCommit.Result r2 = createChange("refs/heads/master", "file2", "content");
- change(r2)
- .current()
- .review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+ change(r2).current().review(ReviewInput.approve().reviewer(user.email()));
change(r2).current().submit();
@@ -228,45 +272,44 @@
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r1, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("Change was submitted");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
// Attention set updates that relate to the admin (the person who replied) are filtered out.
attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r2, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("Change was submitted");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
}
- /**
- * There is currently a bug that adds the person who submitted the change as reviewer, which in
- * turn adds them to the attention set. This test ensures this doesn't happen.
- */
@Test
- public void submitDoesNotAddReviewersToAttentionSet() throws Exception {
+ public void robotSubmitsRemovesUsers() throws Exception {
PushOneCommit.Result r = createChange("refs/heads/master", "file1", "content");
- // Someone else approves, because if admin reviews, they will be added to the reviewers (and the
- // bug won't be reproduced).
- requestScopeOperations.setApiUser(accountCreator.admin2().id());
- change(r).current().review(ReviewInput.approve().addUserToAttentionSet(user.email(), "reason"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
- requestScopeOperations.setApiUser(admin.id());
-
- change(r).attention(admin.email()).remove(new AttentionSetInput("remove"));
+ TestAccount robot =
+ accountCreator.create(
+ "robot2",
+ "robot2@example.com",
+ "Ro Bot",
+ "Ro",
+ ServiceUserClassifier.SERVICE_USERS,
+ "Administrators");
+ requestScopeOperations.setApiUser(robot.id());
+ change(r).current().review(ReviewInput.approve());
change(r).current().submit();
// Attention set updates that relate to the admin (the person who replied) are filtered out.
AttentionSetUpdate attentionSet =
- Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(admin.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("remove");
-
- change(r).addReviewer(user.email());
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Change was submitted");
}
@Test
@@ -279,9 +322,9 @@
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
}
@Test
@@ -291,16 +334,41 @@
change(r).addReviewer(user.id().toString());
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
change(r).reviewer(user.email()).remove();
attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
+ }
+
+ @Test
+ public void removedCcRemovedFromAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ // Add cc
+ AddReviewerInput input = new AddReviewerInput();
+ input.reviewer = user.email();
+ input.state = ReviewerState.CC;
+ change(r).addReviewer(input);
+
+ // Add them to the attention set
+ AttentionSetInput attentionSetInput = new AttentionSetInput();
+ attentionSetInput.user = user.email();
+ attentionSetInput.reason = "reason";
+ change(r).addToAttentionSet(attentionSetInput);
+
+ // Remove them from cc
+ change(r).reviewer(user.email()).remove();
+
+ AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
}
@Test
@@ -310,16 +378,16 @@
change(r).addReviewer(user.email());
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
change(r).reviewer(user.email()).remove();
attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
}
@Test
@@ -332,7 +400,7 @@
}
@Test
- public void addingReviewerWhileMarkingWorkInprogressDoesntAddToAttentionSet() throws Exception {
+ public void addingReviewerWhileMarkingWorkInProgressDoesntAddToAttentionSet() throws Exception {
PushOneCommit.Result r = createChange();
ReviewInput reviewInput = ReviewInput.create().setWorkInProgress(true);
AddReviewerInput addReviewerInput = new AddReviewerInput();
@@ -356,9 +424,10 @@
change(r).addReviewer(user.id().toString());
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason())
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet)
+ .hasReasonThat()
.isEqualTo("removed and not re-added when re-adding as reviewer");
}
@@ -385,9 +454,31 @@
change(r).addReviewer(addReviewerInput);
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
+ }
+
+ @Test
+ public void robotReadyForReviewAddsAllReviewersToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).setWorkInProgress();
+ change(r).addReviewer(user.email());
+
+ TestAccount robot =
+ accountCreator.create(
+ "robot1",
+ "robot1@example.com",
+ "Ro Bot",
+ "Ro",
+ ServiceUserClassifier.SERVICE_USERS,
+ "Administrators");
+ requestScopeOperations.setApiUser(robot.id());
+ change(r).setReadyForReview();
+ AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Change was marked ready for review");
}
@Test
@@ -398,9 +489,9 @@
change(r).setReadyForReview();
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("Change was marked ready for review");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Change was marked ready for review");
}
@Test
@@ -419,9 +510,9 @@
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
}
@Test
@@ -434,15 +525,17 @@
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("Reviewer was added");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
}
@Test
public void reviewersAreNotAddedForNoReasonBecauseOfAnUpdate() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "user"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+
change(r).attention(user.id().toString()).remove(new AttentionSetInput("removed"));
HashtagsInput hashtagsInput = new HashtagsInput();
@@ -450,29 +543,35 @@
change(r).setHashtags(hashtagsInput);
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("removed");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("removed");
}
@Test
public void reviewAddsManuallyAddedUserToAttentionSet() throws Exception {
PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(user.email(), "reason");
change(r).current().review(reviewInput);
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("reason");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+
+ // No emails for adding to attention set were sent.
+ email.getMessages().isEmpty();
}
@Test
public void reviewRemovesManuallyRemovedUserFromAttentionSet() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+ requestScopeOperations.setApiUser(user.id());
ReviewInput reviewInput =
ReviewInput.create().removeUserFromAttentionSet(user.email(), "reason");
@@ -480,15 +579,19 @@
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("reason");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+
+ // No emails for removing from attention set were sent.
+ email.getMessages().isEmpty();
}
@Test
public void reviewWithManualAdditionToAttentionSetFailsWithoutReason() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
ReviewInput reviewInput = ReviewInput.create().addUserToAttentionSet(user.email(), "");
@@ -512,7 +615,8 @@
@Test
public void reviewAddReviewerWhileRemovingFromAttentionSetJustRemovesUser() throws Exception {
PushOneCommit.Result r = createChange();
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "addition"));
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
ReviewInput reviewInput =
ReviewInput.create()
@@ -524,9 +628,9 @@
// Attention set updates that relate to the admin (the person who replied) are filtered out.
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("reason");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
}
@Test
@@ -542,7 +646,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
@@ -558,7 +663,28 @@
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
+ public void reviewDoesNotAddReviewerWithoutAutomaticRules() throws Exception {
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+ ReviewInput reviewInput = ReviewInput.recommend().blockAutomaticAttentionSetRules();
+
+ change(r).current().review(reviewInput);
+ assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+ }
+
+ @Test
+ public void reviewDoesNotAddReviewer() throws Exception {
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+ ReviewInput reviewInput = ReviewInput.recommend();
+
+ change(r).current().review(reviewInput);
+ assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
}
@Test
@@ -574,7 +700,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
@@ -593,9 +720,9 @@
// Attention set updates that relate to the admin (the person who replied) are filtered out.
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("reason");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
}
@Test
@@ -612,9 +739,9 @@
// Attention set updates that relate to the admin (the person who replied) are filtered out.
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("reason");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
}
@Test
@@ -626,9 +753,9 @@
change(r).current().review(reviewInput);
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(admin.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("removed on reply");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("removed on reply");
}
@Test
@@ -639,9 +766,9 @@
change(r).current().review(reviewInput);
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(admin.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("reason");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
}
@Test
@@ -650,15 +777,23 @@
requestScopeOperations.setApiUser(user.id());
- change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
+ // add the user to the attention set.
+ change(r)
+ .current()
+ .review(
+ ReviewInput.create()
+ .reviewer(user.email(), ReviewerState.CC, true)
+ .addUserToAttentionSet(user.email(), "reason"));
+
+ // add the user as reviewer but still be removed on reply.
ReviewInput reviewInput = ReviewInput.create().reviewer(user.email());
change(r).current().review(reviewInput);
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("removed on reply");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("removed on reply");
}
@Test
@@ -684,9 +819,21 @@
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
- assertThat(attentionSet.account()).isEqualTo(admin.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("reviewer or cc replied");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
+ }
+
+ @Test
+ public void repliesDoNotAddOwnerWhenChangeIsClosed() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).abandon();
+ requestScopeOperations.setApiUser(user.id());
+
+ ReviewInput reviewInput = new ReviewInput();
+ change(r).current().review(reviewInput);
+
+ assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
}
@Test
@@ -723,29 +870,16 @@
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
- assertThat(attentionSet.account()).isEqualTo(admin.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("reviewer or cc replied");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
}
@Test
public void repliesAddsOwnerAndUploader() throws Exception {
// Create change with owner: admin
PushOneCommit.Result r = createChange();
-
- // Clone, fetch, and checkout the change with user, and then create a new patchset.
- TestRepository<InMemoryRepository> repo = cloneProject(project, user);
- GitUtil.fetch(repo, "refs/*:refs/*");
- repo.reset(r.getCommit());
- r =
- amendChange(
- r.getChangeId(),
- "refs/for/master",
- user,
- repo,
- "new subject",
- "new file",
- "new content");
+ r = amendChangeWithUploader(r, project, user);
TestAccount user2 = accountCreator.user2();
requestScopeOperations.setApiUser(user2.id());
@@ -760,48 +894,223 @@
// Uploader added
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("reviewer or cc replied");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
// Owner added
attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
- assertThat(attentionSet.account()).isEqualTo(admin.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("reviewer or cc replied");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
}
@Test
- public void ownerRepliesAddsReviewersOnly() throws Exception {
+ public void reviewIgnoresRobotCommentsForAttentionSet() throws Exception {
PushOneCommit.Result r = createChange();
- // add reviewer and cc
- change(r).addReviewer(user.email());
+ requestScopeOperations.setApiUser(user.id());
+ testCommentHelper.addRobotComment(
+ r.getChangeId(),
+ TestCommentHelper.createRobotCommentInputWithMandatoryFields(Patch.COMMIT_MSG));
+
+ requestScopeOperations.setApiUser(admin.id());
change(r)
- .attention(user.email())
- .remove(new AttentionSetInput("Reviewer is not in attention-set"));
+ .current()
+ .review(
+ reviewInReplyToComment(
+ Iterables.getOnlyElement(
+ gApi.changes().id(r.getChangeId()).current().robotCommentsAsList())
+ .id));
+ assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+ }
- TestAccount cc = accountCreator.admin2();
- AddReviewerInput input = new AddReviewerInput();
- input.state = ReviewerState.CC;
- input.reviewer = cc.email();
- change(r).addReviewer(input);
+ @Test
+ public void reviewAddsAllUsersInCommentThread() throws Exception {
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+ change(r).current().review(reviewWithComment());
- ReviewInput reviewInput = new ReviewInput();
- change(r).current().review(reviewInput);
+ TestAccount user2 = accountCreator.user2();
- // cc not added
- assertThat(getAttentionSetUpdatesForUser(r, cc)).isEmpty();
+ requestScopeOperations.setApiUser(user2.id());
+ change(r)
+ .current()
+ .review(
+ reviewInReplyToComment(
+ Iterables.getOnlyElement(
+ gApi.changes().id(r.getChangeId()).current().commentsAsList())
+ .id));
- // reviewer added
+ change(r).attention(user.email()).remove(new AttentionSetInput("removal"));
+ requestScopeOperations.setApiUser(admin.id());
+ change(r)
+ .current()
+ .review(
+ reviewInReplyToComment(
+ gApi.changes().id(r.getChangeId()).current().commentsAsList().get(1).id));
+
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("owner or uploader replied");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet)
+ .hasReasonThat()
+ .isEqualTo("Someone else replied on a comment you posted");
+
+ attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user2));
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user2.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet)
+ .hasReasonThat()
+ .isEqualTo("Someone else replied on a comment you posted");
}
@Test
- public void ownerRepliesWhileRemovingReviewerStillRemovesFromAttentionSet() throws Exception {
+ public void reviewAddsAllUsersInCommentThreadWhenOriginalCommentIsARobotComment()
+ throws Exception {
+ PushOneCommit.Result result = createChange();
+ testCommentHelper.addRobotComment(
+ result.getChangeId(),
+ TestCommentHelper.createRobotCommentInputWithMandatoryFields(Patch.COMMIT_MSG));
+
+ requestScopeOperations.setApiUser(user.id());
+ // Reply to the robot comment.
+ change(result)
+ .current()
+ .review(
+ reviewInReplyToComment(
+ Iterables.getOnlyElement(
+ gApi.changes().id(result.getChangeId()).current().robotCommentsAsList())
+ .id));
+
+ requestScopeOperations.setApiUser(admin.id());
+ // Reply to the human comment. which was a reply to the robot comment.
+ change(result)
+ .current()
+ .review(
+ reviewInReplyToComment(
+ Iterables.getOnlyElement(
+ gApi.changes().id(result.getChangeId()).current().commentsAsList())
+ .id));
+
+ // The user which replied to the robot comment was added to the attention set.
+ AttentionSetUpdate attentionSet =
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(result, user));
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet)
+ .hasReasonThat()
+ .isEqualTo("Someone else replied on a comment you posted");
+ }
+
+ @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());
+ change(r).current().review(reviewWithComment());
+
+ requestScopeOperations.setApiUser(admin.id());
+ testCommentHelper.addDraft(
+ r.getChangeId(),
+ testCommentHelper.newDraft(
+ "message",
+ Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).current().commentsAsList())
+ .id));
+
+ ReviewInput reviewInput = new ReviewInput();
+ reviewInput.drafts = ReviewInput.DraftHandling.PUBLISH;
+ change(r).current().review(reviewInput);
+
+ AttentionSetUpdate attentionSet =
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet)
+ .hasReasonThat()
+ .isEqualTo("Someone else replied on a comment you posted");
+ }
+
+ @Test
+ public void reviewDoesNotAddUsersInACommentThreadThatAreNotActiveInTheChange() throws Exception {
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+ change(r).current().review(reviewWithComment());
+ change(r).reviewer(user.id().toString()).remove(new DeleteReviewerInput());
+
+ requestScopeOperations.setApiUser(admin.id());
+ change(r)
+ .current()
+ .review(
+ reviewInReplyToComment(
+ Iterables.getOnlyElement(
+ gApi.changes().id(r.getChangeId()).current().commentsAsList())
+ .id));
+
+ // The user was to be added, but was not added since that user is no longer a
+ // reviewer/cc/owner/uploader.
+ assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+ }
+
+ @Test
+ public void ownerRepliesWhileRemovingReviewerRemovesFromAttentionSet() throws Exception {
PushOneCommit.Result r = createChange();
change(r).addReviewer(user.email());
@@ -811,64 +1120,46 @@
// cc removed
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("Reviewer was removed");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer/Cc was removed");
}
@Test
- public void uploaderRepliesAddsOwnerAndReviewersOnly() throws Exception {
+ public void uploaderRepliesAddsOwner() throws Exception {
PushOneCommit.Result r = createChange();
-
- // Clone, fetch, and checkout the change with user, and then create a new patchset.
- TestRepository<InMemoryRepository> repo = cloneProject(project, user);
- GitUtil.fetch(repo, "refs/*:refs/*");
- repo.reset(r.getCommit());
- r =
- amendChange(
- r.getChangeId(),
- "refs/for/master",
- user,
- repo,
- "new subject",
- "new file",
- "new content");
+ r = amendChangeWithUploader(r, project, user);
// Add reviewer and cc
TestAccount reviewer = accountCreator.user2();
- change(r).addReviewer(reviewer.email());
TestAccount cc = accountCreator.admin2();
- AddReviewerInput input = new AddReviewerInput();
- input.state = ReviewerState.CC;
- input.reviewer = cc.email();
- change(r).addReviewer(input);
-
- requestScopeOperations.setApiUser(user.id());
- change(r).attention(reviewer.email()).remove(new AttentionSetInput("reason"));
-
- ReviewInput reviewInput = new ReviewInput();
+ ReviewInput reviewInput = new ReviewInput().blockAutomaticAttentionSetRules();
+ reviewInput = reviewInput.reviewer(reviewer.email());
+ reviewInput.reviewer(cc.email(), ReviewerState.CC, false);
change(r).current().review(reviewInput);
- // cc not added
- assertThat(getAttentionSetUpdatesForUser(r, cc)).isEmpty();
+ requestScopeOperations.setApiUser(user.id());
- // reviewer added
- AttentionSetUpdate attentionSet =
- Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, reviewer));
- assertThat(attentionSet.account()).isEqualTo(reviewer.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("owner or uploader replied");
+ change(r).current().review(new ReviewInput());
+
+ // Reviewer and CC not added since the uploader didn't reply to their comments
+ assertThat(getAttentionSetUpdatesForUser(r, cc)).isEmpty();
+ assertThat(getAttentionSetUpdatesForUser(r, reviewer)).isEmpty();
// Owner added
- attentionSet = Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
- assertThat(attentionSet.account()).isEqualTo(admin.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("uploader replied");
+ AttentionSetUpdate attentionSet =
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Someone else replied on the change");
}
@Test
public void repliesWhileAddingAsReviewerStillRemovesUser() throws Exception {
PushOneCommit.Result r = createChange();
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+
change(r).addToAttentionSet(new AttentionSetInput(user.email(), "remove"));
requestScopeOperations.setApiUser(user.id());
@@ -878,13 +1169,13 @@
// reviewer removed
AttentionSetUpdate attentionSet =
Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
- assertThat(attentionSet.account()).isEqualTo(user.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("removed on reply");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("removed on reply");
}
@Test
- public void attentionSetUnchangedWithIgnoreDefaultAttentionSetRules() throws Exception {
+ public void attentionSetUnchangedWithIgnoreAutomaticAttentionSetRules() throws Exception {
PushOneCommit.Result r = createChange();
change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
change(r)
@@ -892,18 +1183,52 @@
.review(
ReviewInput.create()
.reviewer(admin.email(), ReviewerState.CC, false)
- .blockDefaultAttentionSetRules());
+ .blockAutomaticAttentionSetRules());
// admin is still in the attention set, although replies remove from attention set, and removing
// from reviewer also should remove from attention set.
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(admin.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
- assertThat(attentionSet.reason()).isEqualTo("reason");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
}
@Test
- public void attentionSetStillChangesWithIgnoreDefaultAttentionSetRulesWithInputList()
+ public void ownerNotAddedAsReviewerToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).current().review(ReviewInput.approve());
+ assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+ }
+
+ @Test
+ public void ownerNotAddedAsReviewerToAttentionSetWithoutAutomaticRules() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).current().review(ReviewInput.approve().blockAutomaticAttentionSetRules());
+ assertThat(getAttentionSetUpdatesForUser(r, admin)).isEmpty();
+ }
+
+ @Test
+ public void uploaderNotAddedAsReviewerToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+ amendChangeWithUploader(r, project, user);
+ requestScopeOperations.setApiUser(user.id());
+
+ change(r).current().review(ReviewInput.recommend());
+ assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+ }
+
+ @Test
+ public void uploaderNotAddedAsReviewerToAttentionSetWithoutAutomaticRules() throws Exception {
+ PushOneCommit.Result r = createChange();
+ amendChangeWithUploader(r, project, user);
+ requestScopeOperations.setApiUser(user.id());
+
+ change(r).current().review(ReviewInput.recommend().blockAutomaticAttentionSetRules());
+ assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+ }
+
+ @Test
+ public void attentionSetStillChangesWithIgnoreAutomaticAttentionSetRulesWithInputList()
throws Exception {
PushOneCommit.Result r = createChange();
change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
@@ -912,20 +1237,504 @@
.review(
ReviewInput.create()
.removeUserFromAttentionSet(admin.email(), "removed")
- .blockDefaultAttentionSetRules());
+ .blockAutomaticAttentionSetRules());
// Admin is still removed although we block default attention set rules, since we remove
// the admin manually.
AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
- assertThat(attentionSet.account()).isEqualTo(admin.id());
- assertThat(attentionSet.operation()).isEqualTo(AttentionSetUpdate.Operation.REMOVE);
- assertThat(attentionSet.reason()).isEqualTo("removed");
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.REMOVE);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("removed");
+ }
+
+ @Test
+ public void robotsNotAddedToAttentionSet() throws Exception {
+ TestAccount robot =
+ accountCreator.create(
+ "robot1", "robot1@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+ PushOneCommit.Result r = createChange();
+ // Make the robot active on the change.
+ change(r).addReviewer(robot.email());
+
+ // Throw an error when adding a robot explicitly.
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> change(r).addToAttentionSet(new AttentionSetInput(robot.email(), "reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ "robot1@example.com is a robot, and robots can't be added to the attention set.");
+
+ // Robots are not added implicitly.
+ change(r).addReviewer(robot.email());
+ assertThat(r.getChange().attentionSet()).isEmpty();
+ }
+
+ @Test
+ public void robotAddingAReviewerChangeAttentionSet() throws Exception {
+ TestAccount robot =
+ accountCreator.create(
+ "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(robot.id());
+ change(r).addReviewer(user.id().toString());
+
+ // Bots can still change the attention set, just not when replying.
+ AttentionSetUpdate attentionSet =
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
+ }
+
+ @Test
+ public void robotReviewDoesNotChangeAttentionSet() throws Exception {
+ TestAccount robot =
+ accountCreator.create(
+ "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(robot.id());
+ change(r).current().review(ReviewInput.recommend());
+
+ assertThat(r.getChange().attentionSet()).isEmpty();
+ }
+
+ @Test
+ public void robotReviewWithNegativeLabelAddsOwner() throws Exception {
+ TestAccount robot =
+ accountCreator.create(
+ "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(robot.id());
+ change(r).current().review(ReviewInput.dislike());
+
+ AttentionSetUpdate attentionSet =
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("A robot voted negatively on a label");
+ }
+
+ @Test
+ public void robotCommentDoesNotAddOwnerOnClosedChanges() throws Exception {
+ TestAccount robot =
+ accountCreator.create(
+ "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+ PushOneCommit.Result r = createChange();
+ gApi.changes().id(r.getChangeId()).abandon();
+
+ requestScopeOperations.setApiUser(robot.id());
+ ReviewInput reviewInput = new ReviewInput();
+ ReviewInput.RobotCommentInput robotCommentInput =
+ TestCommentHelper.createRobotCommentInputWithMandatoryFields("a.txt");
+ reviewInput.robotComments = ImmutableMap.of("a.txt", ImmutableList.of(robotCommentInput));
+ change(r).current().review(reviewInput);
+
+ assertThat(r.getChange().attentionSet()).isEmpty();
+ }
+
+ @Test
+ public void robotCanChangeAttentionSetExplicitly() throws Exception {
+ TestAccount robot =
+ accountCreator.create(
+ "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(robot.id());
+ change(r).current().review(new ReviewInput().addUserToAttentionSet(admin.email(), "reason"));
+
+ AttentionSetUpdate attentionSet =
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin));
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(admin.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
+ }
+
+ @Test
+ public void addUsersToAttentionSetInPrivateChanges() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).setPrivate(true);
+
+ // implictly adds the user to the attention set when adding as reviewer
+ change(r).addReviewer(user.email());
+
+ AttentionSetUpdate attentionSet =
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
+ }
+
+ @Test
+ public void addUsersAsReviewerAndAttentionSetInPrivateChanges() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).setPrivate(true);
+ change(r).current().review(new ReviewInput().reviewer(user.email()));
+
+ AttentionSetUpdate attentionSet =
+ Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+ assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+ assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+ assertThat(attentionSet).hasReasonThat().isEqualTo("Reviewer was added");
+ }
+
+ @Test
+ public void attentionSetEmailFooter() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ // Add user to attention set. They receive an email with the attention footer.
+ change(r).addReviewer(user.id().toString());
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .contains("Gerrit-Attention: " + user.fullName());
+ sender.clear();
+
+ // Irrelevant reply, User is still in the attention set.
+ change(r).current().review(ReviewInput.approve());
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .contains("Gerrit-Attention: " + user.fullName());
+ sender.clear();
+
+ // Abandon the change which removes user from attention set; there is an email but without the
+ // attention footer.
+ change(r).abandon();
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .doesNotContain("Gerrit-Attention: " + user.fullName());
+ sender.clear();
+ }
+
+ @Test
+ @GerritConfig(name = "change.enableAttentionSet", value = "true")
+ public void attentionSetEmailHeader() throws Exception {
+ PushOneCommit.Result r = createChange();
+ TestAccount user2 = accountCreator.user2();
+
+ // The pattern ensures the header mentions the attention set requirements in any order.
+ Pattern attentionSetHeaderPattern =
+ Pattern.compile(
+ String.format(
+ "Attention is currently required from: (%s|%s), (%s|%s).",
+ user2.fullName(), user.fullName(), user.fullName(), user2.fullName()));
+ // Add user and user2 to the attention set.
+ change(r)
+ .current()
+ .review(
+ ReviewInput.create().reviewer(user.email()).reviewer(accountCreator.user2().email()));
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .containsMatch(attentionSetHeaderPattern);
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+ .containsMatch(attentionSetHeaderPattern);
+ sender.clear();
+
+ // Irrelevant reply, User and User2 are still in the attention set.
+ change(r).current().review(ReviewInput.approve());
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .containsMatch(attentionSetHeaderPattern);
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+ .containsMatch(attentionSetHeaderPattern);
+ sender.clear();
+
+ // Abandon the change which removes user from attention set; there is an email but without the
+ // attention footer.
+ change(r).abandon();
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .doesNotContain("Attention is currently required");
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+ .doesNotContain("Attention is currently required");
+ sender.clear();
+ }
+
+ @Test
+ @GerritConfig(name = "change.enableAttentionSet", value = "false")
+ public void noReferenceToAttentionSetInEmailsWhenDisabled() throws Exception {
+ PushOneCommit.Result r = createChange();
+ // Add user and to the attention set.
+ change(r).addReviewer(user.id().toString());
+
+ // Attention set is not referenced.
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+ .doesNotContain("Attention is currently required");
+ assertThat(Iterables.getOnlyElement(sender.getMessages()).htmlBody())
+ .doesNotContain("Attention is currently required");
+ sender.clear();
+ }
+
+ @Test
+ public void attentionSetWithEmailFilter() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ // Add preference for the user such that they only receive an email on changes that require
+ // their attention.
+ requestScopeOperations.setApiUser(user.id());
+ GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+ prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+ gApi.accounts().self().setPreferences(prefs);
+ requestScopeOperations.setApiUser(admin.id());
+
+ // Add user to attention set. They receive an email since they are in the attention set.
+ change(r).addReviewer(user.id().toString());
+ assertThat(sender.getMessages()).isNotEmpty();
+ sender.clear();
+
+ // Irrelevant reply, User is still in the attention set, thus got another email.
+ change(r).current().review(ReviewInput.approve());
+ assertThat(sender.getMessages()).isNotEmpty();
+ sender.clear();
+
+ // Abandon the change which removes user from attention set; the user doesn't receive an email
+ // since they are not in the attention set.
+ change(r).abandon();
+ assertThat(sender.getMessages()).isEmpty();
+ }
+
+ @Test
+ public void attentionSetWithEmailFilterFiltersNewPatchsets() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ // Add preference for the user such that they only receive an email on changes that require
+ // their attention.
+ requestScopeOperations.setApiUser(user.id());
+ GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+ prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+ gApi.accounts().self().setPreferences(prefs);
+ requestScopeOperations.setApiUser(admin.id());
+
+ // Add user to reviewers but not to the attention set
+ change(r)
+ .current()
+ .review(
+ ReviewInput.create()
+ .reviewer(user.email())
+ .removeUserFromAttentionSet(user.email(), "reason"));
+ sender.clear();
+
+ // amending a change doesn't send an email when user is not in the attention set.
+ amendChange(r.getChangeId());
+ assertThat(sender.getMessages()).isEmpty();
+ }
+
+ @Test
+ public void attentionSetWithEmailFilterStillReceivesSubmitEmail() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ // Add preference for the user such that they only receive an email on changes that require
+ // their attention.
+ requestScopeOperations.setApiUser(user.id());
+ GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+ prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+ gApi.accounts().self().setPreferences(prefs);
+ requestScopeOperations.setApiUser(admin.id());
+
+ // Add user to reviewers but not to the attention set
+ change(r)
+ .current()
+ .review(
+ ReviewInput.approve()
+ .reviewer(user.email())
+ .removeUserFromAttentionSet(user.email(), "reason"));
+ sender.clear();
+
+ // submitting the change sends an email even when user is not in the attention set.
+ change(r).current().submit();
+ assertThat(sender.getMessages()).isNotEmpty();
+ }
+
+ @Test
+ public void attentionSetWithEmailFilterImpactingOnlyChangeEmails() throws Exception {
+ // Add preference for the user such that they only receive an email on changes that require
+ // their attention.
+ requestScopeOperations.setApiUser(user.id());
+ GeneralPreferencesInfo prefs = gApi.accounts().self().getPreferences();
+ prefs.emailStrategy = EmailStrategy.ATTENTION_SET_ONLY;
+ gApi.accounts().self().setPreferences(prefs);
+ requestScopeOperations.setApiUser(admin.id());
+
+ // Ensure emails that don't relate to changes are still sent.
+ gApi.accounts().id(user.id().get()).generateHttpPassword();
+ assertThat(sender.getMessages()).isNotEmpty();
+ }
+
+ @Test
+ public void cannotAddIrrelevantUserToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ String.format(
+ "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+ + "or cc so they can't be added to the attention set",
+ user.email()));
+ }
+
+ @Test
+ public void cannotAddNonExistingUserToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> change(r).addToAttentionSet(new AttentionSetInput("INVALID USER", "reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ "INVALID USER doesn't exist or is not active on the change as an owner,"
+ + " uploader, reviewer, or cc so they can't be added to the attention set");
+ }
+
+ @Test
+ public void cannotRemoveIrrelevantUserToAttentionSet() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> change(r).attention(user.email()).remove(new AttentionSetInput("reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ String.format(
+ "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+ + "or cc so they can't be added to the attention set",
+ user.email()));
+ }
+
+ @Test
+ public void cannotRemoveIrrelevantUserToAttentionSetWithUserInInput() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () ->
+ change(r)
+ .attention(user.email())
+ .remove(new AttentionSetInput(user.email(), "reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ String.format(
+ "%s doesn't exist or is not active on the change as an owner, uploader, reviewer, "
+ + "or cc so they can't be added to the attention set",
+ user.email()));
+ }
+
+ @Test
+ public void cannotRemoveNonExistingUser() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> change(r).attention("INVALID USER").remove(new AttentionSetInput("reason")));
+ assertThat(exception.getMessage())
+ .isEqualTo(
+ "INVALID USER doesn't exist or is not active on the change as an owner,"
+ + " uploader, reviewer, or cc so they can't be added to the attention set");
+ }
+
+ @Test
+ public void irrelevantUsersAddedToAttentionSetAreIgnoredOnReply() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ change(r).current().review(ReviewInput.create().addUserToAttentionSet(user.email(), "reason"));
+ assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
+ }
+
+ @Test
+ public void newReviewerCanBeAddedToTheAttentionSetManually() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r)
+ .current()
+ .review(
+ ReviewInput.create()
+ .reviewer(user.email())
+ .addUserToAttentionSet(user.email(), "reason")
+ .blockAutomaticAttentionSetRules());
+ assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+ .isEqualTo(Operation.ADD);
+ }
+
+ @Test
+ public void newReviewerCanBeAddedToTheAttentionSetAutomatically() throws Exception {
+ PushOneCommit.Result r = createChange();
+ change(r).current().review(ReviewInput.create().reviewer(user.email()));
+ assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+ .isEqualTo(Operation.ADD);
+ }
+
+ @GerritConfig(name = "accounts.visibility", value = "NONE")
+ public void onReplyCanAddInvisibleUsersToAttentionSetOnVisibleChanges() throws Exception {
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+
+ // admin is invisible to the user, but they can still add them to the attention set since they
+ // see the change.
+ change(r).current().review(ReviewInput.create().addUserToAttentionSet(admin.email(), "reason"));
+ assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+ .isEqualTo(Operation.ADD);
+ }
+
+ @Test
+ public void onReplyNonExistingUsersAreSilentlyIgnored() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ change(r)
+ .current()
+ .review(ReviewInput.create().addUserToAttentionSet("INVALID USER", "reason"));
+ assertThat(getAttentionSetUpdates(r.getChange().getId())).isEmpty();
+ }
+
+ @Test
+ @GerritConfig(name = "accounts.visibility", value = "NONE")
+ public void canModifyAttentionSetForInvisibleUsersOnVisibleChanges() throws Exception {
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+
+ // admin is invisible to the user, but they can still add them to the attention set since they
+ // see the change.
+ change(r).addToAttentionSet(new AttentionSetInput(admin.email(), "reason"));
+ assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+ .isEqualTo(Operation.ADD);
+
+ // admin is invisible to the user, but they can still remove them to the attention set since
+ // they see the change.
+ change(r).attention(admin.id().toString()).remove(new AttentionSetInput("removed"));
+ assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, admin)).operation())
+ .isEqualTo(Operation.REMOVE);
}
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);
+ }
+
+ private ReviewInput reviewInReplyToComment(@Nullable String id) {
+ ReviewInput.CommentInput comment = new ReviewInput.CommentInput();
+ comment.side = Side.REVISION;
+ comment.path = Patch.COMMIT_MSG;
+ comment.message = "comment";
+ comment.updated = TimeUtil.nowTs();
+ comment.inReplyTo = id;
+ ReviewInput reviewInput = new ReviewInput();
+ 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/rest/change/ChangeIncludedInIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
index 47fb20a..dd85cb0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeIncludedInIT.java
@@ -22,8 +22,8 @@
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit.Result;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.projects.TagInput;
import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index 40b6da4..a6bd5eb 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -36,8 +36,8 @@
import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.AddReviewerInput;
import com.google.gerrit.extensions.api.changes.AddReviewerResult;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index 243991b..ed21050 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -26,7 +26,7 @@
import com.google.gerrit.acceptance.TestProjectInput;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.ReviewInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 68e9b14..7fe2a50 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -16,7 +16,7 @@
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
import static com.google.gerrit.entities.RefNames.changeMetaRef;
import static com.google.gerrit.extensions.common.testing.GitPersonSubject.assertThat;
import static com.google.gerrit.git.ObjectIds.abbreviateName;
@@ -36,9 +36,9 @@
import com.google.gerrit.acceptance.UseSystemTime;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.accounts.AccountInput;
import com.google.gerrit.extensions.api.changes.ChangeApi;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index 0099fe6..058a96f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -30,7 +30,7 @@
import com.google.gerrit.acceptance.UseClockStep;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.changes.HashtagsInput;
import com.google.gerrit.extensions.common.ChangeMessageInfo;
import com.google.gerrit.extensions.restapi.AuthException;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index 61dc4d4..def4ed8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -26,8 +26,8 @@
import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java
new file mode 100644
index 0000000..59914bc
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/change/LifecycleListenersIT.java
@@ -0,0 +1,54 @@
+// 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.acceptance.rest.change;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractLifecycleListenersTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+public class LifecycleListenersIT extends AbstractLifecycleListenersTest {
+ @Inject private InvocationCheck invocationCheck;
+
+ @Before
+ public void before() {
+ invocationCheck.setStartInvoked(false);
+ invocationCheck.setStopInvoked(false);
+ }
+
+ @Test
+ public void lifecycleListenerSuccessfulInvocation() throws Exception {
+ try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+ RestResponse response = adminRestSession.get("/changes/?--my-plugin--opt&q=status:open");
+ response.assertOK();
+ assertTrue(invocationCheck.isStartInvoked());
+ assertTrue(invocationCheck.isStopInvoked());
+ }
+ }
+
+ @Test
+ public void lifecycleListenerUnsuccessfulInvocation() throws Exception {
+ try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+ RestResponse response = adminRestSession.get("/projects/");
+ response.assertOK();
+ assertFalse(invocationCheck.isStartInvoked());
+ assertFalse(invocationCheck.isStopInvoked());
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 542085c..d5881ea 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -28,10 +28,10 @@
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.changes.MoveInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.projects.BranchInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
index 7649316..17bf37e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/PluginFieldsIT.java
@@ -22,9 +22,11 @@
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
import com.google.gerrit.json.OutputFormat;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
+import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.junit.Test;
@@ -68,6 +70,24 @@
}
@Test
+ public void querySingleChangeWithBulkAttribute() throws Exception {
+ getSingleChangeWithPluginDefinedBulkAttribute(
+ id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))));
+ }
+
+ @Test
+ public void pluginDefinedGetChangeWithSimpleAttribute() throws Exception {
+ getSingleChangeWithPluginDefinedBulkAttribute(
+ id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))));
+ }
+
+ @Test
+ public void pluginDefinedGetChangeDetailWithSimpleAttribute() throws Exception {
+ getSingleChangeWithPluginDefinedBulkAttribute(
+ id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id))));
+ }
+
+ @Test
public void queryChangeWithOption() throws Exception {
getChangeWithOption(
id -> pluginInfoFromSingletonList(adminRestSession.get(changeQueryUrl(id))),
@@ -88,6 +108,57 @@
(id, opts) -> pluginInfoFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
}
+ @Test
+ public void pluginDefinedQueryChangeWithOption() throws Exception {
+ getChangeWithPluginDefinedBulkAttributeOption(
+ id -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id))),
+ (id, opts) -> pluginInfosFromChangeInfos(adminRestSession.get(changeQueryUrl(id, opts))));
+ }
+
+ @Test
+ public void pluginDefinedGetChangeWithOption() throws Exception {
+ getChangeWithPluginDefinedBulkAttributeOption(
+ id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))),
+ (id, opts) -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id, opts))));
+ }
+
+ @Test
+ public void pluginDefinedGetChangeDetailWithOption() throws Exception {
+ getChangeWithPluginDefinedBulkAttributeOption(
+ id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id))),
+ (id, opts) -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeDetailUrl(id, opts))));
+ }
+
+ @Test
+ public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+ getMultipleChangesWithPluginDefinedBulkAttribute(
+ () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+ }
+
+ @Test
+ public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+ getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+ () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+ }
+
+ @Test
+ public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+ getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+ () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+ }
+
+ @Test
+ public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+ getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+ () -> pluginInfosFromChangeInfos(adminRestSession.get("/changes/?q=status:open")));
+ }
+
+ @Test
+ public void getChangeWithPluginDefinedException() throws Exception {
+ getChangeWithPluginDefinedBulkAttributeWithException(
+ id -> pluginInfoMapFromChangeInfo(adminRestSession.get(changeUrl(id))));
+ }
+
private String changeQueryUrl(Change.Id id) {
return changeQueryUrl(id, ImmutableListMultimap.of());
}
@@ -133,7 +204,8 @@
}
@Nullable
- private static List<MyInfo> pluginInfoFromSingletonList(RestResponse res) throws Exception {
+ private static List<PluginDefinedInfo> pluginInfoFromSingletonList(RestResponse res)
+ throws Exception {
res.assertOK();
List<Map<String, Object>> changeInfos =
GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
@@ -142,10 +214,28 @@
}
@Nullable
- private List<MyInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
+ private List<PluginDefinedInfo> pluginInfoFromChangeInfo(RestResponse res) throws Exception {
res.assertOK();
Map<String, Object> changeInfo =
GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
return decodeRawPluginsList(GSON, changeInfo.get("plugins"));
}
+
+ @Nullable
+ private Map<Change.Id, List<PluginDefinedInfo>> pluginInfoMapFromChangeInfo(RestResponse res)
+ throws Exception {
+ res.assertOK();
+ Map<String, Object> changeInfo =
+ GSON.fromJson(res.getReader(), new TypeToken<Map<String, Object>>() {}.getType());
+ return getPluginInfosFromChangeInfos(GSON, Arrays.asList(changeInfo));
+ }
+
+ @Nullable
+ private Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromChangeInfos(RestResponse res)
+ throws Exception {
+ res.assertOK();
+ List<Map<String, Object>> changeInfos =
+ GSON.fromJson(res.getReader(), new TypeToken<List<Map<String, Object>>>() {}.getType());
+ return getPluginInfosFromChangeInfos(GSON, changeInfos);
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index 6f519f1..5bcf995 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -25,6 +25,7 @@
import com.google.gerrit.acceptance.TestProjectInput;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.api.changes.SubmitInput;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -373,4 +374,27 @@
change2.getChangeId(),
headAfterFirstSubmit.name());
}
+
+ @Test
+ public void dependencyOnOutdatedPatchSetNotPreventingCherryPick() throws Throwable {
+ // Create a change
+ PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+ PushOneCommit.Result changeResult = change.to("refs/for/master");
+ PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+ // Create a successor change.
+ PushOneCommit change2 =
+ pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+ PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+ // Create new patch set for first change.
+ testRepo.reset(changeResult.getCommit().name());
+ amendChange(changeResult.getChangeId());
+
+ // Approve both changes
+ approve(changeResult.getChangeId());
+ approve(change2Result.getChangeId());
+
+ assertSubmittable(change2Result.getChangeId());
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 670cff2..66eb48c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -21,8 +21,9 @@
import com.google.gerrit.acceptance.GitUtil;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.ActionInfo;
import com.google.inject.Inject;
@@ -170,4 +171,49 @@
assertRefUpdatedEvents(initialHead, headAfterSubmit);
assertChangeMergedEvents(id1, headAfterSubmit.name());
}
+
+ @Test
+ public void dependencyOnOutdatedPatchSetPreventsFastForward() throws Throwable {
+ // Create a change
+ PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
+ PushOneCommit.Result changeResult = change.to("refs/for/master");
+ PatchSet.Id patchSetId = changeResult.getPatchSetId();
+
+ // Create a successor change.
+ PushOneCommit change2 =
+ pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
+ PushOneCommit.Result change2Result = change2.to("refs/for/master");
+
+ // Create new patch set for first change.
+ testRepo.reset(changeResult.getCommit().name());
+ amendChange(changeResult.getChangeId());
+
+ // Approve both changes
+ approve(changeResult.getChangeId());
+ approve(change2Result.getChangeId());
+
+ // submit button is disabled.
+ assertSubmitDisabled(change2Result.getChangeId());
+
+ submitWithConflict(
+ change2Result.getChangeId(),
+ "Failed to submit 2 changes due to the following problems:\n"
+ + "Change "
+ + change2Result.getChange().getId()
+ + ": Depends on change that was not submitted."
+ + " Commit "
+ + change2Result.getCommit().name()
+ + " depends on commit "
+ + changeResult.getCommit().name()
+ + ", which is outdated patch set "
+ + patchSetId.get()
+ + " of change "
+ + changeResult.getChange().getId()
+ + ". The latest patch set is "
+ + changeResult.getPatchSetId().get()
+ + ".");
+
+ assertRefUpdatedEvents();
+ assertChangeMergedEvents();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
index b259d90..995de0d 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByMergeIfNecessaryIT.java
@@ -19,7 +19,7 @@
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
@@ -28,9 +28,8 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.BranchNameKey;
-import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.ChangeApi;
import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -534,48 +533,6 @@
}
@Test
- public void dependencyOnOutdatedPatchSetPreventsMerge() throws Throwable {
- // Create a change
- PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
- PushOneCommit.Result changeResult = change.to("refs/for/master");
- PatchSet.Id patchSetId = changeResult.getPatchSetId();
-
- // Create a successor change.
- PushOneCommit change2 =
- pushFactory.create(user.newIdent(), testRepo, "feature", "b.txt", "bar");
- PushOneCommit.Result change2Result = change2.to("refs/for/master");
-
- // Create new patch set for first change.
- testRepo.reset(changeResult.getCommit().name());
- amendChange(changeResult.getChangeId());
-
- // Approve both changes
- approve(changeResult.getChangeId());
- approve(change2Result.getChangeId());
-
- submitWithConflict(
- change2Result.getChangeId(),
- "Failed to submit 2 changes due to the following problems:\n"
- + "Change "
- + change2Result.getChange().getId()
- + ": Depends on change that was not submitted."
- + " Commit "
- + change2Result.getCommit().name()
- + " depends on commit "
- + changeResult.getCommit().name()
- + ", which is outdated patch set "
- + patchSetId.get()
- + " of change "
- + changeResult.getChange().getId()
- + ". The latest patch set is "
- + changeResult.getPatchSetId().get()
- + ".");
-
- assertRefUpdatedEvents();
- assertChangeMergedEvents();
- }
-
- @Test
public void dependencyOnDeletedChangePreventsMerge() throws Throwable {
// Create a change
PushOneCommit change = pushFactory.create(user.newIdent(), testRepo, "fix", "a.txt", "foo");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 551a349..888878f 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -19,7 +19,7 @@
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static java.util.stream.Collectors.toList;
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
index a3c1722..614ce80 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/IndexChangesIT.java
@@ -24,7 +24,7 @@
import com.google.gerrit.acceptance.ExtensionRegistry;
import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.server.restapi.config.IndexChanges;
import com.google.inject.Inject;
diff --git a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
index d8132b7..4453345 100644
--- a/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/group/ListGroupsIT.java
@@ -19,6 +19,7 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gson.reflect.TypeToken;
import java.util.Map;
import org.junit.Test;
@@ -32,6 +33,7 @@
Map<String, GroupInfo> groupMap =
newGson()
.fromJson(response.getReader(), new TypeToken<Map<String, GroupInfo>>() {}.getType());
- assertThat(groupMap.keySet()).containsExactly("Administrators", "Non-Interactive Users");
+ assertThat(groupMap.keySet())
+ .containsExactly("Administrators", ServiceUserClassifier.SERVICE_USERS);
}
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
index d70d120..191d5c5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AbstractPushTag.java
@@ -29,7 +29,7 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.GitUtil;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.inject.Inject;
import org.eclipse.jgit.lib.PersonIdent;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 5f17e87..33d0d29 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -31,10 +31,10 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.access.AccessSectionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/BUILD b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
index 54ae5af..5e1fc83 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/project/BUILD
@@ -21,6 +21,7 @@
],
deps = [
"//java/com/google/gerrit/common:server",
+ "//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/server",
"//lib/truth",
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
index b01a07b..096c72b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateBranchIT.java
@@ -29,10 +29,10 @@
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.projects.BranchApi;
import com.google.gerrit.extensions.api.projects.BranchInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index e5587a9..94511f8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -25,8 +25,8 @@
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
import com.google.gerrit.extensions.common.LabelDefinitionInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
index 5636014..c98a58e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchIT.java
@@ -26,8 +26,8 @@
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.projects.BranchApi;
import com.google.gerrit.extensions.api.projects.BranchInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
index ad90109..98fc020 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteBranchesIT.java
@@ -28,7 +28,7 @@
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index c916285..57c7b17 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -22,7 +22,7 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
index 9770031..7e60395 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteTagIT.java
@@ -26,7 +26,7 @@
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.projects.TagApi;
import com.google.gerrit.extensions.api.projects.TagInfo;
import com.google.gerrit.extensions.api.projects.TagInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
index 1b1a36d..5bd0e25 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetCommitIT.java
@@ -24,7 +24,7 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.common.CommitInfo;
import com.google.inject.Inject;
import org.eclipse.jgit.junit.TestRepository;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
index 3e35f04..a2c5c64 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/GetLabelIT.java
@@ -21,7 +21,7 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.entities.LabelFunction;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
index 65e352b..201bb53 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/LabelAssert.java
@@ -16,7 +16,7 @@
import static com.google.common.truth.Truth.assertThat;
-import com.google.gerrit.common.data.LabelFunction;
+import com.google.gerrit.entities.LabelFunction;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
import com.google.gerrit.server.config.AllProjectsNameProvider;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
index 91a2c4b..f8be28b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListBranchesIT.java
@@ -25,7 +25,7 @@
import com.google.gerrit.acceptance.TestProjectInput;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.projects.BranchInfo;
import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
index 33a0654..d39c96e 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListLabelsIT.java
@@ -26,8 +26,8 @@
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
index bb08267..2e274d9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ListProjectsIT.java
@@ -32,7 +32,7 @@
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.projects.ConfigInfo;
import com.google.gerrit.extensions.api.projects.ConfigInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
index e7663f7..93b1f12 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PluginAccessIT.java
@@ -18,7 +18,7 @@
import com.google.common.collect.ImmutableMap;
import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.extensions.api.access.AccessSectionInfo;
import com.google.gerrit.extensions.api.access.PermissionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
index 9e6b051..ba52024 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/PostLabelsIT.java
@@ -24,8 +24,8 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.common.BatchLabelInput;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
index c3891cf..5fd55ec 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/ProjectLevelConfigIT.java
@@ -173,4 +173,16 @@
assertThat(state.getConfig(configName).get().toText()).isEqualTo(cfg.toText());
}
+
+ @Test
+ public void brokenConfigDoesNotBlockPush() throws Exception {
+ String configName = "test.config";
+ PushOneCommit push =
+ pushFactory.create(
+ admin.newIdent(), testRepo, "Create Project Level Config", configName, "\\\\///");
+ push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+ ProjectState state = projectCache.get(project).get();
+ assertThat(state.getConfig(configName).get().toText()).isEmpty();
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index a1817d9..1e8d978 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -25,8 +25,8 @@
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.common.LabelDefinitionInfo;
import com.google.gerrit.extensions.common.LabelDefinitionInput;
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
index 3d3865a..b1879f6 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/TagsIT.java
@@ -28,8 +28,8 @@
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
import com.google.gerrit.extensions.api.projects.TagApi;
import com.google.gerrit.extensions.api.projects.TagInfo;
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index e924143..c7beb2d 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -171,6 +171,33 @@
}
@Test
+ public void byUserNameOrFullNameOrEmailExact() throws Exception {
+ String userName = "UserFoo";
+ String fullName = "Name Foo";
+ String email = "emailfoo@something.com";
+ Account.Id id =
+ accountOperations
+ .newAccount()
+ .username(userName)
+ .fullname(fullName)
+ .preferredEmail(email)
+ .create();
+
+ // resolver returns results for exact matches
+ assertThat(resolveByExactNameOrEmail(userName)).containsExactly(id);
+ assertThat(resolveByExactNameOrEmail(fullName)).containsExactly(id);
+ assertThat(resolveByExactNameOrEmail(email)).containsExactly(id);
+
+ // resolver does not match with prefixes
+ assertThat(resolveByExactNameOrEmail("UserF")).isEmpty();
+ assertThat(resolveByExactNameOrEmail("Name F")).isEmpty();
+ assertThat(resolveByExactNameOrEmail("emailf")).isEmpty();
+
+ /* The default name/email resolver accepts prefix matches */
+ assertThat(resolveByNameOrEmail("emai")).containsExactly(id);
+ }
+
+ @Test
public void byNameAndEmailPrefersAccountsWithMatchingFullName() throws Exception {
String email = name("user@example.com");
Account.Id id1 = accountOperations.newAccount().fullname("Aaa Bbb").create();
@@ -334,6 +361,11 @@
return accountResolver.resolveByNameOrEmail(input.toString()).asIdSet();
}
+ @SuppressWarnings("deprecation")
+ private ImmutableSet<Account.Id> resolveByExactNameOrEmail(Object input) throws Exception {
+ return accountResolver.resolveByExactNameOrEmail(input.toString()).asIdSet();
+ }
+
private void setPreferredEmailBypassingUniquenessCheck(Account.Id id, String email)
throws Exception {
Optional<AccountState> result =
diff --git a/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
new file mode 100644
index 0000000..df84fd7
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/ServiceUserClassifierIT.java
@@ -0,0 +1,98 @@
+// 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.acceptance.server.account;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.server.account.ListGroupMembership;
+import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.account.UniversalGroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class ServiceUserClassifierIT extends AbstractDaemonTest {
+ @Inject private GroupOperations groupOperations;
+ @Inject private ServiceUserClassifier serviceUserClassifier;
+ @Inject private UniversalGroupBackend universalGroupBackend;
+ @Inject private ExtensionRegistry extensionRegistry;
+
+ @Test
+ public void userWithoutMembershipInServiceUserIsNotAServiceUser() throws Exception {
+ TestAccount user = accountCreator.create();
+ assertThat(serviceUserClassifier.isServiceUser(user.id())).isFalse();
+ }
+
+ @Test
+ public void userWithDirectMembershipInServiceUserIsAServiceUser() throws Exception {
+ TestAccount user = accountCreator.create(null, ServiceUserClassifier.SERVICE_USERS);
+ assertThat(serviceUserClassifier.isServiceUser(user.id())).isTrue();
+ }
+
+ @Test
+ public void userWithIndirectMembershipInServiceUserIsAServiceUser() throws Exception {
+ TestAccount user = accountCreator.create();
+ AccountGroup.UUID subGroupUUID =
+ groupOperations.newGroup().name("CI Service Users").addMember(user.id()).create();
+ groupOperations.group(serviceUsersUUID()).forUpdate().addSubgroup(subGroupUUID).update();
+ assertThat(serviceUserClassifier.isServiceUser(user.id())).isTrue();
+ }
+
+ @Test
+ public void userWithIndirectExternalMembershipInServiceUserIsAServiceUser() throws Exception {
+ TestGroupBackend testGroupBackend = new TestGroupBackend();
+ TestAccount user = accountCreator.create();
+ GroupDescription.Basic externalServiceUsers = testGroupBackend.create("External Service Users");
+
+ try (ExtensionRegistry.Registration registration =
+ extensionRegistry.newRegistration().add(testGroupBackend)) {
+ assertThat(universalGroupBackend.handles(externalServiceUsers.getGroupUUID())).isTrue();
+ assertThat(serviceUserClassifier.isServiceUser(user.id())).isFalse();
+
+ groupOperations
+ .group(serviceUsersUUID())
+ .forUpdate()
+ .addSubgroup(externalServiceUsers.getGroupUUID())
+ .update();
+ testGroupBackend.setMembershipsOf(
+ user.id(),
+ new ListGroupMembership(ImmutableList.of(externalServiceUsers.getGroupUUID())));
+ assertThat(serviceUserClassifier.isServiceUser(user.id())).isTrue();
+ }
+ }
+
+ @Test
+ public void cyclicSubgroupsDontCauseInfiniteLoop() throws Exception {
+ TestAccount user = accountCreator.create();
+ AccountGroup.UUID subGroupUUID = groupOperations.newGroup().name("CI Service Users").create();
+ groupOperations.group(serviceUsersUUID()).forUpdate().addSubgroup(subGroupUUID).update();
+ groupOperations.group(subGroupUUID).forUpdate().addSubgroup(serviceUsersUUID()).update();
+ assertThat(serviceUserClassifier.isServiceUser(user.id())).isFalse();
+ }
+
+ private AccountGroup.UUID serviceUsersUUID() {
+ return groupCache
+ .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
+ .orElseThrow(() -> new IllegalStateException("unable to find 'Service Users'"))
+ .getGroupUUID();
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 15f1a6a..2c42d0a 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -14,10 +14,12 @@
package com.google.gerrit.acceptance.server.change;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth8.assertThat;
import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
+import static com.google.gerrit.entities.Patch.COMMIT_MSG;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static com.google.gerrit.truth.MapSubject.assertThatMap;
@@ -28,14 +30,20 @@
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
+import com.google.common.collect.MoreCollectors;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.change.TestHumanComment;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -46,12 +54,12 @@
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.ContextLineInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.RevisionResource;
import com.google.gerrit.server.notedb.ChangeNoteUtil;
@@ -70,9 +78,9 @@
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
-import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import java.util.stream.Collectors;
import org.eclipse.jgit.lib.ObjectReader;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
@@ -88,8 +96,9 @@
@Inject private ProjectOperations projectOperations;
@Inject private Provider<ChangesCollection> changes;
@Inject private Provider<PostReview> postReview;
+ @Inject private ChangeOperations changeOperations;
+ @Inject private AccountOperations accountOperations;
@Inject private RequestScopeOperations requestScopeOperations;
- @Inject private CommentsUtil commentsUtil;
private final Integer[] lines = {0, 1};
@@ -648,6 +657,34 @@
}
@Test
+ public void putDraft_humanInReplyTo() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().create();
+
+ DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+ draft.inReplyTo = parentCommentUuid;
+ String createdDraftUuid = addDraft(changeId, draft).id;
+ TestHumanComment actual =
+ changeOperations.change(changeId).draftComment(createdDraftUuid).get();
+ assertThat(actual.parentUuid()).hasValue(parentCommentUuid);
+ }
+
+ @Test
+ public void putDraft_robotInReplyTo() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentRobotCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+ DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+ draft.inReplyTo = parentRobotCommentUuid;
+ String createdDraftUuid = addDraft(changeId, draft).id;
+ TestHumanComment actual =
+ changeOperations.change(changeId).draftComment(createdDraftUuid).get();
+ assertThat(actual.parentUuid()).hasValue(parentRobotCommentUuid);
+ }
+
+ @Test
public void putDraft_idMismatch() throws Exception {
String file = "file";
PushOneCommit.Result r = createChange();
@@ -692,6 +729,16 @@
}
@Test
+ public void putDraft_invalidInReplyTo() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ DraftInput draft = newDraft(COMMIT_MSG, Side.REVISION, 0, "foo");
+ draft.inReplyTo = "invalid";
+ BadRequestException exception =
+ assertThrows(BadRequestException.class, () -> addDraft(changeId, draft));
+ assertThat(exception.getMessage()).contains(String.format("%s not found", draft.inReplyTo));
+ }
+
+ @Test
public void putDraft_updatePath() throws Exception {
PushOneCommit.Result r = createChange();
String changeId = r.getChangeId();
@@ -705,22 +752,93 @@
}
@Test
- public void putDraft_updateInReplyToAndTag() throws Exception {
- PushOneCommit.Result r = createChange();
- String changeId = r.getChangeId();
- String revId = r.getCommit().getName();
- DraftInput draftInput1 = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
- CommentInfo commentInfo = addDraft(changeId, revId, draftInput1);
- DraftInput draftInput2 = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
- String inReplyTo = "in_reply_to";
+ public void putDraft_updateInvalidInReplyTo() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+ CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
+
+ DraftInput updatedDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+ updatedDraftInput.inReplyTo = "invalid";
+ BadRequestException exception =
+ assertThrows(
+ BadRequestException.class,
+ () -> updateDraft(changeId, updatedDraftInput, originalDraft.id));
+ assertThat(exception.getMessage()).contains(String.format("Invalid inReplyTo"));
+ }
+
+ @Test
+ public void putDraft_updateHumanInReplyTo() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().create();
+ DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+ CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
+
+ DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+ updateDraftInput.inReplyTo = parentCommentUuid;
+ updateDraft(changeId, updateDraftInput, originalDraft.id);
+ assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().parentUuid())
+ .hasValue(parentCommentUuid);
+ }
+
+ @Test
+ public void putDraft_updateRobotInReplyTo() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentRobotCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+ DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+ CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
+
+ DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+ updateDraftInput.inReplyTo = parentRobotCommentUuid;
+ updateDraft(changeId, updateDraftInput, originalDraft.id);
+ assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().parentUuid())
+ .hasValue(parentRobotCommentUuid);
+ }
+
+ @Test
+ public void putDraft_updateTag() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ DraftInput originalDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "foo");
+ CommentInfo originalDraft = addDraft(changeId, originalDraftInput);
+
+ DraftInput updateDraftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
String tag = "täg";
- draftInput2.inReplyTo = inReplyTo;
- draftInput2.tag = tag;
- updateDraft(changeId, revId, draftInput2, commentInfo.id);
- com.google.gerrit.entities.Comment comment =
- Iterables.getOnlyElement(commentsUtil.draftByChange(r.getChange().notes()));
- assertThat(comment.parentUuid).isEqualTo(inReplyTo);
- assertThat(comment.tag).isEqualTo(tag);
+ updateDraftInput.tag = tag;
+ updateDraft(changeId, updateDraftInput, originalDraft.id);
+ assertThat(changeOperations.change(changeId).draftComment(originalDraft.id).get().tag())
+ .hasValue(tag);
+ }
+
+ @Test
+ public void updatedDraftStillPointsToParentComment() throws Exception {
+ Account.Id accountId = accountOperations.newAccount().create();
+ Change.Id changeId = changeOperations.newChange().create();
+ PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ String parentCommentUuid =
+ changeOperations.change(changeId).patchset(patchsetId).newComment().create();
+ String draftCommentUuid =
+ changeOperations
+ .change(changeId)
+ .patchset(patchsetId)
+ .newDraftComment()
+ .parentUuid(parentCommentUuid)
+ .author(accountId)
+ .create();
+
+ // Each user can only see their own drafts.
+ requestScopeOperations.setApiUser(accountId);
+ DraftInput draftInput = newDraft(FILE_NAME, Side.REVISION, 0, "bar");
+ draftInput.message = "Another comment text.";
+ gApi.changes()
+ .id(changeId.get())
+ .revision(patchsetId.get())
+ .draft(draftCommentUuid)
+ .update(draftInput);
+
+ TestHumanComment comment =
+ changeOperations.change(changeId).draftComment(draftCommentUuid).get();
+ assertThat(comment.parentUuid()).hasValue(parentCommentUuid);
}
@Test
@@ -903,12 +1021,16 @@
addComment(r1, "nit: trailing whitespace");
addComment(r2, "typo: content");
- Map<String, List<CommentInfo>> actual = gApi.changes().id(r2.getChangeId()).comments();
+ Map<String, List<CommentInfo>> actual =
+ gApi.changes().id(r2.getChangeId()).commentsRequest().get();
assertThat(actual.keySet()).containsExactly(FILE_NAME);
List<CommentInfo> comments = actual.get(FILE_NAME);
assertThat(comments).hasSize(2);
+ // Comment context is disabled by default
+ assertThat(comments.stream().filter(c -> c.contextLines != null)).isEmpty();
+
CommentInfo c1 = comments.get(0);
assertThat(c1.author._accountId).isEqualTo(user.id().get());
assertThat(c1.patchSet).isEqualTo(1);
@@ -925,6 +1047,131 @@
}
@Test
+ public void listChangeCommentsWithContextEnabled() throws Exception {
+ PushOneCommit.Result r1 = createChange();
+
+ ImmutableList.Builder<String> content = ImmutableList.builder();
+ for (int i = 1; i <= 10; i++) {
+ content.add("line_" + i);
+ }
+
+ PushOneCommit.Result r2 =
+ pushFactory
+ .create(
+ admin.newIdent(),
+ testRepo,
+ SUBJECT,
+ FILE_NAME,
+ content.build().stream().collect(Collectors.joining("\n")),
+ r1.getChangeId())
+ .to("refs/for/master");
+
+ addCommentOnLine(r2, "nit: please fix", 1);
+ addCommentOnRange(r2, "looks good", commentRangeInLines(2, 5));
+
+ List<CommentInfo> comments =
+ gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+ assertThat(comments).hasSize(2);
+
+ assertThat(
+ comments.stream()
+ .filter(c -> c.message.equals("nit: please fix"))
+ .collect(MoreCollectors.onlyElement())
+ .contextLines)
+ .containsExactlyElementsIn(contextLines("1", "line_1"));
+
+ assertThat(
+ comments.stream()
+ .filter(c -> c.message.equals("looks good"))
+ .collect(MoreCollectors.onlyElement())
+ .contextLines)
+ .containsExactlyElementsIn(
+ contextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+ }
+
+ @Test
+ public void commentContextForCommentsOnDifferentPatchsets() throws Exception {
+ PushOneCommit.Result r1 = createChange();
+
+ ImmutableList.Builder<String> content = ImmutableList.builder();
+ for (int i = 1; i <= 10; i++) {
+ content.add("line_" + i);
+ }
+
+ PushOneCommit.Result r2 =
+ pushFactory
+ .create(
+ admin.newIdent(),
+ testRepo,
+ SUBJECT,
+ FILE_NAME,
+ String.join("\n", content.build()),
+ r1.getChangeId())
+ .to("refs/for/master");
+
+ PushOneCommit.Result r3 =
+ pushFactory
+ .create(
+ admin.newIdent(),
+ testRepo,
+ SUBJECT,
+ FILE_NAME,
+ content.build().stream().collect(Collectors.joining("\n")),
+ r1.getChangeId())
+ .to("refs/for/master");
+
+ addCommentOnLine(r2, "r2: please fix", 1);
+ addCommentOnRange(r2, "r2: looks good", commentRangeInLines(2, 3));
+ addCommentOnLine(r3, "r3: please fix", 6);
+ addCommentOnRange(r3, "r3: looks good", commentRangeInLines(7, 8));
+
+ List<CommentInfo> comments =
+ gApi.changes().id(r2.getChangeId()).commentsRequest().withContext(true).getAsList();
+
+ assertThat(comments).hasSize(4);
+
+ assertThat(
+ comments.stream()
+ .filter(c -> c.message.equals("r2: please fix"))
+ .collect(MoreCollectors.onlyElement())
+ .contextLines)
+ .containsExactlyElementsIn(contextLines("1", "line_1"));
+
+ assertThat(
+ comments.stream()
+ .filter(c -> c.message.equals("r2: looks good"))
+ .collect(MoreCollectors.onlyElement())
+ .contextLines)
+ .containsExactlyElementsIn(contextLines("2", "line_2", "3", "line_3"));
+
+ assertThat(
+ comments.stream()
+ .filter(c -> c.message.equals("r3: please fix"))
+ .collect(MoreCollectors.onlyElement())
+ .contextLines)
+ .containsExactlyElementsIn(contextLines("6", "line_6"));
+
+ assertThat(
+ comments.stream()
+ .filter(c -> c.message.equals("r3: looks good"))
+ .collect(MoreCollectors.onlyElement())
+ .contextLines)
+ .containsExactlyElementsIn(contextLines("7", "line_7", "8", "line_8"));
+ }
+
+ private List<ContextLineInfo> contextLines(String... args) {
+ List<ContextLineInfo> result = new ArrayList<>();
+ for (int i = 0; i < args.length; i += 2) {
+ int lineNbr = Integer.parseInt(args[i]);
+ String contextLine = args[i + 1];
+ ContextLineInfo info = new ContextLineInfo(lineNbr, contextLine);
+ result.add(info);
+ }
+ return result;
+ }
+
+ @Test
public void listChangeCommentsAnonymousDoesNotRequireAuth() throws Exception {
PushOneCommit.Result r1 = createChange();
@@ -936,12 +1183,12 @@
addComment(r1, "nit: trailing whitespace");
addComment(r2, "typo: content");
- List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+ List<CommentInfo> comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
assertThat(comments.stream().map(c -> c.message).collect(toList()))
.containsExactly("nit: trailing whitespace", "typo: content");
requestScopeOperations.setApiUserAnonymous();
- comments = gApi.changes().id(r1.getChangeId()).commentsAsList();
+ comments = gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList();
assertThat(comments.stream().map(c -> c.message).collect(toList()))
.containsExactly("nit: trailing whitespace", "typo: content");
}
@@ -1062,12 +1309,6 @@
+ "\n"
+ "comments\n"
+ "\n"
- + url
- + "c/"
- + project.get()
- + "/+/"
- + c
- + "/1/a.txt \n"
+ "File a.txt:\n"
+ "\n"
+ url
@@ -1075,7 +1316,9 @@
+ project.get()
+ "/+/"
+ c
- + "/1/a.txt@a1 \n"
+ + "/comment/"
+ + ps1List.get(0).id
+ + " \n"
+ "PS1, Line 1: initial\n"
+ "what happened to this?\n"
+ "\n"
@@ -1085,17 +1328,13 @@
+ project.get()
+ "/+/"
+ c
- + "/1/a.txt@1 \n"
+ + "/comment/"
+ + ps1List.get(1).id
+ + " \n"
+ "PS1, Line 1: boring\n"
+ "Is it that bad?\n"
+ "\n"
+ "\n"
- + url
- + "c/"
- + project.get()
- + "/+/"
- + c
- + "/2/a.txt \n"
+ "File a.txt:\n"
+ "\n"
+ url
@@ -1103,7 +1342,9 @@
+ project.get()
+ "/+/"
+ c
- + "/2/a.txt@a1 \n"
+ + "/comment/"
+ + ps2List.get(0).id
+ + " \n"
+ "PS2, Line 1: initial content\n"
+ "comment 1 on base\n"
+ "\n"
@@ -1113,7 +1354,9 @@
+ project.get()
+ "/+/"
+ c
- + "/2/a.txt@a2 \n"
+ + "/comment/"
+ + ps2List.get(1).id
+ + " \n"
+ "PS2, Line 2: \n"
+ "comment 2 on base\n"
+ "\n"
@@ -1123,7 +1366,9 @@
+ project.get()
+ "/+/"
+ c
- + "/2/a.txt@1 \n"
+ + "/comment/"
+ + ps2List.get(2).id
+ + " \n"
+ "PS2, Line 1: interesting\n"
+ "better now\n"
+ "\n"
@@ -1133,7 +1378,9 @@
+ project.get()
+ "/+/"
+ c
- + "/2/a.txt@2 \n"
+ + "/comment/"
+ + ps2List.get(3).id
+ + " \n"
+ "PS2, Line 2: cntent\n"
+ "typo: content\n"
+ "\n"
@@ -1169,6 +1416,51 @@
}
@Test
+ public void draftCommentsWithTagPublishPatchset() throws Exception {
+ PushOneCommit.Result result = createChange();
+
+ DraftInput draft = newDraft(FILE_NAME, Side.REVISION, 2, "draft");
+ draft.tag = "old_tag";
+ addDraft(result.getChangeId(), result.getCommit().name(), draft);
+
+ ReviewInput reviewInput = new ReviewInput();
+ reviewInput.tag = "new_tag";
+ reviewInput.drafts = DraftHandling.PUBLISH;
+ gApi.changes().id(result.getChangeId()).current().review(reviewInput);
+
+ assertThat(
+ Iterables.getOnlyElement(
+ gApi.changes().id(result.getChangeId()).current().commentsAsList())
+ .tag)
+ .isEqualTo("new_tag");
+ }
+
+ @Test
+ public void draftCommentsWithTagPublishAllRevisions() throws Exception {
+ PushOneCommit.Result result = createChange();
+
+ DraftInput draft = newDraft(FILE_NAME, Side.REVISION, 2, "draft");
+ draft.tag = "old_tag";
+ addDraft(result.getChangeId(), result.getCommit().name(), draft);
+
+ amendChange(result.getChangeId());
+
+ ReviewInput reviewInput = new ReviewInput();
+ reviewInput.tag = "new_tag";
+ reviewInput.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
+ gApi.changes().id(result.getChangeId()).current().review(reviewInput);
+
+ assertThat(
+ Iterables.getOnlyElement(
+ gApi.changes()
+ .id(result.getChangeId())
+ .revision(result.getCommit().name())
+ .commentsAsList())
+ .tag)
+ .isEqualTo("new_tag");
+ }
+
+ @Test
public void queryChangesWithCommentCount() throws Exception {
// PS1 has three comments in three different threads, PS2 has one comment in one thread.
PushOneCommit.Result result = createChange("change 1", FILE_NAME, "content 1");
@@ -1386,6 +1678,77 @@
assertThat(getChangeSortedComments(id.get())).hasSize(3);
}
+ @Test
+ public void canCreateHumanCommentWithRobotCommentAsParentAndUnsetUnresolved() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentRobotCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+ CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+ createdCommentInput.inReplyTo = parentRobotCommentUuid;
+ createdCommentInput.unresolved = null;
+ addComments(changeId, createdCommentInput);
+
+ CommentInfo resultNewComment =
+ Iterables.getOnlyElement(
+ getPublishedCommentsAsList(changeId).stream()
+ .filter(c -> c.message.equals("comment reply"))
+ .collect(toImmutableSet()));
+
+ assertThat(resultNewComment.inReplyTo).isEqualTo(parentRobotCommentUuid);
+
+ // Default unresolved is false.
+ assertThat(resultNewComment.unresolved).isFalse();
+ }
+
+ @Test
+ public void canCreateHumanCommentWithHumanCommentAsParent() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().create();
+
+ CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+ createdCommentInput.inReplyTo = parentCommentUuid;
+ addComments(changeId, createdCommentInput);
+
+ CommentInfo resultNewComment =
+ Iterables.getOnlyElement(
+ getPublishedCommentsAsList(changeId).stream()
+ .filter(c -> c.message.equals("comment reply"))
+ .collect(toImmutableSet()));
+ assertThat(resultNewComment.inReplyTo).isEqualTo(parentCommentUuid);
+ }
+
+ @Test
+ public void canCreateHumanCommentWithRobotCommentAsParent() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentRobotCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+ CommentInput createdCommentInput = newComment(COMMIT_MSG, "comment reply");
+ createdCommentInput.inReplyTo = parentRobotCommentUuid;
+ addComments(changeId, createdCommentInput);
+
+ CommentInfo resultNewComment =
+ Iterables.getOnlyElement(
+ getPublishedCommentsAsList(changeId).stream()
+ .filter(c -> c.message.equals("comment reply"))
+ .collect(toImmutableSet()));
+ assertThat(resultNewComment.inReplyTo).isEqualTo(parentRobotCommentUuid);
+ }
+
+ @Test
+ public void cannotCreateCommentWithInvalidInReplyTo() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ CommentInput comment = newComment(COMMIT_MSG, "comment 1 reply");
+ comment.inReplyTo = "invalid";
+
+ BadRequestException exception =
+ assertThrows(BadRequestException.class, () -> addComments(changeId, comment));
+ assertThat(exception.getMessage()).contains(String.format("%s not found", comment.inReplyTo));
+ }
+
private List<CommentInfo> getRevisionComments(String changeId, String revId) throws Exception {
return getPublishedComments(changeId, revId).values().stream()
.flatMap(List::stream)
@@ -1400,6 +1763,12 @@
return comment;
}
+ private void addComments(Change.Id changeId, CommentInput... commentInputs) throws Exception {
+ ReviewInput input = new ReviewInput();
+ input.comments = Arrays.stream(commentInputs).collect(groupingBy(c -> c.path));
+ gApi.changes().id(changeId.get()).current().review(input);
+ }
+
private void addComments(String changeId, String revision, CommentInput... commentInputs)
throws Exception {
ReviewInput input = new ReviewInput();
@@ -1475,7 +1844,23 @@
}
private void addComment(PushOneCommit.Result r, String message) throws Exception {
- addComment(r, message, false, false, null);
+ addComment(r, message, false, false, null, null, null);
+ }
+
+ private void addCommentOnLine(PushOneCommit.Result r, String message, int line) throws Exception {
+ addComment(r, message, false, false, null, line, null);
+ }
+
+ private void addCommentOnRange(PushOneCommit.Result r, String message, Comment.Range range)
+ throws Exception {
+ addComment(r, message, false, false, null, null, range);
+ }
+
+ private Comment.Range commentRangeInLines(int startLine, int endLine) {
+ Comment.Range range = new Comment.Range();
+ range.startLine = startLine;
+ range.endLine = endLine;
+ return range;
}
private void addComment(
@@ -1485,12 +1870,25 @@
Boolean unresolved,
String inReplyTo)
throws Exception {
+ addComment(r, message, omitDuplicateComments, unresolved, inReplyTo, null, null);
+ }
+
+ private void addComment(
+ PushOneCommit.Result r,
+ String message,
+ boolean omitDuplicateComments,
+ Boolean unresolved,
+ String inReplyTo,
+ Integer line,
+ Comment.Range range)
+ throws Exception {
CommentInput c = new CommentInput();
- c.line = 1;
+ c.line = line == null ? 1 : line;
c.message = message;
c.path = FILE_NAME;
c.unresolved = unresolved;
c.inReplyTo = inReplyTo;
+ c.range = range;
ReviewInput in = newInput(c);
in.omitDuplicateComments = omitDuplicateComments;
gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(in);
@@ -1500,11 +1898,19 @@
return gApi.changes().id(changeId).revision(revId).createDraft(in).get();
}
+ private CommentInfo addDraft(Change.Id changeId, DraftInput in) throws Exception {
+ return gApi.changes().id(changeId.get()).current().createDraft(in).get();
+ }
+
private void updateDraft(String changeId, String revId, DraftInput in, String uuid)
throws Exception {
gApi.changes().id(changeId).revision(revId).draft(uuid).update(in);
}
+ private void updateDraft(Change.Id changeId, DraftInput in, String uuid) throws Exception {
+ gApi.changes().id(changeId.get()).current().draft(uuid).update(in);
+ }
+
private void deleteDraft(String changeId, String revId, String uuid) throws Exception {
gApi.changes().id(changeId).revision(revId).draft(uuid).delete();
}
@@ -1520,7 +1926,11 @@
}
private List<CommentInfo> getPublishedCommentsAsList(String changeId) throws Exception {
- return gApi.changes().id(changeId).commentsAsList();
+ return gApi.changes().id(changeId).commentsRequest().getAsList();
+ }
+
+ private List<CommentInfo> getPublishedCommentsAsList(Change.Id changeId) throws Exception {
+ return gApi.changes().id(changeId.get()).commentsRequest().getAsList();
}
private Map<String, List<CommentInfo>> getDraftComments(String changeId, String revId)
@@ -1542,39 +1952,46 @@
private static CommentInput newCommentWithOnlyMandatoryFields(String path, String message) {
CommentInput c = new CommentInput();
- return populate(c, path, null, null, null, null, message, false);
+ c.unresolved = false;
+ return populate(c, path, null, null, null, null, message);
}
private static CommentInput newComment(
String path, Side side, int line, String message, Boolean unresolved) {
CommentInput c = new CommentInput();
- return populate(c, path, side, null, line, message, unresolved);
+ c.unresolved = unresolved;
+ return populate(c, path, side, null, line, message);
}
private static CommentInput newCommentOnParent(
String path, int parent, int line, String message) {
CommentInput c = new CommentInput();
- return populate(c, path, Side.PARENT, parent, line, message, false);
+ c.unresolved = false;
+ return populate(c, path, Side.PARENT, parent, line, message);
}
private DraftInput newDraft(String path, Side side, int line, String message) {
DraftInput d = new DraftInput();
- return populate(d, path, side, null, line, message, false);
+ d.unresolved = false;
+ return populate(d, path, side, null, line, message);
}
private DraftInput newDraft(String path, Side side, Comment.Range range, String message) {
DraftInput d = new DraftInput();
- return populate(d, path, side, null, range.startLine, range, message, false);
+ d.unresolved = false;
+ return populate(d, path, side, null, range.startLine, range, message);
}
private DraftInput newDraftOnParent(String path, int parent, int line, String message) {
DraftInput d = new DraftInput();
- return populate(d, path, Side.PARENT, parent, line, message, false);
+ d.unresolved = false;
+ return populate(d, path, Side.PARENT, parent, line, message);
}
private DraftInput newDraftWithOnlyMandatoryFields(String path, String message) {
DraftInput d = new DraftInput();
- return populate(d, path, null, null, null, null, message, false);
+ d.unresolved = false;
+ return populate(d, path, null, null, null, null, message);
}
private static <C extends Comment> C populate(
@@ -1584,14 +2001,12 @@
Integer parent,
Integer line,
Comment.Range range,
- String message,
- Boolean unresolved) {
+ String message) {
c.path = path;
c.side = side;
c.parent = parent;
c.line = line != null && line != 0 ? line : null;
c.message = message;
- c.unresolved = unresolved;
if (range != null) {
c.range = range;
}
@@ -1599,8 +2014,8 @@
}
private static <C extends Comment> C populate(
- C c, String path, Side side, Integer parent, int line, String message, Boolean unresolved) {
- return populate(c, path, side, parent, line, null, message, unresolved);
+ C c, String path, Side side, Integer parent, int line, String message) {
+ return populate(c, path, side, parent, line, null, message);
}
private static Comment.Range createLineRange(int startChar, int endChar) {
@@ -1613,20 +2028,22 @@
}
private static Function<CommentInfo, CommentInput> infoToInput(String path) {
- return infoToInput(path, CommentInput::new);
+ return info -> {
+ CommentInput commentInput = new CommentInput();
+ commentInput.path = path;
+ commentInput.unresolved = info.unresolved;
+ copy(info, commentInput);
+ return commentInput;
+ };
}
private static Function<CommentInfo, DraftInput> infoToDraft(String path) {
- return infoToInput(path, DraftInput::new);
- }
-
- private static <I extends Comment> Function<CommentInfo, I> infoToInput(
- String path, Supplier<I> supplier) {
return info -> {
- I i = supplier.get();
- i.path = path;
- copy(info, i);
- return i;
+ DraftInput draftInput = new DraftInput();
+ draftInput.path = path;
+ draftInput.unresolved = info.unresolved;
+ copy(info, draftInput);
+ return draftInput;
};
}
@@ -1636,7 +2053,6 @@
to.line = from.line;
to.message = from.message;
to.range = from.range;
- to.unresolved = from.unresolved;
to.inReplyTo = from.inReplyTo;
}
}
diff --git a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
index 17eb534..002b860 100644
--- a/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/event/CommentAddedEventIT.java
@@ -27,7 +27,7 @@
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
index d68cada..1a01184 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -22,20 +22,28 @@
import static org.mockito.MockitoAnnotations.initMocks;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.PushOneCommit.Result;
import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
import com.google.gerrit.extensions.api.changes.DraftInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.config.FactoryModule;
import com.google.gerrit.extensions.validators.CommentForValidation;
import com.google.gerrit.extensions.validators.CommentValidationContext;
import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.testing.FakeEmailSender;
import com.google.gerrit.testing.TestCommentHelper;
import com.google.inject.Inject;
import com.google.inject.Module;
+import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
@@ -47,6 +55,7 @@
public class ReceiveCommitsCommentValidationIT extends AbstractDaemonTest {
@Inject private CommentValidator mockCommentValidator;
@Inject private TestCommentHelper testCommentHelper;
+ @Inject private RequestScopeOperations requestScopeOperations;
private static final int COMMENT_SIZE_LIMIT = 666;
@@ -101,6 +110,72 @@
}
@Test
+ public void emailsSentOnPublishCommentsHaveDifferentMessageIds() throws Exception {
+ PushOneCommit.Result result = createChange();
+ String changeId = result.getChangeId();
+ gApi.changes().id(changeId).addReviewer(user.email());
+ sender.clear();
+
+ String revId = result.getCommit().getName();
+ DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
+ testCommentHelper.addDraft(changeId, revId, comment);
+ amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+
+ List<FakeEmailSender.Message> messages = sender.getMessages();
+ assertThat(messages).hasSize(2);
+
+ FakeEmailSender.Message newPatchsetMessage = messages.get(0);
+ assertThat(newPatchsetMessage.body()).contains("new patch set");
+ assertThat(newPatchsetMessage.headers().get("Message-ID").toString())
+ .doesNotContain("EmailReviewComments");
+
+ FakeEmailSender.Message newCommentsMessage = messages.get(1);
+ assertThat(newCommentsMessage.body()).contains("has posted comments on this change");
+ assertThat(newCommentsMessage.headers().get("Message-ID").toString())
+ .contains("EmailReviewComments");
+ }
+
+ @Test
+ public void publishCommentsAddsAllUsersInCommentThread() throws Exception {
+ PushOneCommit.Result result = createChange();
+ String changeId = result.getChangeId();
+ String revId = result.getCommit().getName();
+
+ requestScopeOperations.setApiUser(user.id());
+ DraftInput comment = testCommentHelper.newDraft(COMMENT_TEXT);
+ testCommentHelper.addDraft(changeId, revId, comment);
+ ReviewInput reviewInput = new ReviewInput().blockAutomaticAttentionSetRules();
+ reviewInput.drafts = ReviewInput.DraftHandling.PUBLISH;
+ change(result).current().review(reviewInput);
+
+ requestScopeOperations.setApiUser(admin.id());
+ comment =
+ testCommentHelper.newDraft(
+ COMMENT_TEXT,
+ Iterables.getOnlyElement(gApi.changes().id(changeId).current().commentsAsList()).id);
+
+ testCommentHelper.addDraft(changeId, revId, comment);
+ Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+ AttentionSetUpdate attentionSetUpdate =
+ Iterables.getOnlyElement(amendResult.getChange().attentionSet());
+ assertThat(attentionSetUpdate.account()).isEqualTo(user.id());
+ assertThat(attentionSetUpdate.reason())
+ .isEqualTo("Someone else replied on a comment you posted");
+ assertThat(attentionSetUpdate.operation()).isEqualTo(AttentionSetUpdate.Operation.ADD);
+ }
+
+ @Test
+ public void attentionSetNotUpdatedWhenNoCommentsPublished() throws Exception {
+ PushOneCommit.Result result = createChange();
+ String changeId = result.getChangeId();
+ gApi.changes().id(changeId).addReviewer(user.email());
+ gApi.changes().id(changeId).attention(user.email()).remove(new AttentionSetInput("removed"));
+ ImmutableSet<AttentionSetUpdate> attentionSet = result.getChange().attentionSet();
+ Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+ assertThat(attentionSet).isEqualTo(amendResult.getChange().attentionSet());
+ }
+
+ @Test
public void validateComments_commentRejected() throws Exception {
PushOneCommit.Result result = createChange();
String changeId = result.getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index 70d8335..b2a349e 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -37,7 +37,7 @@
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.AbandonInput;
import com.google.gerrit.extensions.api.changes.AddReviewerInput;
@@ -1714,7 +1714,7 @@
.sent("newpatchset", sc)
.notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
.to(sc.reviewer)
- .to(other)
+ .cc(other)
.cc(sc.ccer)
.cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
.noOneElse();
@@ -1730,7 +1730,7 @@
.sent("newpatchset", sc)
.notTo(sc.owner) // TODO(logan): This shouldn't be sent *from* the owner.
.to(sc.reviewer)
- .to(other)
+ .cc(other)
.cc(sc.ccer)
.cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
.noOneElse();
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index e961c67..49b184b 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -45,11 +45,11 @@
}
@Test
- @GerritConfig(name = "receiveemail.filter.mode", value = "WHITELIST")
+ @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
@GerritConfig(
name = "receiveemail.filter.patterns",
values = {".+ser@example\\.com", "a@b\\.com"})
- public void listFilterWhitelistDoesNotFilterListedUser() throws Exception {
+ public void listFilterAllowDoesNotFilterListedUser() throws Exception {
ChangeInfo changeInfo = createChangeAndReplyByEmail();
// Check that the comments from the email have been persisted
Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -57,11 +57,11 @@
}
@Test
- @GerritConfig(name = "receiveemail.filter.mode", value = "WHITELIST")
+ @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
@GerritConfig(
name = "receiveemail.filter.patterns",
values = {".+@gerritcodereview\\.com", "a@b\\.com"})
- public void listFilterWhitelistFiltersNotListedUser() throws Exception {
+ public void listFilterAllowFiltersNotListedUser() throws Exception {
ChangeInfo changeInfo = createChangeAndReplyByEmail();
// Check that the comments from the email have NOT been persisted
Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -72,11 +72,11 @@
}
@Test
- @GerritConfig(name = "receiveemail.filter.mode", value = "BLACKLIST")
+ @GerritConfig(name = "receiveemail.filter.mode", value = "BLOCK")
@GerritConfig(
name = "receiveemail.filter.patterns",
values = {".+@gerritcodereview\\.com", "a@b\\.com"})
- public void listFilterBlacklistDoesNotFilterNotListedUser() throws Exception {
+ public void listFilterBlockDoesNotFilterNotListedUser() throws Exception {
ChangeInfo changeInfo = createChangeAndReplyByEmail();
// Check that the comments from the email have been persisted
Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
@@ -84,11 +84,11 @@
}
@Test
- @GerritConfig(name = "receiveemail.filter.mode", value = "BLACKLIST")
+ @GerritConfig(name = "receiveemail.filter.mode", value = "BLOCK")
@GerritConfig(
name = "receiveemail.filter.patterns",
values = {".+@example\\.com", "a@b\\.com"})
- public void listFilterBlacklistFiltersListedUser() throws Exception {
+ public void listFilterBlockFiltersListedUser() throws Exception {
ChangeInfo changeInfo = createChangeAndReplyByEmail();
// Check that the comments from the email have been persisted
Collection<ChangeMessageInfo> messages = gApi.changes().id(changeInfo.id).get().messages;
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index 4f79e09..5679c41 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -459,7 +459,7 @@
private ImmutableSet<CommentInfo> getCommentsAndRobotComments(String changeId)
throws RestApiException {
return Streams.concat(
- gApi.changes().id(changeId).comments().values().stream(),
+ gApi.changes().id(changeId).commentsRequest().get().values().stream(),
gApi.changes().id(changeId).robotComments().values().stream())
.flatMap(Collection::stream)
.collect(toImmutableSet());
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/BUILD b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
new file mode 100644
index 0000000..e89e8d1
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+ srcs = glob(["*IT.java"]),
+ group = "server_permissions",
+ labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
new file mode 100644
index 0000000..d68d681
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/GroupBackedUserPermissionIT.java
@@ -0,0 +1,184 @@
+// 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.acceptance.server.permissions;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.group.testing.TestGroupBackend;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.query.change.GroupBackedUser;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests that permission logic used by {@link GroupBackedUser} works as expected. */
+public class GroupBackedUserPermissionIT extends AbstractDaemonTest {
+ @Inject private ChangeOperations changeOperations;
+ @Inject private ProjectOperations projectOperations;
+ @Inject private PermissionBackend permissionBackend;
+ @Inject private ChangeNotes.Factory changeNotesFactory;
+
+ private final TestGroupBackend testGroupBackend = new TestGroupBackend();
+ private final AccountGroup.UUID externalGroup = AccountGroup.uuid("testbackend:test");
+
+ @Before
+ public void setUp() {
+ // Allow only read on refs/heads/master by default
+ projectOperations
+ .project(allProjects)
+ .forUpdate()
+ .remove(permissionKey(Permission.READ).ref("refs/*").group(ANONYMOUS_USERS))
+ .add(allow(Permission.READ).ref("refs/heads/master").group(ANONYMOUS_USERS))
+ .update();
+ }
+
+ @Override
+ public Module createModule() {
+ /** Binding a {@link TestGroupBackend} to test adding external groups * */
+ return new AbstractModule() {
+ @Override
+ protected void configure() {
+ DynamicSet.bind(binder(), GroupBackend.class).toInstance(testGroupBackend);
+ }
+ };
+ }
+
+ @Test
+ public void defaultRefFilter_changeVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+ GroupBackedUser user =
+ new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+ Change.Id changeOnMaster = changeOperations.newChange().project(project).create();
+ Change.Id changeOnRefsMetaConfig =
+ changeOperations.newChange().project(project).branch("refs/meta/config").create();
+ // Check that only the change on the default branch is visible
+ assertThat(getVisibleRefNames(user))
+ .containsExactly(
+ "HEAD",
+ "refs/heads/master",
+ RefNames.changeMetaRef(changeOnMaster),
+ RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)));
+ // Grant access
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+ .update();
+ // Check that both changes are visible now
+ assertThat(getVisibleRefNames(user))
+ .containsExactly(
+ "HEAD",
+ "refs/heads/master",
+ "refs/meta/config",
+ RefNames.changeMetaRef(changeOnMaster),
+ RefNames.patchSetRef(PatchSet.id(changeOnMaster, 1)),
+ RefNames.changeMetaRef(changeOnRefsMetaConfig),
+ RefNames.patchSetRef(PatchSet.id(changeOnRefsMetaConfig, 1)));
+ }
+
+ @Test
+ public void defaultRefFilter_refVisibilityIsAgnosticOfProvidedGroups() throws Exception {
+ GroupBackedUser user =
+ new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+ // Check that refs/meta/config isn't visible by default
+ assertThat(getVisibleRefNames(user)).containsExactly("HEAD", "refs/heads/master");
+ // Grant access
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .add(allow(Permission.READ).ref("refs/meta/config").group(externalGroup))
+ .update();
+ // Check that refs/meta/config became visible
+ assertThat(getVisibleRefNames(user))
+ .containsExactly("HEAD", "refs/heads/master", "refs/meta/config");
+ }
+
+ @Test
+ public void changeVisibility_changeOnInvisibleBranchNotVisible() throws Exception {
+ // Create a change that is not visible to members of 'externalGroup'
+ Change.Id invisibleChange =
+ changeOperations.newChange().project(project).branch("refs/meta/config").create();
+ GroupBackedUser user =
+ new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+ AuthException thrown =
+ assertThrows(
+ AuthException.class,
+ () ->
+ permissionBackend
+ .user(user)
+ .change(changeNotesFactory.create(project, invisibleChange))
+ .check(ChangePermission.READ));
+ assertThat(thrown).hasMessageThat().isEqualTo("read not permitted");
+ }
+
+ @Test
+ public void changeVisibility_changeOnBranchVisibleToAnonymousIsVisible() throws Exception {
+ Change.Id changeId = changeOperations.newChange().project(project).create();
+ GroupBackedUser user =
+ new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+ permissionBackend
+ .user(user)
+ .change(changeNotesFactory.create(project, changeId))
+ .check(ChangePermission.READ);
+ }
+
+ @Test
+ public void changeVisibility_changeOnBranchVisibleToRegisteredUsersIsVisible() throws Exception {
+ Change.Id changeId = changeOperations.newChange().project(project).create();
+ GroupBackedUser user =
+ new GroupBackedUser(ImmutableSet.of(ANONYMOUS_USERS, REGISTERED_USERS, externalGroup));
+ blockAnonymousRead();
+ permissionBackend
+ .user(user)
+ .change(changeNotesFactory.create(project, changeId))
+ .check(ChangePermission.READ);
+ }
+
+ private ImmutableList<String> getVisibleRefNames(CurrentUser user) throws Exception {
+ try (Repository repo = repoManager.openRepository(project)) {
+ return permissionBackend.user(user).project(project)
+ .filter(
+ repo.getRefDatabase().getRefs(), repo, PermissionBackend.RefFilterOptions.defaults())
+ .stream()
+ .map(Ref::getName)
+ .collect(toImmutableList());
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 1991e79..6aa5878 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -17,11 +17,11 @@
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
-import static com.google.gerrit.common.data.LabelFunction.ANY_WITH_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.MAX_NO_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.MAX_WITH_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.NO_BLOCK;
-import static com.google.gerrit.common.data.LabelFunction.NO_OP;
+import static com.google.gerrit.entities.LabelFunction.ANY_WITH_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.MAX_NO_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.MAX_WITH_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.NO_BLOCK;
+import static com.google.gerrit.entities.LabelFunction.NO_OP;
import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
@@ -39,15 +39,16 @@
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.extensions.api.changes.AddReviewerInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.events.CommentAddedListener;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.server.project.CachedProjectConfig;
import com.google.inject.Inject;
import org.junit.Before;
import org.junit.Test;
@@ -63,6 +64,7 @@
@Inject private ProjectOperations projectOperations;
@Inject private ExtensionRegistry extensionRegistry;
+ @Inject private RequestScopeOperations requestScopeOperations;
@Before
public void setUp() throws Exception {
@@ -204,6 +206,28 @@
}
@Test
+ public void customLabelMaxWithBlock_DeletedVoteDoesNotTriggerNegativeBlock() throws Exception {
+ saveLabelConfig(P.toBuilder().setFunction(MAX_WITH_BLOCK));
+ PushOneCommit.Result r = createChange();
+ requestScopeOperations.setApiUser(user.id());
+ revision(r).review(new ReviewInput().label(P_LABEL_NAME, 1));
+ requestScopeOperations.setApiUser(admin.id());
+ revision(r).review(new ReviewInput().label(P_LABEL_NAME, 1));
+
+ LabelInfo labelInfo = getWithLabels(r).labels.get(P_LABEL_NAME);
+ assertThat(labelInfo.all).hasSize(2);
+ assertThat(labelInfo.approved).isNotNull();
+ assertThat(labelInfo.blocking).isNull();
+
+ revision(r).reviewer(admin.email()).deleteVote(P_LABEL_NAME);
+ labelInfo = getWithLabels(r).labels.get(P_LABEL_NAME);
+ assertThat(labelInfo.all).hasSize(2); // 0 vote still delivered
+ assertThat(labelInfo.approved).isNotNull();
+ assertThat(labelInfo.rejected).isNull();
+ assertThat(labelInfo.blocking).isNull(); // label is not blocking the change submission
+ }
+
+ @Test
public void customLabelMaxWithBlock_MaxVoteSubmittable() throws Exception {
saveLabelConfig(
LABEL.toBuilder().setFunction(MAX_WITH_BLOCK), P.toBuilder().setFunction(NO_OP));
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
new file mode 100644
index 0000000..6e67d5f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectCacheIT.java
@@ -0,0 +1,107 @@
+// 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.acceptance.server.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.project.ProjectCacheImpl;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.name.Named;
+import javax.inject.Inject;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.Test;
+
+public class ProjectCacheIT extends AbstractDaemonTest {
+ @Inject private PluginConfigFactory pluginConfigFactory;
+
+ @Inject
+ @Named(ProjectCacheImpl.CACHE_NAME)
+ private LoadingCache<Project.NameKey, CachedProjectConfig> inMemoryProjectCache;
+
+ @Inject private SitePaths sitePaths;
+
+ @Test
+ public void pluginConfig_cachedValueEqualsConfigValue() throws Exception {
+ GroupReference group = GroupReference.create(AccountGroup.uuid("uuid"), "local-group-name");
+ try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(project)) {
+ u.getConfig()
+ .updatePluginConfig(
+ "important-plugin",
+ cfg -> {
+ cfg.setGroupReference("group-config-name", group);
+ cfg.setString("key", "my-plugin-value");
+ });
+ u.save();
+ }
+
+ PluginConfig pluginConfig = projectCache.get(project).get().getPluginConfig("important-plugin");
+ assertThat(pluginConfig.getString("key")).isEqualTo("my-plugin-value");
+
+ assertThat(pluginConfig.getGroupReference("group-config-name")).isPresent();
+ assertThat(pluginConfig.getGroupReference("group-config-name")).hasValue(group);
+ }
+
+ @Test
+ public void pluginConfig_inheritedCachedValueEqualsConfigValue() throws Exception {
+ GroupReference group = GroupReference.create(AccountGroup.uuid("uuid"), "local-group-name");
+ try (AbstractDaemonTest.ProjectConfigUpdate u = updateProject(allProjects)) {
+ u.getConfig()
+ .updatePluginConfig(
+ "important-plugin",
+ cfg -> {
+ cfg.setGroupReference("group-config-name", group);
+ cfg.setString("key", "my-plugin-value");
+ });
+ u.save();
+ }
+
+ PluginConfig pluginConfig =
+ pluginConfigFactory.getFromProjectConfigWithInheritance(project, "important-plugin");
+ assertThat(pluginConfig.getString("key")).isEqualTo("my-plugin-value");
+
+ assertThat(pluginConfig.getGroupReference("group-config-name")).isPresent();
+ assertThat(pluginConfig.getGroupReference("group-config-name")).hasValue(group);
+ }
+
+ @Test
+ public void allProjectsProjectsConfig_ChangeInFileInvalidatesPersistedCache() throws Exception {
+ assertThat(projectCache.getAllProjects().getConfig().getCheckReceivedObjects()).isTrue();
+ // Change etc/All-Projects-project.config
+ FileBasedConfig fileBasedConfig =
+ new FileBasedConfig(
+ sitePaths
+ .etc_dir
+ .resolve(allProjects.get())
+ .resolve(ProjectConfig.PROJECT_CONFIG)
+ .toFile(),
+ FS.DETECTED);
+ fileBasedConfig.setString("receive", null, "checkReceivedObjects", "false");
+ fileBasedConfig.save();
+ // Invalidate only the in-memory cache
+ inMemoryProjectCache.invalidate(allProjects);
+ assertThat(projectCache.getAllProjects().getConfig().getCheckReceivedObjects()).isFalse();
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index b04ae33..33276e7 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -25,11 +25,11 @@
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.NotifyConfig;
import com.google.gerrit.entities.NotifyConfig.NotifyType;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.StarsInput;
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
index 11d39b4..127f34b 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ReflogIT.java
@@ -25,9 +25,9 @@
import com.google.gerrit.acceptance.UseLocalDisk;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.groups.GroupApi;
import com.google.gerrit.extensions.api.projects.BranchApi;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
index 90d4e09..d3b40cc 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/IgnoreSelfApprovalRuleIT.java
@@ -20,9 +20,9 @@
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
import com.google.inject.Inject;
import java.util.Map;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
index 92cc396..6079388 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/PrologRuleEvaluatorIT.java
@@ -19,8 +19,8 @@
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.rules.PrologOptions;
import com.google.gerrit.server.rules.PrologRuleEvaluator;
diff --git a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
index 18ae6c4..5cbc767 100644
--- a/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/rules/RulesIT.java
@@ -15,13 +15,14 @@
package com.google.gerrit.acceptance.server.rules;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.server.project.SubmitRuleEvaluator;
import com.google.gerrit.server.project.SubmitRuleOptions;
import com.google.gerrit.server.query.change.ChangeData;
@@ -76,11 +77,40 @@
assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
}
+ @Test
+ public void testFileNamesPredicateWithANewFile() throws Exception {
+ modifySubmitRules("gerrit:files([file('a.txt', 'A', 'REGULAR')])");
+ assertThat(statusForRule()).isEqualTo(SubmitRecord.Status.OK);
+ }
+
+ @Test
+ public void testFileNamesPredicateWithADeletedFile() throws Exception {
+ modifySubmitRules("gerrit:files([file('a.txt', 'D', 'REGULAR')])");
+ assertThat(statusForRuleRemoveFile()).isEqualTo(SubmitRecord.Status.OK);
+ }
+
private SubmitRecord.Status statusForRule() throws Exception {
String oldHead = projectOperations.project(project).getHead("master").name();
PushOneCommit.Result result1 =
pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
testRepo.reset(oldHead);
+ return getStatus(result1);
+ }
+
+ private SubmitRecord.Status statusForRuleRemoveFile() throws Exception {
+ String oldHead = projectOperations.project(project).getHead("master").name();
+ // create a.txt
+ commitBuilder().add("a.txt", "4").message("subject").create();
+ pushHead(testRepo, "refs/heads/master", false);
+
+ // This implictly removes a.txt
+ PushOneCommit.Result result =
+ pushFactory.create(user.newIdent(), testRepo).rm("refs/for/master");
+ testRepo.reset(oldHead);
+ return getStatus(result);
+ }
+
+ private SubmitRecord.Status getStatus(PushOneCommit.Result result1) throws Exception {
ChangeData cd = result1.getChange();
Collection<SubmitRecord> records;
diff --git a/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java b/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java
new file mode 100644
index 0000000..0596cad
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/LifecycleListenersIT.java
@@ -0,0 +1,57 @@
+// 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.acceptance.ssh;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractLifecycleListenersTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.inject.Inject;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+@UseSsh
+public class LifecycleListenersIT extends AbstractLifecycleListenersTest {
+ @Inject private InvocationCheck invocationCheck;
+
+ @Before
+ public void before() {
+ invocationCheck.setStartInvoked(false);
+ invocationCheck.setStopInvoked(false);
+ }
+
+ @Test
+ public void lifecycleListenerSuccessfulInvocation() throws Exception {
+ try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+ adminSshSession.exec("gerrit query --format json status:open --my-plugin--opt");
+ adminSshSession.assertSuccess();
+ assertTrue(invocationCheck.isStartInvoked());
+ assertTrue(invocationCheck.isStopInvoked());
+ }
+ }
+
+ @Test
+ public void lifecycleListenerUnsuccessfulInvocation() throws Exception {
+ try (AutoCloseable ignored = installPlugin("my-plugin", SimpleModule.class)) {
+ adminSshSession.exec("gerrit ls-projects");
+ adminSshSession.assertSuccess();
+ assertFalse(invocationCheck.isStartInvoked());
+ assertFalse(invocationCheck.isStopInvoked());
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
index 4bf7c19..38293f9 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/PluginChangeFieldsIT.java
@@ -23,6 +23,7 @@
import com.google.gerrit.acceptance.UseSsh;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
import com.google.gerrit.server.query.change.OutputStreamQuery;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
@@ -52,12 +53,55 @@
}
@Test
+ public void querySingleChangeWithBulkAttribute() throws Exception {
+ getSingleChangeWithPluginDefinedBulkAttribute(
+ id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
+ }
+
+ @Test
public void queryChangeWithOption() throws Exception {
getChangeWithOption(
id -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id))),
(id, opts) -> pluginInfoFromSingletonList(adminSshSession.exec(changeQueryCmd(id, opts))));
}
+ @Test
+ public void queryPluginDefinedAttributeChangeWithOption() throws Exception {
+ getChangeWithPluginDefinedBulkAttributeOption(
+ id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))),
+ (id, opts) -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id, opts))));
+ }
+
+ @Test
+ public void queryMultipleChangesWithPluginDefinedAttribute() throws Exception {
+ getMultipleChangesWithPluginDefinedBulkAttribute(
+ () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+ }
+
+ @Test
+ public void queryChangesByCommitMessageWithPluginDefinedBulkAttribute() throws Exception {
+ getChangesByCommitMessageWithPluginDefinedBulkAttribute(
+ () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+ }
+
+ @Test
+ public void getMultipleChangesWithPluginDefinedAndChangeAttributes() throws Exception {
+ getMultipleChangesWithPluginDefinedBulkAndChangeAttributes(
+ () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+ }
+
+ @Test
+ public void getMultipleChangesWithPluginDefinedAttributeInSingleCall() throws Exception {
+ getMultipleChangesWithPluginDefinedBulkAttributeInSingleCall(
+ () -> pluginInfosFromList(adminSshSession.exec("gerrit query --format json status:open")));
+ }
+
+ @Test
+ public void getChangeWithPluginDefinedException() throws Exception {
+ getChangeWithPluginDefinedBulkAttributeWithException(
+ id -> pluginInfosFromList(adminSshSession.exec(changeQueryCmd(id))));
+ }
+
private String changeQueryCmd(Change.Id id) {
return changeQueryCmd(id, ImmutableListMultimap.of());
}
@@ -72,7 +116,22 @@
}
@Nullable
- private static List<MyInfo> pluginInfoFromSingletonList(String sshOutput) throws Exception {
+ private static List<PluginDefinedInfo> pluginInfoFromSingletonList(String sshOutput)
+ throws Exception {
+ List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
+
+ assertThat(changeAttrs).hasSize(1);
+ return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
+ }
+
+ @Nullable
+ private static Map<Change.Id, List<PluginDefinedInfo>> pluginInfosFromList(String sshOutput)
+ throws Exception {
+ List<Map<String, Object>> changeAttrs = getChangeAttrs(sshOutput);
+ return getPluginInfosFromChangeInfos(GSON, changeAttrs);
+ }
+
+ private static List<Map<String, Object>> getChangeAttrs(String sshOutput) throws Exception {
List<Map<String, Object>> changeAttrs = new ArrayList<>();
for (String line : CharStreams.readLines(new StringReader(sshOutput))) {
Map<String, Object> changeAttr =
@@ -81,8 +140,6 @@
changeAttrs.add(changeAttr);
}
}
-
- assertThat(changeAttrs).hasSize(1);
- return decodeRawPluginsList(GSON, changeAttrs.get(0).get("plugins"));
+ return changeAttrs;
}
}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
new file mode 100644
index 0000000..827c192
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshDaemonIT.java
@@ -0,0 +1,100 @@
+// 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.acceptance.ssh;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.Sandboxed;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.restapi.config.ListTasks;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.time.LocalDateTime;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@NoHttpd
+@UseSsh
+@Sandboxed
+@RunWith(ConfigSuite.class)
+@SuppressWarnings("unused")
+public class SshDaemonIT extends AbstractDaemonTest {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ @Inject private ListTasks listTasks;
+ @Inject private SitePaths gerritSitePath;
+
+ @ConfigSuite.Parameter protected Config config;
+
+ @ConfigSuite.Config
+ public static Config gracefulConfig() {
+ Config config = new Config();
+ config.setString("sshd", null, "gracefulStopTimeout", "10s");
+ return config;
+ }
+
+ @Override
+ public Module createSshModule() {
+ return new TestSshCommandModule();
+ }
+
+ public Future<Integer> startCommand(String command) throws Exception {
+ Callable<Integer> gracefulSession =
+ () -> {
+ int returnCode = -1;
+ logger.atFine().log("Before Command");
+ returnCode = userSshSession.execAndReturnStatus(command);
+ logger.atFine().log("After Command");
+ return returnCode;
+ };
+
+ ExecutorService executor = Executors.newFixedThreadPool(1);
+ Future<Integer> future = executor.submit(gracefulSession);
+
+ LocalDateTime timeout = LocalDateTime.now().plusSeconds(10);
+
+ TestCommand.syncPoint.await();
+
+ return future;
+ }
+
+ @Test
+ public void NonGracefulCommandIsStoppedImmediately() throws Exception {
+ Future<Integer> future = startCommand("non-graceful -d 5");
+ restart();
+ Assert.assertTrue(future.get() == -1);
+ }
+
+ @Test
+ public void GracefulCommandIsStoppedGracefully() throws Exception {
+ Future<Integer> future = startCommand("graceful -d 5");
+ restart();
+ if (cfg.getTimeUnit("sshd", null, "gracefulStopTimeout", 0, TimeUnit.SECONDS) == 0) {
+ Assert.assertTrue(future.get() == -1);
+ } else {
+ Assert.assertTrue(future.get() == 0);
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
new file mode 100644
index 0000000..0bd6554
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/ChangeOperationsImplTest.java
@@ -0,0 +1,1302 @@
+// 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.acceptance.testsuite.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
+import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.hasCommit;
+import static com.google.gerrit.extensions.common.testing.DiffInfoSubject.assertThat;
+import static com.google.gerrit.extensions.restapi.testing.BinaryResultSubject.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeType;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.DiffInfo;
+import com.google.gerrit.extensions.common.FileInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import com.google.inject.Inject;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ChangeOperationsImplTest extends AbstractDaemonTest {
+
+ @Inject private ChangeOperations changeOperations;
+ @Inject private ProjectOperations projectOperations;
+ @Inject private AccountOperations accountOperations;
+ @Inject private RequestScopeOperations requestScopeOperations;
+
+ @Test
+ public void changeCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+ Change.Id numericChangeId = changeOperations.newChange().create();
+
+ ChangeInfo change = getChangeFromServer(numericChangeId);
+ assertThat(change._number).isEqualTo(numericChangeId.get());
+ assertThat(change.changeId).isNotEmpty();
+ }
+
+ @Test
+ public void changeCanBeCreatedEvenWithRequestScopeOfArbitraryUser() throws Exception {
+ Account.Id user = accountOperations.newAccount().create();
+
+ requestScopeOperations.setApiUser(user);
+ Change.Id numericChangeId = changeOperations.newChange().create();
+
+ ChangeInfo change = getChangeFromServer(numericChangeId);
+ assertThat(change._number).isEqualTo(numericChangeId.get());
+ }
+
+ @Test
+ public void twoChangesWithoutAnyParametersDoNotClash() {
+ Change.Id changeId1 = changeOperations.newChange().create();
+ Change.Id changeId2 = changeOperations.newChange().create();
+
+ TestChange change1 = changeOperations.change(changeId1).get();
+ TestChange change2 = changeOperations.change(changeId2).get();
+ assertThat(change1.numericChangeId()).isNotEqualTo(change2.numericChangeId());
+ assertThat(change1.changeId()).isNotEqualTo(change2.changeId());
+ }
+
+ @Test
+ public void twoSubsequentlyCreatedChangesDoNotDependOnEachOther() throws Exception {
+ Change.Id changeId1 = changeOperations.newChange().create();
+ Change.Id changeId2 = changeOperations.newChange().create();
+
+ ChangeInfo change1 = getChangeFromServer(changeId1);
+ ChangeInfo change2 = getChangeFromServer(changeId2);
+ CommitInfo currentPatchsetCommit1 = change1.revisions.get(change1.currentRevision).commit;
+ CommitInfo currentPatchsetCommit2 = change2.revisions.get(change2.currentRevision).commit;
+ assertThat(currentPatchsetCommit1)
+ .parents()
+ .comparingElementsUsing(hasCommit())
+ .doesNotContain(currentPatchsetCommit2.commit);
+ assertThat(currentPatchsetCommit2)
+ .parents()
+ .comparingElementsUsing(hasCommit())
+ .doesNotContain(currentPatchsetCommit1.commit);
+ }
+
+ @Test
+ public void createdChangeHasAtLeastOnePatchset() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThatMap(change.revisions).size().isAtLeast(1);
+ }
+
+ @Test
+ public void createdChangeIsInSpecifiedProject() throws Exception {
+ Project.NameKey project = projectOperations.newProject().create();
+ Change.Id changeId = changeOperations.newChange().project(project).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.project).isEqualTo(project.get());
+ }
+
+ @Test
+ public void changeCanBeCreatedInEmptyRepository() throws Exception {
+ Project.NameKey project = projectOperations.newProject().noEmptyCommit().create();
+ Change.Id changeId = changeOperations.newChange().project(project).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.project).isEqualTo(project.get());
+ }
+
+ @Test
+ public void createdChangeHasSpecifiedTargetBranch() throws Exception {
+ Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+ Change.Id changeId =
+ changeOperations.newChange().project(project).branch("test-branch").create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.branch).isEqualTo("test-branch");
+ }
+
+ @Test
+ public void createdChangeUsesTipOfTargetBranchAsParentByDefault() throws Exception {
+ Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+ ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
+ Change.Id changeId =
+ changeOperations.newChange().project(project).branch("test-branch").create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .onlyElement()
+ .commit()
+ .isEqualTo(parentCommitId.name());
+ }
+
+ @Test
+ public void createdChangeUsesSpecifiedBranchTipAsParent() throws Exception {
+ Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .project(project)
+ .childOf()
+ .tipOfBranch("refs/heads/test-branch")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .onlyElement()
+ .commit()
+ .isEqualTo(parentCommitId.name());
+ }
+
+ @Test
+ public void specifiedParentBranchMayHaveShortName() throws Exception {
+ Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+
+ Change.Id changeId =
+ changeOperations.newChange().project(project).childOf().tipOfBranch("test-branch").create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ ObjectId parentCommitId = projectOperations.project(project).getHead("test-branch").getId();
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .onlyElement()
+ .commit()
+ .isEqualTo(parentCommitId.name());
+ }
+
+ @Test
+ public void specifiedParentBranchMustExist() {
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ changeOperations.newChange().childOf().tipOfBranch("not-existing-branch").create());
+ assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+ }
+
+ @Test
+ public void createdChangeUsesSpecifiedChangeAsParent() throws Exception {
+ Change.Id parentChangeId = changeOperations.newChange().create();
+
+ Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ ObjectId parentCommitId =
+ changeOperations.change(parentChangeId).currentPatchset().get().commitId();
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .onlyElement()
+ .commit()
+ .isEqualTo(parentCommitId.name());
+ }
+
+ @Test
+ public void specifiedParentChangeMustExist() {
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () -> changeOperations.newChange().childOf().change(Change.id(987654321)).create());
+ assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+ }
+
+ @Test
+ public void createdChangeUsesSpecifiedPatchsetAsParent() throws Exception {
+ Change.Id parentChangeId = changeOperations.newChange().create();
+ TestPatchset parentPatchset = changeOperations.change(parentChangeId).currentPatchset().get();
+
+ Change.Id changeId =
+ changeOperations.newChange().childOf().patchset(parentPatchset.patchsetId()).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .onlyElement()
+ .commit()
+ .isEqualTo(parentPatchset.commitId().name());
+ }
+
+ @Test
+ public void changeOfSpecifiedParentPatchsetMustExist() {
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ changeOperations
+ .newChange()
+ .childOf()
+ .patchset(PatchSet.id(Change.id(987654321), 1))
+ .create());
+ assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+ }
+
+ @Test
+ public void specifiedParentPatchsetMustExist() {
+ Change.Id parentChangeId = changeOperations.newChange().create();
+
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ changeOperations
+ .newChange()
+ .childOf()
+ .patchset(PatchSet.id(parentChangeId, 1000))
+ .create());
+ assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+ }
+
+ @Test
+ public void createdChangeUsesSpecifiedCommitAsParent() throws Exception {
+ // Currently, the easiest way to create a commit is by creating another change.
+ Change.Id anotherChangeId = changeOperations.newChange().create();
+ ObjectId parentCommitId =
+ changeOperations.change(anotherChangeId).currentPatchset().get().commitId();
+
+ Change.Id changeId = changeOperations.newChange().childOf().commit(parentCommitId).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .onlyElement()
+ .commit()
+ .isEqualTo(parentCommitId.name());
+ }
+
+ @Test
+ public void specifiedParentCommitMustExist() {
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ changeOperations
+ .newChange()
+ .childOf()
+ .commit(ObjectId.fromString("0123456789012345678901234567890123456789"))
+ .create());
+ assertThat(exception).hasMessageThat().ignoringCase().contains("parent");
+ }
+
+ @Test
+ public void createdChangeUsesSpecifiedChangesInGivenOrderAsParents() throws Exception {
+ Change.Id parent1ChangeId = changeOperations.newChange().create();
+ Change.Id parent2ChangeId = changeOperations.newChange().create();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ ObjectId parent1CommitId =
+ changeOperations.change(parent1ChangeId).currentPatchset().get().commitId();
+ ObjectId parent2CommitId =
+ changeOperations.change(parent2ChangeId).currentPatchset().get().commitId();
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .comparingElementsUsing(hasSha1())
+ .containsExactly(parent1CommitId.name(), parent2CommitId.name())
+ .inOrder();
+ }
+
+ @Test
+ public void createdChangeUsesMergedParentsAsBaseCommit() throws Exception {
+ Change.Id parent1ChangeId =
+ changeOperations.newChange().file("file1").content("Line 1").create();
+ Change.Id parent2ChangeId =
+ changeOperations.newChange().file("file2").content("Some other content").create();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+
+ PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+ assertThat(file1Content).asString().isEqualTo("Line 1");
+ BinaryResult file2Content = getFileContent(changeId, patchsetId, "file2");
+ assertThat(file2Content).asString().isEqualTo("Some other content");
+ }
+
+ @Test
+ public void mergeConflictsOfParentsAreReported() {
+ Change.Id parent1ChangeId =
+ changeOperations.newChange().file("file1").content("Content 1").create();
+ Change.Id parent2ChangeId =
+ changeOperations.newChange().file("file1").content("Content 2").create();
+
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create());
+
+ assertThat(exception).hasMessageThat().ignoringCase().contains("conflict");
+ }
+
+ @Test
+ public void mergeConflictsCanBeAvoidedByUsingTheFirstParentAsBase() throws Exception {
+ Change.Id parent1ChangeId =
+ changeOperations.newChange().file("file1").content("Content 1").create();
+ Change.Id parent2ChangeId =
+ changeOperations.newChange().file("file1").content("Content 2").create();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOfButBaseOnFirst()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+
+ PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+ assertThat(file1Content).asString().isEqualTo("Content 1");
+ }
+
+ @Test
+ public void createdChangeHasAllParentsEvenWhenBasedOnFirst() throws Exception {
+ Change.Id parent1ChangeId = changeOperations.newChange().create();
+ Change.Id parent2ChangeId = changeOperations.newChange().create();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOfButBaseOnFirst()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ ObjectId parent1CommitId =
+ changeOperations.change(parent1ChangeId).currentPatchset().get().commitId();
+ ObjectId parent2CommitId =
+ changeOperations.change(parent2ChangeId).currentPatchset().get().commitId();
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .comparingElementsUsing(hasSha1())
+ .containsExactly(parent1CommitId.name(), parent2CommitId.name())
+ .inOrder();
+ }
+
+ @Test
+ public void automaticMergeOfMoreThanTwoParentsIsNotPossible() {
+ Change.Id parent1ChangeId =
+ changeOperations.newChange().file("file1").content("Content 1").create();
+ Change.Id parent2ChangeId =
+ changeOperations.newChange().file("file2").content("Content 2").create();
+ Change.Id parent3ChangeId =
+ changeOperations.newChange().file("file3").content("Content 3").create();
+
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .followedBy()
+ .change(parent2ChangeId)
+ .and()
+ .change(parent3ChangeId)
+ .create());
+
+ assertThat(exception).hasMessageThat().ignoringCase().contains("conflict");
+ }
+
+ @Test
+ public void createdChangeCanHaveMoreThanTwoParentsWhenBasedOnFirst() throws Exception {
+ Change.Id parent1ChangeId =
+ changeOperations.newChange().file("file1").content("Content 1").create();
+ Change.Id parent2ChangeId =
+ changeOperations.newChange().file("file2").content("Content 2").create();
+ Change.Id parent3ChangeId =
+ changeOperations.newChange().file("file3").content("Content 3").create();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOfButBaseOnFirst()
+ .change(parent1ChangeId)
+ .followedBy()
+ .change(parent2ChangeId)
+ .and()
+ .change(parent3ChangeId)
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ ObjectId parent1CommitId =
+ changeOperations.change(parent1ChangeId).currentPatchset().get().commitId();
+ ObjectId parent2CommitId =
+ changeOperations.change(parent2ChangeId).currentPatchset().get().commitId();
+ ObjectId parent3CommitId =
+ changeOperations.change(parent3ChangeId).currentPatchset().get().commitId();
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .comparingElementsUsing(hasSha1())
+ .containsExactly(parent1CommitId.name(), parent2CommitId.name(), parent3CommitId.name())
+ .inOrder();
+ }
+
+ @Test
+ public void changeBasedOnParentMayHaveAdditionalFileModifications() throws Exception {
+ Change.Id parentChangeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Content 1")
+ .file("file2")
+ .content("Content 2")
+ .create();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .childOf()
+ .change(parentChangeId)
+ .file("file1")
+ .content("Different content")
+ .create();
+
+ PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+ assertThat(file1Content).asString().isEqualTo("Different content");
+ BinaryResult file2Content = getFileContent(changeId, patchsetId, "file2");
+ assertThat(file2Content).asString().isEqualTo("Content 2");
+ }
+
+ @Test
+ public void changeFromMergedParentsMayHaveAdditionalFileModifications() throws Exception {
+ Change.Id parent1ChangeId =
+ changeOperations.newChange().file("file1").content("Content 1").create();
+ Change.Id parent2ChangeId =
+ changeOperations.newChange().file("file2").content("Content 2").create();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .file("file1")
+ .content("Different content")
+ .create();
+
+ PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+ assertThat(file1Content).asString().isEqualTo("Different content");
+ BinaryResult file2Content = getFileContent(changeId, patchsetId, "file2");
+ assertThat(file2Content).asString().isEqualTo("Content 2");
+ }
+
+ @Test
+ public void changeBasedOnFirstOfMultipleParentsMayHaveAdditionalFileModifications()
+ throws Exception {
+ Change.Id parent1ChangeId =
+ changeOperations.newChange().file("file1").content("Content 1").create();
+ Change.Id parent2ChangeId = changeOperations.newChange().create();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOfButBaseOnFirst()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .file("file1")
+ .content("Different content")
+ .create();
+
+ PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+ assertThat(file1Content).asString().isEqualTo("Different content");
+ }
+
+ @Test
+ public void createdChangeHasSpecifiedOwner() throws Exception {
+ Account.Id changeOwner = accountOperations.newAccount().create();
+ Change.Id changeId = changeOperations.newChange().owner(changeOwner).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+ }
+
+ @Test
+ public void changeOwnerDoesNotNeedAnyPermissionsForChangeCreation() throws Exception {
+ Account.Id changeOwner = accountOperations.newAccount().create();
+ Project.NameKey project = projectOperations.newProject().branches("test-branch").create();
+ // Remove any read and push permissions which might potentially exist. Without read, users
+ // shouldn't be able to do anything. The newly created project should only inherit from
+ // All-Projects.
+ projectOperations
+ .project(project)
+ .forUpdate()
+ .remove(permissionKey(Permission.READ).ref("refs/heads/test-branch"))
+ .remove(permissionKey(Permission.PUSH).ref("refs/heads/test-branch"))
+ .update();
+ projectOperations
+ .allProjectsForUpdate()
+ .remove(permissionKey(Permission.READ).ref("refs/heads/test-branch"))
+ .remove(permissionKey(Permission.PUSH).ref("refs/heads/test-branch"))
+ .update();
+
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .owner(changeOwner)
+ .branch("test-branch")
+ .project(project)
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThat(change.owner._accountId).isEqualTo(changeOwner.get());
+ }
+
+ @Test
+ public void createdChangeHasSpecifiedCommitMessage() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .commitMessage("Summary line\n\nDetailed description.")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ assertThat(currentPatchsetCommit).message().startsWith("Summary line\n\nDetailed description.");
+ }
+
+ @Test
+ public void changeCannotBeCreatedWithoutCommitMessage() {
+ assertThrows(
+ IllegalStateException.class, () -> changeOperations.newChange().commitMessage("").create());
+ }
+
+ @Test
+ public void commitMessageOfCreatedChangeAutomaticallyGetsChangeId() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .commitMessage("Summary line\n\nDetailed description.")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ assertThat(currentPatchsetCommit).message().contains("Change-Id:");
+ }
+
+ @Test
+ public void changeIdSpecifiedInCommitMessageIsKeptForCreatedChange() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .commitMessage("Summary line\n\nChange-Id: I0123456789012345678901234567890123456789")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ assertThat(currentPatchsetCommit)
+ .message()
+ .contains("Change-Id: I0123456789012345678901234567890123456789");
+ assertThat(change.changeId).isEqualTo("I0123456789012345678901234567890123456789");
+ }
+
+ @Test
+ public void createdChangeHasSpecifiedFiles() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Line 1")
+ .file("path/to/file2.txt")
+ .content("Line one")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+ assertThatMap(files).keys().containsExactly("file1", "path/to/file2.txt");
+ BinaryResult fileContent1 = gApi.changes().id(changeId.get()).current().file("file1").content();
+ assertThat(fileContent1).asString().isEqualTo("Line 1");
+ BinaryResult fileContent2 =
+ gApi.changes().id(changeId.get()).current().file("path/to/file2.txt").content();
+ assertThat(fileContent2).asString().isEqualTo("Line one");
+ }
+
+ @Test
+ public void existingChangeCanBeCheckedForExistence() {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ boolean exists = changeOperations.change(changeId).exists();
+
+ assertThat(exists).isTrue();
+ }
+
+ @Test
+ public void notExistingChangeCanBeCheckedForExistence() {
+ Change.Id changeId = Change.id(123456789);
+
+ boolean exists = changeOperations.change(changeId).exists();
+
+ assertThat(exists).isFalse();
+ }
+
+ @Test
+ public void retrievingNotExistingChangeFails() {
+ Change.Id changeId = Change.id(123456789);
+ assertThrows(IllegalStateException.class, () -> changeOperations.change(changeId).get());
+ }
+
+ @Test
+ public void numericChangeIdOfExistingChangeCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ TestChange change = changeOperations.change(changeId).get();
+ assertThat(change.numericChangeId()).isEqualTo(changeId);
+ }
+
+ @Test
+ public void changeIdOfExistingChangeCanBeRetrieved() {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .commitMessage("Summary line\n\nChange-Id: I0123456789012345678901234567890123456789")
+ .create();
+
+ TestChange change = changeOperations.change(changeId).get();
+ assertThat(change.changeId()).isEqualTo("I0123456789012345678901234567890123456789");
+ }
+
+ @Test
+ public void currentPatchsetOfExistingChangeCanBeRetrieved() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ TestPatchset patchset = changeOperations.change(changeId).currentPatchset().get();
+
+ ChangeInfo expectedChange = getChangeFromServer(changeId);
+ String expectedCommitId = expectedChange.currentRevision;
+ int expectedPatchsetNumber = expectedChange.revisions.get(expectedCommitId)._number;
+ assertThat(patchset.commitId()).isEqualTo(ObjectId.fromString(expectedCommitId));
+ assertThat(patchset.patchsetId()).isEqualTo(PatchSet.id(changeId, expectedPatchsetNumber));
+ }
+
+ @Test
+ public void earlierPatchsetOfExistingChangeCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+ PatchSet.Id earlierPatchsetId =
+ changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ PatchSet.Id currentPatchsetId = changeOperations.change(changeId).newPatchset().create();
+
+ TestPatchset earlierPatchset =
+ changeOperations.change(changeId).patchset(earlierPatchsetId).get();
+
+ assertThat(earlierPatchset.patchsetId()).isEqualTo(earlierPatchsetId);
+ assertThat(earlierPatchset.patchsetId()).isNotEqualTo(currentPatchsetId);
+ }
+
+ @Test
+ public void newPatchsetCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ ChangeInfo unmodifiedChange = getChangeFromServer(changeId);
+ int originalPatchsetCount = unmodifiedChange.revisions.size();
+
+ PatchSet.Id patchsetId = changeOperations.change(changeId).newPatchset().create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ assertThatMap(change.revisions).hasSize(originalPatchsetCount + 1);
+ RevisionInfo currentRevision = change.revisions.get(change.currentRevision);
+ assertThat(currentRevision._number).isEqualTo(patchsetId.get());
+ }
+
+ @Test
+ public void newPatchsetIsCopyOfPreviousPatchsetByDefault() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ PatchSet.Id patchsetId = changeOperations.change(changeId).newPatchset().create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ RevisionInfo patchsetRevision = getRevision(change, patchsetId);
+ assertThat(patchsetRevision.kind).isEqualTo(ChangeKind.NO_CHANGE);
+ }
+
+ @Test
+ public void newPatchsetCanHaveUpdatedCommitMessage() throws Exception {
+ Change.Id changeId = changeOperations.newChange().commitMessage("Old message").create();
+
+ changeOperations.change(changeId).newPatchset().commitMessage("New message").create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ assertThat(currentPatchsetCommit).message().startsWith("New message");
+ }
+
+ @Test
+ public void updatedCommitMessageOfNewPatchsetAutomaticallyKeepsChangeId() throws Exception {
+ Change.Id numericChangeId = changeOperations.newChange().commitMessage("Old message").create();
+ String changeId = changeOperations.change(numericChangeId).get().changeId();
+
+ changeOperations.change(numericChangeId).newPatchset().commitMessage("New message").create();
+
+ ChangeInfo change = getChangeFromServer(numericChangeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ assertThat(currentPatchsetCommit).message().contains("Change-Id: " + changeId);
+ }
+
+ @Test
+ public void newPatchsetCanHaveDifferentChangeIdFooter() throws Exception {
+ Change.Id numericChangeId =
+ changeOperations
+ .newChange()
+ .commitMessage("Old message\n\nChange-Id: I1111111111111111111111111111111111111111")
+ .create();
+
+ // Specifying another change-id is not an officially supported behavior of Gerrit but we might
+ // need this for some test scenarios and hence we support it in the test API.
+ changeOperations
+ .change(numericChangeId)
+ .newPatchset()
+ .commitMessage("New message\n\nChange-Id: I0123456789012345678901234567890123456789")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(numericChangeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ assertThat(currentPatchsetCommit)
+ .message()
+ .contains("Change-Id: I0123456789012345678901234567890123456789");
+ assertThat(currentPatchsetCommit)
+ .message()
+ .doesNotContain("Change-Id: I1111111111111111111111111111111111111111");
+ // Actual change-id should not have been updated.
+ String changeId = changeOperations.change(numericChangeId).get().changeId();
+ assertThat(changeId).isEqualTo("I1111111111111111111111111111111111111111");
+ }
+
+ @Test
+ public void newPatchsetCanHaveReplacedFileContent() throws Exception {
+ Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+ PatchSet.Id patchsetId =
+ changeOperations
+ .change(changeId)
+ .newPatchset()
+ .file("file1")
+ .content("Different content")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+ assertThatMap(files).keys().containsExactly("file1");
+ BinaryResult fileContent = getFileContent(changeId, patchsetId, "file1");
+ assertThat(fileContent).asString().isEqualTo("Different content");
+ }
+
+ @Test
+ public void newPatchsetCanHaveAdditionalFile() throws Exception {
+ Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+ PatchSet.Id patchsetId =
+ changeOperations
+ .change(changeId)
+ .newPatchset()
+ .file("file2")
+ .content("My file content")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+ assertThatMap(files).keys().containsExactly("file1", "file2");
+ BinaryResult fileContent = getFileContent(changeId, patchsetId, "file2");
+ assertThat(fileContent).asString().isEqualTo("My file content");
+ }
+
+ @Test
+ public void newPatchsetCanHaveLessFiles() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Line 1")
+ .file("file2")
+ .content("Line one")
+ .create();
+
+ changeOperations.change(changeId).newPatchset().file("file2").delete().create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+ assertThatMap(files).keys().containsExactly("file1");
+ }
+
+ @Test
+ public void newPatchsetCanHaveRenamedFile() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Line 1")
+ .file("file2")
+ .content("Line one")
+ .create();
+
+ PatchSet.Id patchsetId =
+ changeOperations
+ .change(changeId)
+ .newPatchset()
+ .file("file2")
+ .renameTo("renamed file")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+ assertThatMap(files).keys().containsExactly("file1", "renamed file");
+ BinaryResult fileContent = getFileContent(changeId, patchsetId, "renamed file");
+ assertThat(fileContent).asString().isEqualTo("Line one");
+ }
+
+ @Test
+ public void newPatchsetCanHaveRenamedFileWithModifiedContent() throws Exception {
+ // We need sufficient content so that the slightly modified content is considered similar enough
+ // (> 60% line similarity) for a rename.
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Some content")
+ .file("file2")
+ .content("Line 1\nLine 2\nLine 3\n")
+ .create();
+ PatchSet.Id patchset1Id =
+ changeOperations.change(changeId).currentPatchset().get().patchsetId();
+
+ PatchSet.Id patchset2Id =
+ changeOperations
+ .change(changeId)
+ .newPatchset()
+ .file("file2")
+ .delete()
+ .file("renamed file")
+ .content("Line 1\nLine two\nLine 3\n")
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ Map<String, FileInfo> files = change.revisions.get(change.currentRevision).files;
+ assertThatMap(files).keys().containsExactly("file1", "renamed file");
+ BinaryResult fileContent = getFileContent(changeId, patchset2Id, "renamed file");
+ assertThat(fileContent).asString().isEqualTo("Line 1\nLine two\nLine 3\n");
+ DiffInfo diff =
+ gApi.changes()
+ .id(changeId.get())
+ .revision(patchset2Id.get())
+ .file("renamed file")
+ .diffRequest()
+ .withBase(patchset1Id.getId())
+ .get();
+ assertThat(diff).changeType().isEqualTo(ChangeType.RENAMED);
+ }
+
+ @Test
+ public void newPatchsetCanHaveCopiedFile() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Some content")
+ .file("file2")
+ .content("Line 1")
+ .create();
+ PatchSet.Id patchset1Id =
+ changeOperations.change(changeId).currentPatchset().get().patchsetId();
+
+ // Copies currently can only happen if a rename happens at the same time.
+ PatchSet.Id patchset2Id =
+ changeOperations
+ .change(changeId)
+ .newPatchset()
+ .file("file2")
+ .renameTo("renamed/copied file 1")
+ .file("renamed/copied file 2")
+ .content("Line 1")
+ .create();
+
+ // We can't control which of the files Gerrit/Git considers as rename and which as copy.
+ // -> Check both for the copy.
+ DiffInfo diff1 =
+ gApi.changes()
+ .id(changeId.get())
+ .revision(patchset2Id.get())
+ .file("renamed/copied file 1")
+ .diffRequest()
+ .withBase(patchset1Id.getId())
+ .get();
+ DiffInfo diff2 =
+ gApi.changes()
+ .id(changeId.get())
+ .revision(patchset2Id.get())
+ .file("renamed/copied file 2")
+ .diffRequest()
+ .withBase(patchset1Id.getId())
+ .get();
+ assertThat(ImmutableSet.of(diff1.changeType, diff2.changeType)).contains(ChangeType.COPIED);
+ }
+
+ @Test
+ public void newPatchsetCanHaveCopiedFileWithModifiedContent() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Some content")
+ .file("file2")
+ .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+ .create();
+ PatchSet.Id patchset1Id =
+ changeOperations.change(changeId).currentPatchset().get().patchsetId();
+
+ // A copy with modified content currently can only happen if the renamed file also has slightly
+ // modified content. Modify the copy slightly more as Gerrit/Git will then select it as the
+ // copied and not renamed file.
+ PatchSet.Id patchset2Id =
+ changeOperations
+ .change(changeId)
+ .newPatchset()
+ .file("file2")
+ .delete()
+ .file("renamed file")
+ .content("Line 1\nLine 1.1\nLine 2\nLine 3\nLine 4\n")
+ .file("copied file")
+ .content("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n")
+ .create();
+
+ DiffInfo diff =
+ gApi.changes()
+ .id(changeId.get())
+ .revision(patchset2Id.get())
+ .file("copied file")
+ .diffRequest()
+ .withBase(patchset1Id.getId())
+ .get();
+ assertThat(diff).changeType().isEqualTo(ChangeType.COPIED);
+ BinaryResult fileContent = getFileContent(changeId, patchset2Id, "copied file");
+ assertThat(fileContent)
+ .asString()
+ .isEqualTo("Line 1\nLine 1.1\nLine 1.2\nLine 2\nLine 3\nLine 4\n");
+ }
+
+ @Test
+ public void newPatchsetCanHaveADifferentParent() throws Exception {
+ Change.Id originalParentChange = changeOperations.newChange().create();
+ Change.Id changeId =
+ changeOperations.newChange().childOf().change(originalParentChange).create();
+ Change.Id newParentChange = changeOperations.newChange().create();
+
+ changeOperations.change(changeId).newPatchset().parent().change(newParentChange).create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ ObjectId newParentCommitId =
+ changeOperations.change(newParentChange).currentPatchset().get().commitId();
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .onlyElement()
+ .commit()
+ .isEqualTo(newParentCommitId.name());
+ }
+
+ @Test
+ public void newPatchsetCanHaveDifferentParents() throws Exception {
+ Change.Id originalParent1Change = changeOperations.newChange().create();
+ Change.Id originalParent2Change = changeOperations.newChange().create();
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(originalParent1Change)
+ .and()
+ .change(originalParent2Change)
+ .create();
+ Change.Id newParent1Change = changeOperations.newChange().create();
+ Change.Id newParent2Change = changeOperations.newChange().create();
+
+ changeOperations
+ .change(changeId)
+ .newPatchset()
+ .parents()
+ .change(newParent1Change)
+ .and()
+ .change(newParent2Change)
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ ObjectId newParent1CommitId =
+ changeOperations.change(newParent1Change).currentPatchset().get().commitId();
+ ObjectId newParent2CommitId =
+ changeOperations.change(newParent2Change).currentPatchset().get().commitId();
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .comparingElementsUsing(hasSha1())
+ .containsExactly(newParent1CommitId.name(), newParent2CommitId.name());
+ }
+
+ @Test
+ public void newPatchsetCanHaveADifferentNumberOfParents() throws Exception {
+ Change.Id originalParentChange = changeOperations.newChange().create();
+ Change.Id changeId =
+ changeOperations.newChange().childOf().change(originalParentChange).create();
+ Change.Id newParent1Change = changeOperations.newChange().create();
+ Change.Id newParent2Change = changeOperations.newChange().create();
+
+ changeOperations
+ .change(changeId)
+ .newPatchset()
+ .parents()
+ .change(newParent1Change)
+ .and()
+ .change(newParent2Change)
+ .create();
+
+ ChangeInfo change = getChangeFromServer(changeId);
+ CommitInfo currentPatchsetCommit = change.revisions.get(change.currentRevision).commit;
+ ObjectId newParent1CommitId =
+ changeOperations.change(newParent1Change).currentPatchset().get().commitId();
+ ObjectId newParent2CommitId =
+ changeOperations.change(newParent2Change).currentPatchset().get().commitId();
+ assertThat(currentPatchsetCommit)
+ .parents()
+ .comparingElementsUsing(hasSha1())
+ .containsExactly(newParent1CommitId.name(), newParent2CommitId.name());
+ }
+
+ @Test
+ public void newPatchsetKeepsFileContentsWithDifferentParent() throws Exception {
+ Change.Id changeId =
+ changeOperations.newChange().file("file1").content("Actual change content").create();
+ Change.Id newParentChange =
+ changeOperations.newChange().file("file1").content("Parent content").create();
+
+ changeOperations.change(changeId).newPatchset().parent().change(newParentChange).create();
+
+ PatchSet.Id patchsetId = changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ BinaryResult file1Content = getFileContent(changeId, patchsetId, "file1");
+ assertThat(file1Content).asString().isEqualTo("Actual change content");
+ }
+
+ @Test
+ public void publishedCommentCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String commentUuid = changeOperations.change(changeId).currentPatchset().newComment().create();
+
+ TestHumanComment comment = changeOperations.change(changeId).comment(commentUuid).get();
+
+ assertThat(comment.uuid()).isEqualTo(commentUuid);
+ }
+
+ @Test
+ public void retrievingDraftCommentAsPublishedCommentFails() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newDraftComment().create();
+
+ assertThrows(
+ Exception.class, () -> changeOperations.change(changeId).comment(commentUuid).get());
+ }
+
+ @Test
+ public void parentUuidOfPublishedCommentCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().create();
+ String childCommentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .parentUuid(parentCommentUuid)
+ .create();
+
+ TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
+
+ assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+ }
+
+ @Test
+ public void tagOfPublishedCommentCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String childCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().tag("tag").create();
+
+ TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
+
+ assertThat(comment.tag()).value().isEqualTo("tag");
+ }
+
+ @Test
+ public void unresolvedOfUnresolvedPublishedCommentCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String childCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().unresolved().create();
+
+ TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
+
+ assertThat(comment.unresolved()).isTrue();
+ }
+
+ @Test
+ public void unresolvedOfResolvedPublishedCommentCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String childCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().resolved().create();
+
+ TestHumanComment comment = changeOperations.change(changeId).comment(childCommentUuid).get();
+
+ assertThat(comment.unresolved()).isFalse();
+ }
+
+ @Test
+ public void draftCommentCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String commentUuid = changeOperations.change(changeId).currentPatchset().newComment().create();
+
+ TestHumanComment comment = changeOperations.change(changeId).comment(commentUuid).get();
+
+ assertThat(comment.uuid()).isEqualTo(commentUuid);
+ }
+
+ @Test
+ public void retrievingPublishedCommentAsDraftCommentFails() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String commentUuid = changeOperations.change(changeId).currentPatchset().newComment().create();
+
+ assertThrows(
+ Exception.class, () -> changeOperations.change(changeId).draftComment(commentUuid).get());
+ }
+
+ @Test
+ public void parentUuidOfDraftCommentCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().create();
+ String childCommentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .parentUuid(parentCommentUuid)
+ .create();
+
+ TestHumanComment comment =
+ changeOperations.change(changeId).draftComment(childCommentUuid).get();
+
+ assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+ }
+
+ @Test
+ public void robotCommentCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+ TestRobotComment comment = changeOperations.change(changeId).robotComment(commentUuid).get();
+
+ assertThat(comment.uuid()).isEqualTo(commentUuid);
+ }
+
+ @Test
+ public void parentUuidOfRobotCommentCanBeRetrieved() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+ String childCommentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .parentUuid(parentCommentUuid)
+ .create();
+
+ TestRobotComment comment =
+ changeOperations.change(changeId).robotComment(childCommentUuid).get();
+
+ assertThat(comment.parentUuid()).value().isEqualTo(parentCommentUuid);
+ }
+
+ private ChangeInfo getChangeFromServer(Change.Id changeId) throws RestApiException {
+ return gApi.changes().id(changeId.get()).get();
+ }
+
+ private RevisionInfo getRevision(ChangeInfo change, PatchSet.Id patchsetId) {
+ return change.revisions.values().stream()
+ .filter(revision -> revision._number == patchsetId.get())
+ .findAny()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format(
+ "Change %d doesn't have specified patchset %d.",
+ change._number, patchsetId.get())));
+ }
+
+ private BinaryResult getFileContent(Change.Id changeId, PatchSet.Id patchsetId, String filePath)
+ throws RestApiException {
+ return gApi.changes().id(changeId.get()).revision(patchsetId.get()).file(filePath).content();
+ }
+
+ private Correspondence<CommitInfo, String> hasSha1() {
+ return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasSha1");
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
new file mode 100644
index 0000000..080c22c
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/change/PatchsetOperationsImplTest.java
@@ -0,0 +1,1245 @@
+// 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.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;
+import static com.google.gerrit.extensions.common.testing.RobotCommentInfoSubject.assertThatList;
+
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+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;
+
+public class PatchsetOperationsImplTest extends AbstractDaemonTest {
+
+ @Inject private ChangeOperations changeOperations;
+ @Inject private AccountOperations accountOperations;
+ @Inject private RequestScopeOperations requestScopeOperations;
+
+ @Test
+ public void commentCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid = changeOperations.change(changeId).currentPatchset().newComment().create();
+ List<CommentInfo> comments = getCommentsFromServer(changeId);
+ assertThatList(comments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+ }
+
+ @Test
+ public void commentCanBeCreatedOnOlderPatchset() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ PatchSet.Id previousPatchsetId =
+ changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ changeOperations.change(changeId).newPatchset().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).patchset(previousPatchsetId).newComment().create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).patchSet().isEqualTo(previousPatchsetId.get());
+ }
+
+ @Test
+ public void commentIsCreatedWithSpecifiedMessage() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .message("Test comment message")
+ .create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).message().isEqualTo("Test comment message");
+ }
+
+ @Test
+ public void commentCanBeCreatedWithEmptyMessage() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().noMessage().create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).message().isNull();
+ }
+
+ @Test
+ public void patchsetLevelCommentCanBeCreated() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().onPatchsetLevel().create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).path().isEqualTo(Patch.PATCHSET_LEVEL);
+ }
+
+ @Test
+ public void fileCommentCanBeCreated() throws Exception {
+ Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .onFileLevelOf("file1")
+ .create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).path().isEqualTo("file1");
+ assertThat(comment).line().isNull();
+ assertThat(comment).range().isNull();
+ }
+
+ @Test
+ public void lineCommentCanBeCreated() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .onLine(3)
+ .ofFile("file1")
+ .create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).line().isEqualTo(3);
+ assertThat(comment).range().isNull();
+ }
+
+ @Test
+ public void rangeCommentCanBeCreated() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .fromLine(2)
+ .charOffset(4)
+ .toLine(3)
+ .charOffset(5)
+ .ofFile("file1")
+ .create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).range().startLine().isEqualTo(2);
+ assertThat(comment).range().startCharacter().isEqualTo(4);
+ assertThat(comment).range().endLine().isEqualTo(3);
+ assertThat(comment).range().endCharacter().isEqualTo(5);
+ // Line is automatically filled from specified range. It's the end line.
+ assertThat(comment).line().isEqualTo(3);
+ }
+
+ @Test
+ public void commentCanBeCreatedOnPatchsetCommit() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .onPatchsetCommit()
+ .create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ // Null is often used instead of Side.REVISION as Side.REVISION is the default.
+ assertThat(comment).side().isAnyOf(Side.REVISION, null);
+ assertThat(comment).parent().isNull();
+ }
+
+ @Test
+ public void commentCanBeCreatedOnParentCommit() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().onParentCommit().create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isEqualTo(1);
+ }
+
+ @Test
+ public void commentCanBeCreatedOnSecondParentCommit() throws Exception {
+ Change.Id parent1ChangeId = changeOperations.newChange().create();
+ Change.Id parent2ChangeId = changeOperations.newChange().create();
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .onSecondParentCommit()
+ .create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isEqualTo(2);
+ }
+
+ @Test
+ public void commentCanBeCreatedOnNonExistingSecondParentCommit() throws Exception {
+ Change.Id parentChangeId = changeOperations.newChange().create();
+ Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .onSecondParentCommit()
+ .create();
+
+ // We want to be able to create such invalid comments for testing purposes (e.g. testing error
+ // handling or resilience of an endpoint) and hence we need to allow such invalid comments in
+ // the test API.
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isEqualTo(2);
+ }
+
+ @Test
+ public void commentCanBeCreatedOnAutoMergeCommit() throws Exception {
+ Change.Id parent1ChangeId = changeOperations.newChange().create();
+ Change.Id parent2ChangeId = changeOperations.newChange().create();
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .onAutoMergeCommit()
+ .create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isNull();
+ }
+
+ @Test
+ public void commentCanBeCreatedAsResolved() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().resolved().create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).unresolved().isFalse();
+ }
+
+ @Test
+ public void commentCanBeCreatedAsUnresolved() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().unresolved().create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).unresolved().isTrue();
+ }
+
+ @Test
+ public void replyToCommentCanBeCreated() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .parentUuid(parentCommentUuid)
+ .create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).inReplyTo().isEqualTo(parentCommentUuid);
+ }
+
+ @Test
+ public void tagCanBeAttachedToAComment() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newComment()
+ .tag("my special tag")
+ .create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).tag().isEqualTo("my special tag");
+ }
+
+ @Test
+ public void commentIsCreatedWithSpecifiedAuthor() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ Account.Id accountId = accountOperations.newAccount().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().author(accountId).create();
+
+ CommentInfo comment = getCommentFromServer(changeId, commentUuid);
+ assertThat(comment).author().id().isEqualTo(accountId.get());
+ }
+
+ @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();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newDraftComment().create();
+
+ List<CommentInfo> comments = getDraftCommentsFromServer(changeId);
+ assertThatList(comments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+ }
+
+ @Test
+ public void draftCommentCanBeCreatedOnOlderPatchset() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ PatchSet.Id previousPatchsetId =
+ changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ changeOperations.change(changeId).newPatchset().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).patchset(previousPatchsetId).newDraftComment().create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).patchSet().isEqualTo(previousPatchsetId.get());
+ }
+
+ @Test
+ public void draftCommentIsCreatedWithSpecifiedMessage() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .message("Test comment message")
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).message().isEqualTo("Test comment message");
+ }
+
+ @Test
+ public void draftCommentCanBeCreatedWithEmptyMessage() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newDraftComment().noMessage().create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).message().isNull();
+ }
+
+ @Test
+ public void draftPatchsetLevelCommentCanBeCreated() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .onPatchsetLevel()
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).path().isEqualTo(Patch.PATCHSET_LEVEL);
+ }
+
+ @Test
+ public void draftFileCommentCanBeCreated() throws Exception {
+ Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .onFileLevelOf("file1")
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).path().isEqualTo("file1");
+ assertThat(comment).line().isNull();
+ assertThat(comment).range().isNull();
+ }
+
+ @Test
+ public void draftLineCommentCanBeCreated() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .onLine(3)
+ .ofFile("file1")
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).line().isEqualTo(3);
+ assertThat(comment).range().isNull();
+ }
+
+ @Test
+ public void draftRangeCommentCanBeCreated() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .fromLine(2)
+ .charOffset(4)
+ .toLine(3)
+ .charOffset(5)
+ .ofFile("file1")
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).range().startLine().isEqualTo(2);
+ assertThat(comment).range().startCharacter().isEqualTo(4);
+ assertThat(comment).range().endLine().isEqualTo(3);
+ assertThat(comment).range().endCharacter().isEqualTo(5);
+ // Line is automatically filled from specified range. It's the end line.
+ assertThat(comment).line().isEqualTo(3);
+ }
+
+ @Test
+ public void draftCommentCanBeCreatedOnPatchsetCommit() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .onPatchsetCommit()
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ // Null is often used instead of Side.REVISION as Side.REVISION is the default.
+ assertThat(comment).side().isAnyOf(Side.REVISION, null);
+ assertThat(comment).parent().isNull();
+ }
+
+ @Test
+ public void draftCommentCanBeCreatedOnParentCommit() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .onParentCommit()
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isEqualTo(1);
+ }
+
+ @Test
+ public void draftCommentCanBeCreatedOnSecondParentCommit() throws Exception {
+ Change.Id parent1ChangeId = changeOperations.newChange().create();
+ Change.Id parent2ChangeId = changeOperations.newChange().create();
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .onSecondParentCommit()
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isEqualTo(2);
+ }
+
+ @Test
+ public void draftCommentCanBeCreatedOnNonExistingSecondParentCommit() throws Exception {
+ Change.Id parentChangeId = changeOperations.newChange().create();
+ Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .onSecondParentCommit()
+ .create();
+
+ // We want to be able to create such invalid comments for testing purposes (e.g. testing error
+ // handling or resilience of an endpoint) and hence we need to allow such invalid comments in
+ // the test API.
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isEqualTo(2);
+ }
+
+ @Test
+ public void draftCommentCanBeCreatedOnAutoMergeCommit() throws Exception {
+ Change.Id parent1ChangeId = changeOperations.newChange().create();
+ Change.Id parent2ChangeId = changeOperations.newChange().create();
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .onAutoMergeCommit()
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isNull();
+ }
+
+ @Test
+ public void draftCommentCanBeCreatedAsResolved() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newDraftComment().resolved().create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).unresolved().isFalse();
+ }
+
+ @Test
+ public void draftCommentCanBeCreatedAsUnresolved() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newDraftComment().unresolved().create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).unresolved().isTrue();
+ }
+
+ @Test
+ public void draftReplyToDraftCommentCanBeCreated() {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newDraftComment().create();
+
+ // Gerrit's other APIs shouldn't support the creation of a draft reply to a draft comment but
+ // there's currently no reason to not support such a comment via the test API if a test really
+ // wants to create such a comment.
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .parentUuid(parentCommentUuid)
+ .create();
+ }
+
+ @Test
+ public void draftReplyToPublishedCommentCanBeCreated() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newComment().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .parentUuid(parentCommentUuid)
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).inReplyTo().isEqualTo(parentCommentUuid);
+ }
+
+ @Test
+ public void tagCanBeAttachedToADraftComment() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .tag("my special tag")
+ .create();
+
+ CommentInfo comment = getDraftCommentFromServer(changeId, commentUuid);
+ assertThat(comment).tag().isEqualTo("my special tag");
+ }
+
+ @Test
+ public void draftCommentIsCreatedWithSpecifiedAuthor() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ Account.Id accountId = accountOperations.newAccount().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newDraftComment()
+ .author(accountId)
+ .create();
+
+ // A user can only retrieve their own draft comments.
+ requestScopeOperations.setApiUser(accountId);
+ List<CommentInfo> comments = getDraftCommentsFromServer(changeId);
+ // Draft comments never have the author field set. As a user can only retrieve their own draft
+ // comments, we implicitly know that the author was correctly set when we find the created
+ // comment in the draft comments of that user.
+ assertThatList(comments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+ }
+
+ @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();
+
+ changeOperations.change(changeId).currentPatchset().newComment().create();
+
+ List<CommentInfo> comments = getDraftCommentsFromServer(changeId);
+ assertThatList(comments).isEmpty();
+ }
+
+ @Test
+ public void noPublishedCommentsAreCreatedOnCreationOfDraftComment() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ changeOperations.change(changeId).currentPatchset().newDraftComment().create();
+
+ List<CommentInfo> comments = getCommentsFromServer(changeId);
+ assertThatList(comments).isEmpty();
+ }
+
+ @Test
+ public void robotCommentCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+ List<RobotCommentInfo> robotComments = getRobotCommentsFromServerFromCurrentPatchset(changeId);
+ assertThatList(robotComments).comparingElementsUsing(hasUuid()).containsExactly(commentUuid);
+ }
+
+ @Test
+ public void robotCommentCanBeCreatedOnOlderPatchset() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ PatchSet.Id previousPatchsetId =
+ changeOperations.change(changeId).currentPatchset().get().patchsetId();
+ changeOperations.change(changeId).newPatchset().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).patchset(previousPatchsetId).newRobotComment().create();
+
+ CommentInfo comment = getRobotCommentFromServer(previousPatchsetId, commentUuid);
+ assertThat(comment).uuid().isEqualTo(commentUuid);
+ }
+
+ @Test
+ public void robotCommentIsCreatedWithSpecifiedMessage() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .message("Test comment message")
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).message().isEqualTo("Test comment message");
+ }
+
+ @Test
+ public void robotCommentCanBeCreatedWithEmptyMessage() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().noMessage().create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).message().isNull();
+ }
+
+ @Test
+ public void patchsetLevelRobotCommentCanBeCreated() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .onPatchsetLevel()
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).path().isEqualTo(Patch.PATCHSET_LEVEL);
+ }
+
+ @Test
+ public void fileRobotCommentCanBeCreated() throws Exception {
+ Change.Id changeId = changeOperations.newChange().file("file1").content("Line 1").create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .onFileLevelOf("file1")
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).path().isEqualTo("file1");
+ assertThat(comment).line().isNull();
+ assertThat(comment).range().isNull();
+ }
+
+ @Test
+ public void lineRobotCommentCanBeCreated() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .onLine(3)
+ .ofFile("file1")
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).line().isEqualTo(3);
+ assertThat(comment).range().isNull();
+ }
+
+ @Test
+ public void rangeRobotCommentCanBeCreated() throws Exception {
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .file("file1")
+ .content("Line 1\nLine 2\nLine 3\nLine 4\n")
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .fromLine(2)
+ .charOffset(4)
+ .toLine(3)
+ .charOffset(5)
+ .ofFile("file1")
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).range().startLine().isEqualTo(2);
+ assertThat(comment).range().startCharacter().isEqualTo(4);
+ assertThat(comment).range().endLine().isEqualTo(3);
+ assertThat(comment).range().endCharacter().isEqualTo(5);
+ // Line is automatically filled from specified range. It's the end line.
+ assertThat(comment).line().isEqualTo(3);
+ }
+
+ @Test
+ public void robotCommentCanBeCreatedOnPatchsetCommit() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .onPatchsetCommit()
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ // Null is often used instead of Side.REVISION as Side.REVISION is the default.
+ assertThat(comment).side().isAnyOf(Side.REVISION, null);
+ assertThat(comment).parent().isNull();
+ }
+
+ @Test
+ public void robotCommentCanBeCreatedOnParentCommit() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .onParentCommit()
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isEqualTo(1);
+ }
+
+ @Test
+ public void robotCommentCanBeCreatedOnSecondParentCommit() throws Exception {
+ Change.Id parent1ChangeId = changeOperations.newChange().create();
+ Change.Id parent2ChangeId = changeOperations.newChange().create();
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .onSecondParentCommit()
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isEqualTo(2);
+ }
+
+ @Test
+ public void robotCommentCanBeCreatedOnNonExistingSecondParentCommit() throws Exception {
+ Change.Id parentChangeId = changeOperations.newChange().create();
+ Change.Id changeId = changeOperations.newChange().childOf().change(parentChangeId).create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .onSecondParentCommit()
+ .create();
+
+ // We want to be able to create such invalid robot comments for testing purposes (e.g. testing
+ // error handling or resilience of an endpoint) and hence we need to allow such invalid robot
+ // comments in the test API.
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isEqualTo(2);
+ }
+
+ @Test
+ public void robotCommentCanBeCreatedOnAutoMergeCommit() throws Exception {
+ Change.Id parent1ChangeId = changeOperations.newChange().create();
+ Change.Id parent2ChangeId = changeOperations.newChange().create();
+ Change.Id changeId =
+ changeOperations
+ .newChange()
+ .mergeOf()
+ .change(parent1ChangeId)
+ .and()
+ .change(parent2ChangeId)
+ .create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .onAutoMergeCommit()
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).side().isEqualTo(Side.PARENT);
+ assertThat(comment).parent().isNull();
+ }
+
+ @Test
+ public void replyToRobotCommentCanBeCreated() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ String parentCommentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .parentUuid(parentCommentUuid)
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).inReplyTo().isEqualTo(parentCommentUuid);
+ }
+
+ @Test
+ public void tagCanBeAttachedToARobotComment() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .tag("my special tag")
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).tag().isEqualTo("my special tag");
+ }
+
+ @Test
+ public void robotCommentIsCreatedWithSpecifiedAuthor() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+ Account.Id accountId = accountOperations.newAccount().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .author(accountId)
+ .create();
+
+ CommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).author().id().isEqualTo(accountId.get());
+ }
+
+ @Test
+ public void robotCommentIsCreatedWithRobotId() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .robotId("robot-id")
+ .create();
+
+ RobotCommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).robotId().isEqualTo("robot-id");
+ }
+
+ @Test
+ public void robotCommentIsCreatedWithRobotRunId() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .robotId("robot-run-id")
+ .create();
+
+ RobotCommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).robotId().isEqualTo("robot-run-id");
+ }
+
+ @Test
+ public void robotCommentIsCreatedWithUrl() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations.change(changeId).currentPatchset().newRobotComment().url("url").create();
+
+ RobotCommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).url().isEqualTo("url");
+ }
+
+ @Test
+ public void robotCommentIsCreatedWithProperty() throws Exception {
+ Change.Id changeId = changeOperations.newChange().create();
+
+ String commentUuid =
+ changeOperations
+ .change(changeId)
+ .currentPatchset()
+ .newRobotComment()
+ .addProperty("key", "value")
+ .create();
+
+ RobotCommentInfo comment = getRobotCommentFromServerInCurrentPatchset(changeId, commentUuid);
+ assertThat(comment).properties().containsExactly("key", "value");
+ }
+
+ private List<CommentInfo> getCommentsFromServer(Change.Id changeId) throws RestApiException {
+ return gApi.changes().id(changeId.get()).commentsRequest().getAsList();
+ }
+
+ private List<RobotCommentInfo> getRobotCommentsFromServerFromCurrentPatchset(Change.Id changeId)
+ throws RestApiException {
+ return gApi.changes().id(changeId.get()).current().robotCommentsAsList();
+ }
+
+ private List<CommentInfo> getDraftCommentsFromServer(Change.Id changeId) throws RestApiException {
+ return gApi.changes().id(changeId.get()).draftsAsList();
+ }
+
+ private CommentInfo getCommentFromServer(Change.Id changeId, String uuid)
+ throws RestApiException {
+ return gApi.changes().id(changeId.get()).commentsRequest().getAsList().stream()
+ .filter(comment -> comment.id.equals(uuid))
+ .findAny()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format("Comment %s not found on change %d", uuid, changeId.get())));
+ }
+
+ private RobotCommentInfo getRobotCommentFromServerInCurrentPatchset(
+ Change.Id changeId, String uuid) throws RestApiException {
+ return gApi.changes().id(changeId.get()).current().robotCommentsAsList().stream()
+ .filter(comment -> comment.id.equals(uuid))
+ .findAny()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format(
+ "Robot Comment %s not found on change %d on the latest patchset",
+ uuid, changeId.get())));
+ }
+
+ private RobotCommentInfo getRobotCommentFromServer(PatchSet.Id patchsetId, String uuid)
+ throws RestApiException {
+ return gApi.changes().id(patchsetId.changeId().toString())
+ .revision(patchsetId.getId().toString()).robotCommentsAsList().stream()
+ .filter(comment -> comment.id.equals(uuid))
+ .findAny()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format(
+ "Robot Comment %s not found on change %d on patchset %d",
+ uuid, patchsetId.changeId().get(), patchsetId.get())));
+ }
+
+ private CommentInfo getDraftCommentFromServer(Change.Id changeId, String uuid)
+ throws RestApiException {
+ return gApi.changes().id(changeId.get()).draftsAsList().stream()
+ .filter(comment -> comment.id.equals(uuid))
+ .findAny()
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ String.format(
+ "Draft comment %s not found on change %d", uuid, changeId.get())));
+ }
+
+ private Correspondence<CommentInfo, String> hasUuid() {
+ return NullAwareCorrespondence.transforming(comment -> comment.id, "hasUuid");
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
index 3e8c017..00d01d6 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImplTest.java
@@ -30,21 +30,24 @@
import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
import static com.google.gerrit.server.group.SystemGroupBackend.PROJECT_OWNERS;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static com.google.gerrit.truth.ConfigSubject.assertThat;
import static java.util.stream.Collectors.toList;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.truth.Correspondence;
import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.projects.BranchInfo;
import com.google.gerrit.extensions.api.projects.ConfigInput;
import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.truth.NullAwareCorrespondence;
import com.google.inject.Inject;
import java.util.List;
import org.eclipse.jgit.junit.TestRepository;
@@ -56,6 +59,7 @@
public class ProjectOperationsImplTest extends AbstractDaemonTest {
@Inject private ProjectOperations projectOperations;
+ @Inject private GroupOperations groupsOperations;
@Test
public void defaultName() throws Exception {
@@ -84,6 +88,50 @@
}
@Test
+ public void specifiedBranchesAreCreatedInNewProject() throws Exception {
+ Project.NameKey project =
+ projectOperations
+ .newProject()
+ .branches("test-branch", "refs/heads/another-test-branch")
+ .create();
+
+ List<BranchInfo> branches = gApi.projects().name(project.get()).branches().get();
+ assertThat(branches)
+ .comparingElementsUsing(hasBranchName())
+ .containsAtLeast("refs/heads/test-branch", "refs/heads/another-test-branch");
+ }
+
+ @Test
+ public void specifiedBranchesAreNotCreatedInNewProjectIfNoEmptyCommitRequested()
+ throws Exception {
+ Project.NameKey project =
+ projectOperations
+ .newProject()
+ .branches("test-branch", "refs/heads/another-test-branch")
+ .noEmptyCommit()
+ .create();
+
+ List<BranchInfo> branches = gApi.projects().name(project.get()).branches().get();
+ assertThat(branches)
+ .comparingElementsUsing(hasBranchName())
+ .containsNoneOf("refs/heads/test-branch", "refs/heads/another-test-branch");
+ }
+
+ @Test
+ public void permissionOnly() throws Exception {
+ Project.NameKey key = projectOperations.newProject().permissionOnly(true).create();
+ String head = gApi.projects().name(key.get()).head();
+ assertThat(head).isEqualTo(RefNames.REFS_CONFIG);
+ }
+
+ @Test
+ public void createWithOwners() throws Exception {
+ AccountGroup.UUID uuid = groupsOperations.newGroup().create();
+ Project.NameKey key = projectOperations.newProject().addOwner(uuid).create();
+ assertPermissions(key, groupRef(uuid), "refs/*", false, Permission.OWNER);
+ }
+
+ @Test
public void getProjectConfig() throws Exception {
Project.NameKey key = projectOperations.newProject().create();
assertThat(projectOperations.project(key).getProjectConfig().getProject().getDescription())
@@ -98,23 +146,6 @@
}
@Test
- public void mutatingResultOfGetProjectConfigDoesNotMutateGlobalCachedValue() throws Exception {
- Project.NameKey key = projectOperations.newProject().create();
- ProjectConfig projectConfig = projectOperations.project(key).getProjectConfig();
- ProjectState cachedProjectState1 = projectCache.get(key).orElseThrow(illegalState(project));
- ProjectConfig cachedProjectConfig1 = cachedProjectState1.getBareConfig();
- assertThat(cachedProjectConfig1).isNotSameInstanceAs(projectConfig);
- assertThat(cachedProjectConfig1.getProject().getDescription()).isEmpty();
- assertThat(projectConfig.getProject().getDescription()).isEmpty();
- projectConfig.updateProject(p -> p.setDescription("my fancy project"));
-
- ProjectConfig cachedProjectConfig2 =
- projectCache.get(key).orElseThrow(illegalState(project)).getBareConfig();
- assertThat(cachedProjectConfig2).isNotSameInstanceAs(projectConfig);
- assertThat(cachedProjectConfig2.getProject().getDescription()).isEmpty();
- }
-
- @Test
public void getProjectConfigNoRefsMetaConfig() throws Exception {
Project.NameKey key = projectOperations.newProject().create();
deleteRefsMetaConfig(key);
@@ -590,4 +621,8 @@
tr.delete(REFS_CONFIG);
}
}
+
+ private static Correspondence<BranchInfo, String> hasBranchName() {
+ return NullAwareCorrespondence.transforming(branch -> branch.ref, "hasName");
+ }
}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
index 8fc1677..e0d0d25 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/project/TestProjectUpdateTest.java
@@ -26,7 +26,7 @@
import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_BATCH_CHANGES_LIMIT;
import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
import static com.google.gerrit.common.data.GlobalCapability.QUERY_LIMIT;
-import static com.google.gerrit.common.data.Permission.ABANDON;
+import static com.google.gerrit.entities.Permission.ABANDON;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
index f6421a5..48fd38c 100644
--- a/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
+++ b/javatests/com/google/gerrit/acceptance/testsuite/request/RequestScopeOperationsImplTest.java
@@ -16,7 +16,6 @@
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
-import static com.google.common.truth.Truth8.assertThat;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import com.google.common.collect.ImmutableSet;
@@ -29,7 +28,6 @@
import com.google.gerrit.extensions.common.ChangeInput;
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.CurrentUser.PropertyKey;
import com.google.gerrit.server.notedb.Sequences;
import com.google.inject.Inject;
import com.google.inject.Provider;
@@ -75,19 +73,6 @@
}
@Test
- public void resetCurrentApiUserClearsCachedState() throws Exception {
- requestScopeOperations.setApiUser(user.id());
- PropertyKey<String> key = PropertyKey.create();
- atrScope.get().getUser().put(key, "foo");
- assertThat(atrScope.get().getUser().get(key)).hasValue("foo");
-
- AcceptanceTestRequestScope.Context oldCtx = requestScopeOperations.resetCurrentApiUser();
- checkCurrentUser(user.id());
- assertThat(atrScope.get().getUser().get(key)).isEmpty();
- assertThat(oldCtx.getUser().get(key)).hasValue("foo");
- }
-
- @Test
public void setApiUserAnonymousSetsAnonymousUser() throws Exception {
fastCheckCurrentUser(admin.id());
requestScopeOperations.setApiUserAnonymous();
diff --git a/javatests/com/google/gerrit/common/data/AccessSectionTest.java b/javatests/com/google/gerrit/common/data/AccessSectionTest.java
deleted file mode 100644
index f60156c..0000000
--- a/javatests/com/google/gerrit/common/data/AccessSectionTest.java
+++ /dev/null
@@ -1,242 +0,0 @@
-// 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.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import java.util.Locale;
-import org.junit.Before;
-import org.junit.Test;
-
-public class AccessSectionTest {
- private static final String REF_PATTERN = "refs/heads/master";
-
- private AccessSection.Builder accessSection;
-
- @Before
- public void setup() {
- this.accessSection = AccessSection.builder(REF_PATTERN);
- }
-
- @Test
- public void getName() {
- assertThat(accessSection.getName()).isEqualTo(REF_PATTERN);
- }
-
- @Test
- public void getEmptyPermissions() {
- AccessSection builtAccessSection = accessSection.build();
- assertThat(builtAccessSection.getPermissions()).isNotNull();
- assertThat(builtAccessSection.getPermissions()).isEmpty();
- }
-
- @Test
- public void setAndGetPermissions() {
- Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
- Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
- accessSection.modifyPermissions(
- permissions -> {
- permissions.clear();
- permissions.add(abandonPermission);
- permissions.add(rebasePermission);
- });
-
- AccessSection builtAccessSection = accessSection.build();
- assertThat(builtAccessSection.getPermissions()).hasSize(2);
- assertThat(builtAccessSection.getPermission(abandonPermission.getName())).isNotNull();
- assertThat(builtAccessSection.getPermission(rebasePermission.getName())).isNotNull();
-
- Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
- accessSection.modifyPermissions(
- p -> {
- p.clear();
- p.add(submitPermission);
- });
- builtAccessSection = accessSection.build();
- assertThat(builtAccessSection.getPermissions()).hasSize(1);
- assertThat(builtAccessSection.getPermission(submitPermission.getName())).isNotNull();
- assertThrows(NullPointerException.class, () -> accessSection.setPermissions(null));
- }
-
- @Test
- public void cannotSetDuplicatePermissions() {
- assertThrows(
- IllegalArgumentException.class,
- () ->
- accessSection
- .addPermission(Permission.builder(Permission.ABANDON))
- .addPermission(Permission.builder(Permission.ABANDON))
- .build());
- }
-
- @Test
- public void cannotSetPermissionsWithConflictingNames() {
- Permission.Builder abandonPermissionLowerCase =
- Permission.builder(Permission.ABANDON.toLowerCase(Locale.US));
- Permission.Builder abandonPermissionUpperCase =
- Permission.builder(Permission.ABANDON.toUpperCase(Locale.US));
-
- assertThrows(
- IllegalArgumentException.class,
- () ->
- accessSection
- .addPermission(abandonPermissionLowerCase)
- .addPermission(abandonPermissionUpperCase)
- .build());
- }
-
- @Test
- public void getNonExistingPermission() {
- assertThat(accessSection.build().getPermission("non-existing")).isNull();
- assertThat(accessSection.build().getPermission("non-existing")).isNull();
- }
-
- @Test
- public void getPermission() {
- Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
- accessSection.addPermission(submitPermission);
- assertThat(accessSection.upsertPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
- assertThrows(NullPointerException.class, () -> accessSection.upsertPermission(null));
- }
-
- @Test
- public void getPermissionWithOtherCase() {
- Permission.Builder submitPermissionLowerCase =
- Permission.builder(Permission.SUBMIT.toLowerCase(Locale.US));
- accessSection.addPermission(submitPermissionLowerCase);
- assertThat(accessSection.upsertPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
- .isEqualTo(submitPermissionLowerCase);
- }
-
- @Test
- public void createMissingPermissionOnGet() {
- assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
-
- assertThat(accessSection.upsertPermission(Permission.SUBMIT).build())
- .isEqualTo(Permission.create(Permission.SUBMIT));
-
- assertThrows(NullPointerException.class, () -> accessSection.upsertPermission(null));
- }
-
- @Test
- public void addPermission() {
- Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
- Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
-
- accessSection.addPermission(abandonPermission);
- accessSection.addPermission(rebasePermission);
- assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
-
- Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
- accessSection.addPermission(submitPermission);
- assertThat(accessSection.build().getPermission(Permission.SUBMIT))
- .isEqualTo(submitPermission.build());
- assertThat(accessSection.build().getPermissions())
- .containsExactly(
- abandonPermission.build(), rebasePermission.build(), submitPermission.build())
- .inOrder();
- assertThrows(NullPointerException.class, () -> accessSection.addPermission(null));
- }
-
- @Test
- public void removePermission() {
- Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
- Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
- Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
-
- accessSection.addPermission(abandonPermission);
- accessSection.addPermission(rebasePermission);
- accessSection.addPermission(submitPermission);
- assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNotNull();
-
- accessSection.remove(submitPermission);
- assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
- assertThat(accessSection.build().getPermissions())
- .containsExactly(abandonPermission.build(), rebasePermission.build())
- .inOrder();
- assertThrows(NullPointerException.class, () -> accessSection.remove(null));
- }
-
- @Test
- public void removePermissionByName() {
- Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
- Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
- Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
-
- accessSection.addPermission(abandonPermission);
- accessSection.addPermission(rebasePermission);
- accessSection.addPermission(submitPermission);
- AccessSection builtAccessSection = accessSection.build();
- assertThat(builtAccessSection.getPermission(Permission.SUBMIT)).isNotNull();
-
- accessSection.removePermission(Permission.SUBMIT);
- builtAccessSection = accessSection.build();
- assertThat(builtAccessSection.getPermission(Permission.SUBMIT)).isNull();
- assertThat(builtAccessSection.getPermissions())
- .containsExactly(abandonPermission.build(), rebasePermission.build())
- .inOrder();
-
- assertThrows(NullPointerException.class, () -> accessSection.removePermission(null));
- }
-
- @Test
- public void removePermissionByNameOtherCase() {
- Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
- Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
-
- String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
- String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
- Permission.Builder submitPermissionLowerCase = Permission.builder(submitLowerCase);
-
- accessSection.addPermission(abandonPermission);
- accessSection.addPermission(rebasePermission);
- accessSection.addPermission(submitPermissionLowerCase);
- AccessSection builtAccessSection = accessSection.build();
- assertThat(builtAccessSection.getPermission(submitLowerCase)).isNotNull();
- assertThat(builtAccessSection.getPermission(submitUpperCase)).isNotNull();
-
- accessSection.removePermission(submitUpperCase);
- builtAccessSection = accessSection.build();
- assertThat(builtAccessSection.getPermission(submitLowerCase)).isNull();
- assertThat(builtAccessSection.getPermission(submitUpperCase)).isNull();
- assertThat(builtAccessSection.getPermissions())
- .containsExactly(abandonPermission.build(), rebasePermission.build())
- .inOrder();
- }
-
- @Test
- public void testEquals() {
- Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
- Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
-
- accessSection.addPermission(abandonPermission);
- accessSection.addPermission(rebasePermission);
-
- AccessSection builtAccessSection = accessSection.build();
- AccessSection.Builder accessSectionSamePermissionsOtherRef =
- AccessSection.builder("refs/heads/other");
- accessSectionSamePermissionsOtherRef.addPermission(abandonPermission);
- accessSectionSamePermissionsOtherRef.addPermission(rebasePermission);
- assertThat(builtAccessSection.equals(accessSectionSamePermissionsOtherRef.build())).isFalse();
-
- AccessSection.Builder accessSectionOther = AccessSection.builder(REF_PATTERN);
- accessSectionOther.addPermission(abandonPermission);
- assertThat(builtAccessSection.equals(accessSectionOther.build())).isFalse();
-
- accessSectionOther.addPermission(rebasePermission);
- assertThat(builtAccessSection.equals(accessSectionOther.build())).isTrue();
- }
-}
diff --git a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java b/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
deleted file mode 100644
index 298ce1e..0000000
--- a/javatests/com/google/gerrit/common/data/LabelFunctionTest.java
+++ /dev/null
@@ -1,146 +0,0 @@
-// 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.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelId;
-import com.google.gerrit.entities.LabelValue;
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSetApproval;
-import java.time.Instant;
-import java.util.ArrayList;
-import java.util.Date;
-import java.util.List;
-import org.junit.Test;
-
-public class LabelFunctionTest {
- private static final String LABEL_NAME = "Verified";
- private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
- private static final Change.Id CHANGE_ID = Change.id(100);
- private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
- private static final LabelType VERIFIED_LABEL = makeLabel();
- private static final PatchSetApproval APPROVAL_2 = makeApproval(2);
- private static final PatchSetApproval APPROVAL_1 = makeApproval(1);
- private static final PatchSetApproval APPROVAL_0 = makeApproval(0);
- private static final PatchSetApproval APPROVAL_M1 = makeApproval(-1);
- private static final PatchSetApproval APPROVAL_M2 = makeApproval(-2);
-
- @Test
- public void checkLabelNameIsCorrect() {
- for (LabelFunction function : LabelFunction.values()) {
- SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
- assertThat(myLabel.label).isEqualTo("Verified");
- }
- }
-
- @Test
- public void checkFunctionDoesNothing() {
- checkNothingHappens(LabelFunction.NO_BLOCK);
- checkNothingHappens(LabelFunction.NO_OP);
- checkNothingHappens(LabelFunction.PATCH_SET_LOCK);
- checkNothingHappens(LabelFunction.ANY_WITH_BLOCK);
-
- checkLabelIsRequired(LabelFunction.MAX_WITH_BLOCK);
- checkLabelIsRequired(LabelFunction.MAX_NO_BLOCK);
- }
-
- @Test
- public void checkBlockWorks() {
- checkBlockWorks(LabelFunction.ANY_WITH_BLOCK);
- checkBlockWorks(LabelFunction.MAX_WITH_BLOCK);
- }
-
- @Test
- public void checkMaxWorks() {
- checkMaxIsEnforced(LabelFunction.MAX_NO_BLOCK);
- checkMaxIsEnforced(LabelFunction.MAX_WITH_BLOCK);
-
- checkMaxValidatesTheLabel(LabelFunction.MAX_NO_BLOCK);
- checkMaxValidatesTheLabel(LabelFunction.MAX_WITH_BLOCK);
- }
-
- @Test
- public void checkMaxNoBlockIgnoresMin() {
- List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_M2, APPROVAL_2, APPROVAL_M2);
-
- SubmitRecord.Label myLabel = LabelFunction.MAX_NO_BLOCK.check(VERIFIED_LABEL, approvals);
-
- assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
- assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
- }
-
- private static LabelType makeLabel() {
- List<LabelValue> values = new ArrayList<>();
- // The label text is irrelevant here, only the numerical value is used
- values.add(LabelValue.create((short) -2, "Great job, please fix compilation."));
- values.add(LabelValue.create((short) -1, "Really good, please make some minor changes."));
- values.add(LabelValue.create((short) 0, "No vote."));
- values.add(LabelValue.create((short) 1, "Closest thing perfection."));
- values.add(LabelValue.create((short) 2, "Perfect!"));
- return LabelType.create(LABEL_NAME, values);
- }
-
- private static PatchSetApproval makeApproval(int value) {
- return PatchSetApproval.builder()
- .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
- .value(value)
- .granted(Date.from(Instant.now()))
- .build();
- }
-
- private static void checkBlockWorks(LabelFunction function) {
- List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_M2, APPROVAL_2);
-
- SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
-
- assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.REJECT);
- assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.accountId());
- }
-
- private static void checkNothingHappens(LabelFunction function) {
- SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-
- assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.MAY);
- assertThat(myLabel.appliedBy).isNull();
- }
-
- private static void checkLabelIsRequired(LabelFunction function) {
- SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
-
- assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
- assertThat(myLabel.appliedBy).isNull();
- }
-
- private static void checkMaxIsEnforced(LabelFunction function) {
- List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_0);
-
- SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
-
- assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
- }
-
- private static void checkMaxValidatesTheLabel(LabelFunction function) {
- List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_2, APPROVAL_M1);
-
- SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
-
- assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
- assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
- }
-}
diff --git a/javatests/com/google/gerrit/common/data/LabelTypeTest.java b/javatests/com/google/gerrit/common/data/LabelTypeTest.java
deleted file mode 100644
index 4810f58..0000000
--- a/javatests/com/google/gerrit/common/data/LabelTypeTest.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// 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.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.entities.LabelValue;
-import org.junit.Test;
-
-public class LabelTypeTest {
- @Test
- public void sortLabelValues() {
- LabelValue v0 = LabelValue.create((short) 0, "Zero");
- LabelValue v1 = LabelValue.create((short) 1, "One");
- LabelValue v2 = LabelValue.create((short) 2, "Two");
- LabelType types = LabelType.create("Label", ImmutableList.of(v2, v0, v1));
- assertThat(types.getValues()).containsExactly(v0, v1, v2).inOrder();
- }
-
- @Test
- public void sortCopyValues() {
- LabelValue v0 = LabelValue.create((short) 0, "Zero");
- LabelValue v1 = LabelValue.create((short) 1, "One");
- LabelValue v2 = LabelValue.create((short) 2, "Two");
- LabelType types =
- LabelType.builder("Label", ImmutableList.of(v2, v0, v1))
- .setCopyValues(ImmutableList.of((short) 2, (short) 0, (short) 1))
- .build();
- assertThat(types.getCopyValues()).containsExactly((short) 0, (short) 1, (short) 2).inOrder();
- }
-
- @Test
- public void insertMissingLabelValues() {
- LabelValue v0 = LabelValue.create((short) 0, "Zero");
- LabelValue v2 = LabelValue.create((short) 2, "Two");
- LabelValue v5 = LabelValue.create((short) 5, "Five");
- LabelType types = LabelType.create("Label", ImmutableList.of(v2, v5, v0));
- assertThat(types.getValues())
- .containsExactly(
- v0,
- LabelValue.create((short) 1, ""),
- v2,
- LabelValue.create((short) 3, ""),
- LabelValue.create((short) 4, ""),
- v5)
- .inOrder();
- }
-}
diff --git a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java b/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
deleted file mode 100644
index ee6590a..0000000
--- a/javatests/com/google/gerrit/common/data/PermissionRuleTest.java
+++ /dev/null
@@ -1,302 +0,0 @@
-// 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.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.GroupReference;
-import org.junit.Before;
-import org.junit.Test;
-
-public class PermissionRuleTest {
- private GroupReference groupReference;
- private PermissionRule permissionRule;
-
- @Before
- public void setup() {
- this.groupReference = GroupReference.create(AccountGroup.uuid("uuid"), "group");
- this.permissionRule = PermissionRule.create(groupReference);
- }
-
- @Test
- public void mergeFromAnyBlock() {
- GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
- PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
-
- GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
- PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
-
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.isBlock()).isFalse();
- assertThat(permissionRule2.isBlock()).isFalse();
-
- permissionRule2 = permissionRule2.toBuilder().setBlock().build();
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.isBlock()).isTrue();
- assertThat(permissionRule2.isBlock()).isTrue();
-
- permissionRule2 = permissionRule2.toBuilder().setDeny().build();
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.isBlock()).isTrue();
- assertThat(permissionRule2.isBlock()).isFalse();
-
- permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.isBlock()).isTrue();
- assertThat(permissionRule2.isBlock()).isFalse();
- }
-
- @Test
- public void mergeFromAnyDeny() {
- GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
- PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
-
- GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
- PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
-
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.isDeny()).isFalse();
- assertThat(permissionRule2.isDeny()).isFalse();
-
- permissionRule2 = permissionRule2.toBuilder().setDeny().build();
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.isDeny()).isTrue();
- assertThat(permissionRule2.isDeny()).isTrue();
-
- permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.isDeny()).isTrue();
- assertThat(permissionRule2.isDeny()).isFalse();
- }
-
- @Test
- public void mergeFromAnyBatch() {
- GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
- PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
-
- GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
- PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
-
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.getAction()).isNotEqualTo(Action.BATCH);
- assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
-
- permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
- assertThat(permissionRule2.getAction()).isEqualTo(Action.BATCH);
-
- permissionRule2 = permissionRule2.toBuilder().setAction(Action.ALLOW).build();
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
- assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
- }
-
- @Test
- public void mergeFromAnyForce() {
- GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
- PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
-
- GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
- PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
-
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.getForce()).isFalse();
- assertThat(permissionRule2.getForce()).isFalse();
-
- permissionRule2 = permissionRule2.toBuilder().setForce(true).build();
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.getForce()).isTrue();
- assertThat(permissionRule2.getForce()).isTrue();
-
- permissionRule2 = permissionRule2.toBuilder().setForce(false).build();
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.getForce()).isTrue();
- assertThat(permissionRule2.getForce()).isFalse();
- }
-
- @Test
- public void mergeFromMergeRange() {
- GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
- PermissionRule permissionRule1 =
- PermissionRule.builder(groupReference1).setRange(-1, 2).build();
-
- GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
- PermissionRule permissionRule2 =
- PermissionRule.builder(groupReference2).setRange(-2, 1).build();
-
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.getMin()).isEqualTo(-2);
- assertThat(permissionRule1.getMax()).isEqualTo(2);
- assertThat(permissionRule2.getMin()).isEqualTo(-2);
- assertThat(permissionRule2.getMax()).isEqualTo(1);
- }
-
- @Test
- public void mergeFromGroupNotChanged() {
- GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
- PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
-
- GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
- PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
-
- permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
- assertThat(permissionRule1.getGroup()).isEqualTo(groupReference1);
- assertThat(permissionRule2.getGroup()).isEqualTo(groupReference2);
- }
-
- @Test
- public void asString() {
- PermissionRule.Builder permissionRule = this.permissionRule.toBuilder();
-
- assertThat(permissionRule.build().asString(true))
- .isEqualTo("group " + groupReference.getName());
-
- permissionRule.setDeny();
- assertThat(permissionRule.build().asString(true))
- .isEqualTo("deny group " + groupReference.getName());
-
- permissionRule.setBlock();
- assertThat(permissionRule.build().asString(true))
- .isEqualTo("block group " + groupReference.getName());
-
- permissionRule.setAction(Action.BATCH);
- assertThat(permissionRule.build().asString(true))
- .isEqualTo("batch group " + groupReference.getName());
-
- permissionRule.setAction(Action.INTERACTIVE);
- assertThat(permissionRule.build().asString(true))
- .isEqualTo("interactive group " + groupReference.getName());
-
- permissionRule.setForce(true);
- assertThat(permissionRule.build().asString(true))
- .isEqualTo("interactive +force group " + groupReference.getName());
-
- permissionRule.setAction(Action.ALLOW);
- assertThat(permissionRule.build().asString(true))
- .isEqualTo("+force group " + groupReference.getName());
-
- permissionRule.setMax(1);
- assertThat(permissionRule.build().asString(true))
- .isEqualTo("+force +0..+1 group " + groupReference.getName());
-
- permissionRule.setMin(-1);
- assertThat(permissionRule.build().asString(true))
- .isEqualTo("+force -1..+1 group " + groupReference.getName());
-
- assertThat(permissionRule.build().asString(false))
- .isEqualTo("+force group " + groupReference.getName());
- }
-
- @Test
- public void fromString() {
- PermissionRule permissionRule = PermissionRule.fromString("group A", true);
- assertPermissionRule(permissionRule, "A", Action.ALLOW, false, 0, 0);
-
- permissionRule = PermissionRule.fromString("deny group A", true);
- assertPermissionRule(permissionRule, "A", Action.DENY, false, 0, 0);
-
- permissionRule = PermissionRule.fromString("block group A", true);
- assertPermissionRule(permissionRule, "A", Action.BLOCK, false, 0, 0);
-
- permissionRule = PermissionRule.fromString("batch group A", true);
- assertPermissionRule(permissionRule, "A", Action.BATCH, false, 0, 0);
-
- permissionRule = PermissionRule.fromString("interactive group A", true);
- assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, false, 0, 0);
-
- permissionRule = PermissionRule.fromString("interactive +force group A", true);
- assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, true, 0, 0);
-
- permissionRule = PermissionRule.fromString("+force group A", true);
- assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
-
- permissionRule = PermissionRule.fromString("+force +0..+1 group A", true);
- assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 1);
-
- permissionRule = PermissionRule.fromString("+force -1..+1 group A", true);
- assertPermissionRule(permissionRule, "A", Action.ALLOW, true, -1, 1);
-
- permissionRule = PermissionRule.fromString("+force group A", false);
- assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
- }
-
- @Test
- public void parseInt() {
- assertThat(PermissionRule.parseInt("0")).isEqualTo(0);
- assertThat(PermissionRule.parseInt("+0")).isEqualTo(0);
- assertThat(PermissionRule.parseInt("-0")).isEqualTo(0);
- assertThat(PermissionRule.parseInt("1")).isEqualTo(1);
- assertThat(PermissionRule.parseInt("+1")).isEqualTo(1);
- assertThat(PermissionRule.parseInt("-1")).isEqualTo(-1);
- }
-
- @Test
- public void testEquals() {
- GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
- PermissionRule.Builder permissionRuleOther = PermissionRule.builder(groupReference2);
- PermissionRule.Builder permissionRule = this.permissionRule.toBuilder();
-
- assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
-
- permissionRuleOther.setGroup(groupReference);
- assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
-
- permissionRule.setDeny();
- assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
-
- permissionRuleOther.setDeny();
- assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
-
- permissionRule.setForce(true);
- assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
-
- permissionRuleOther.setForce(true);
- assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
-
- permissionRule.setMin(-1);
- assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
-
- permissionRuleOther.setMin(-1);
- assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
-
- permissionRule.setMax(1);
- assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
-
- permissionRuleOther.setMax(1);
- assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
- }
-
- private static boolean permissionRuleEquals(
- PermissionRule.Builder r1, PermissionRule.Builder r2) {
- return r1.build().equals(r2.build());
- }
-
- private void assertPermissionRule(
- PermissionRule permissionRule,
- String expectedGroupName,
- Action expectedAction,
- boolean expectedForce,
- int expectedMin,
- int expectedMax) {
- assertThat(permissionRule.getGroup().getName()).isEqualTo(expectedGroupName);
- assertThat(permissionRule.getAction()).isEqualTo(expectedAction);
- assertThat(permissionRule.getForce()).isEqualTo(expectedForce);
- assertThat(permissionRule.getMin()).isEqualTo(expectedMin);
- assertThat(permissionRule.getMax()).isEqualTo(expectedMax);
- }
-}
diff --git a/javatests/com/google/gerrit/common/data/PermissionTest.java b/javatests/com/google/gerrit/common/data/PermissionTest.java
deleted file mode 100644
index ac3e2c5..0000000
--- a/javatests/com/google/gerrit/common/data/PermissionTest.java
+++ /dev/null
@@ -1,293 +0,0 @@
-// 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.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.GroupReference;
-import org.junit.Before;
-import org.junit.Test;
-
-public class PermissionTest {
- private static final String PERMISSION_NAME = "foo";
-
- private Permission.Builder permission;
-
- @Before
- public void setup() {
- this.permission = Permission.builder(PERMISSION_NAME);
- }
-
- @Test
- public void isPermission() {
- assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
- assertThat(Permission.isPermission("no-permission")).isFalse();
-
- assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
- assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
- assertThat(Permission.isPermission("Code-Review")).isFalse();
- }
-
- @Test
- public void hasRange() {
- assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
- assertThat(Permission.hasRange("no-permission")).isFalse();
-
- assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
- assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
- assertThat(Permission.hasRange("Code-Review")).isFalse();
- }
-
- @Test
- public void isLabel() {
- assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
- assertThat(Permission.isLabel("no-permission")).isFalse();
-
- assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
- assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
- assertThat(Permission.isLabel("Code-Review")).isFalse();
- }
-
- @Test
- public void isLabelAs() {
- assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
- assertThat(Permission.isLabelAs("no-permission")).isFalse();
-
- assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
- assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
- assertThat(Permission.isLabelAs("Code-Review")).isFalse();
- }
-
- @Test
- public void forLabel() {
- assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
- }
-
- @Test
- public void forLabelAs() {
- assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
- }
-
- @Test
- public void extractLabel() {
- assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
- assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
- .isEqualTo("Code-Review");
- assertThat(Permission.extractLabel("Code-Review")).isNull();
- assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
- }
-
- @Test
- public void canBeOnAllProjects() {
- assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
- assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
- assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
- .isTrue();
- assertThat(
- Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
- .isTrue();
-
- assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
- assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
- assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
- .isTrue();
- assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
- .isTrue();
- }
-
- @Test
- public void getName() {
- assertThat(permission.getName()).isEqualTo(PERMISSION_NAME);
- }
-
- @Test
- public void getLabel() {
- assertThat(Permission.create(Permission.LABEL + "Code-Review").getLabel())
- .isEqualTo("Code-Review");
- assertThat(Permission.create(Permission.LABEL_AS + "Code-Review").getLabel())
- .isEqualTo("Code-Review");
- assertThat(Permission.create("Code-Review").getLabel()).isNull();
- assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
- }
-
- @Test
- public void exclusiveGroup() {
- assertThat(permission.build().getExclusiveGroup()).isFalse();
-
- permission.setExclusiveGroup(true);
- assertThat(permission.build().getExclusiveGroup()).isTrue();
-
- permission.setExclusiveGroup(false);
- assertThat(permission.build().getExclusiveGroup()).isFalse();
- }
-
- @Test
- public void noExclusiveGroupOnOwnerPermission() {
- Permission permission = Permission.create(Permission.OWNER);
- assertThat(permission.getExclusiveGroup()).isFalse();
-
- permission = permission.toBuilder().setExclusiveGroup(true).build();
- assertThat(permission.getExclusiveGroup()).isFalse();
- }
-
- @Test
- public void getEmptyRules() {
- assertThat(permission.getRulesBuilders()).isNotNull();
- assertThat(permission.getRulesBuilders()).isEmpty();
- }
-
- @Test
- public void setAndGetRules() {
- PermissionRule.Builder permissionRule1 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
- PermissionRule.Builder permissionRule2 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
- permission.add(permissionRule1);
- permission.add(permissionRule2);
- assertThat(permission.getRulesBuilders())
- .containsExactly(permissionRule1, permissionRule2)
- .inOrder();
-
- PermissionRule.Builder permissionRule3 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-3"), "group3"));
- permission.modifyRules(
- rules -> {
- rules.clear();
- rules.add(permissionRule3);
- });
- assertThat(permission.getRulesBuilders()).containsExactly(permissionRule3);
- }
-
- @Test
- public void getNonExistingRule() {
- GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
- assertThat(permission.build().getRule(groupReference)).isNull();
- assertThat(permission.build().getRule(groupReference)).isNull();
- }
-
- @Test
- public void getRule() {
- GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
- PermissionRule.Builder permissionRule = PermissionRule.builder(groupReference);
- permission.add(permissionRule);
- assertThat(permission.build().getRule(groupReference)).isEqualTo(permissionRule.build());
- }
-
- @Test
- public void addRule() {
- PermissionRule.Builder permissionRule1 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
- PermissionRule.Builder permissionRule2 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
- permission.add(permissionRule1);
- permission.add(permissionRule2);
- GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
- assertThat(permission.build().getRule(groupReference3)).isNull();
-
- PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
- permission.add(permissionRule3);
- assertThat(permission.build().getRule(groupReference3)).isEqualTo(permissionRule3.build());
- assertThat(permission.build().getRules())
- .containsExactly(permissionRule1.build(), permissionRule2.build(), permissionRule3.build())
- .inOrder();
- }
-
- @Test
- public void removeRule() {
- PermissionRule.Builder permissionRule1 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
- PermissionRule.Builder permissionRule2 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
- GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
- PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
-
- permission.add(permissionRule1);
- permission.add(permissionRule2);
- permission.add(permissionRule3);
- assertThat(permission.build().getRule(groupReference3)).isNotNull();
-
- permission.remove(permissionRule3.build());
- assertThat(permission.build().getRule(groupReference3)).isNull();
- assertThat(permission.build().getRules())
- .containsExactly(permissionRule1.build(), permissionRule2.build())
- .inOrder();
- }
-
- @Test
- public void removeRuleByGroupReference() {
- PermissionRule.Builder permissionRule1 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
- PermissionRule.Builder permissionRule2 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
- GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
- PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
-
- permission.add(permissionRule1);
- permission.add(permissionRule2);
- permission.add(permissionRule3);
- assertThat(permission.build().getRule(groupReference3)).isNotNull();
-
- permission.removeRule(groupReference3);
- assertThat(permission.build().getRule(groupReference3)).isNull();
- assertThat(permission.build().getRules())
- .containsExactly(permissionRule1.build(), permissionRule2.build())
- .inOrder();
- }
-
- @Test
- public void clearRules() {
- PermissionRule.Builder permissionRule1 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
- PermissionRule.Builder permissionRule2 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
-
- permission.add(permissionRule1);
- permission.add(permissionRule2);
- assertThat(permission.build().getRules()).isNotEmpty();
-
- permission.clearRules();
- assertThat(permission.build().getRules()).isEmpty();
- }
-
- @Test
- public void testEquals() {
- PermissionRule.Builder permissionRule1 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
- PermissionRule.Builder permissionRule2 =
- PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
-
- permission.add(permissionRule1);
- permission.add(permissionRule2);
-
- Permission.Builder permissionSameRulesOtherName = Permission.builder("bar");
- permissionSameRulesOtherName.add(permissionRule1);
- permissionSameRulesOtherName.add(permissionRule2);
- assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
-
- Permission.Builder permissionSameRulesSameNameOtherExclusiveGroup = Permission.builder("foo");
- permissionSameRulesSameNameOtherExclusiveGroup.add(permissionRule1);
- permissionSameRulesSameNameOtherExclusiveGroup.add(permissionRule2);
- permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
- assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
-
- Permission.Builder permissionOther = Permission.builder(PERMISSION_NAME);
- permissionOther.add(permissionRule1);
- assertThat(permission.build().equals(permissionOther.build())).isFalse();
-
- permissionOther.add(permissionRule2);
- assertThat(permission.build().equals(permissionOther.build())).isTrue();
- }
-}
diff --git a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java b/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
deleted file mode 100644
index 5386b87..0000000
--- a/javatests/com/google/gerrit/common/data/SubmitRecordTest.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// 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.
-
-package com.google.gerrit.common.data;
-
-import static com.google.common.truth.Truth.assertThat;
-
-import java.util.ArrayList;
-import java.util.Collection;
-import org.junit.Test;
-
-public class SubmitRecordTest {
- private static final SubmitRecord OK_RECORD;
- private static final SubmitRecord FORCED_RECORD;
- private static final SubmitRecord NOT_READY_RECORD;
-
- static {
- OK_RECORD = new SubmitRecord();
- OK_RECORD.status = SubmitRecord.Status.OK;
-
- FORCED_RECORD = new SubmitRecord();
- FORCED_RECORD.status = SubmitRecord.Status.FORCED;
-
- NOT_READY_RECORD = new SubmitRecord();
- NOT_READY_RECORD.status = SubmitRecord.Status.NOT_READY;
- }
-
- @Test
- public void okIfAllOkay() {
- Collection<SubmitRecord> submitRecords = new ArrayList<>();
- submitRecords.add(OK_RECORD);
-
- assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
- }
-
- @Test
- public void okWhenEmpty() {
- Collection<SubmitRecord> submitRecords = new ArrayList<>();
-
- assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
- }
-
- @Test
- public void okWhenForced() {
- Collection<SubmitRecord> submitRecords = new ArrayList<>();
- submitRecords.add(FORCED_RECORD);
-
- assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
- }
-
- @Test
- public void emptyResultIfInvalid() {
- Collection<SubmitRecord> submitRecords = new ArrayList<>();
- submitRecords.add(NOT_READY_RECORD);
- submitRecords.add(OK_RECORD);
-
- assertThat(SubmitRecord.allRecordsOK(submitRecords)).isFalse();
- }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
index f7a806b..86829b9 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -38,12 +38,8 @@
private static String getImageName(ElasticVersion version) {
switch (version) {
- case V6_6:
- return "blacktop/elasticsearch:6.6.2";
- case V6_7:
- return "blacktop/elasticsearch:6.7.2";
case V6_8:
- return "blacktop/elasticsearch:6.8.10";
+ return "blacktop/elasticsearch:6.8.12";
case V7_0:
return "blacktop/elasticsearch:7.0.1";
case V7_1:
@@ -61,7 +57,7 @@
case V7_7:
return "blacktop/elasticsearch:7.7.1";
case V7_8:
- return "blacktop/elasticsearch:7.8.0";
+ return "blacktop/elasticsearch:7.8.1";
}
throw new IllegalStateException("No tests for version: " + version.name());
}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index ac7f33b..9325a1b 100644
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -22,12 +22,6 @@
public class ElasticVersionTest {
@Test
public void supportedVersion() throws Exception {
- assertThat(ElasticVersion.forVersion("6.6.0")).isEqualTo(ElasticVersion.V6_6);
- assertThat(ElasticVersion.forVersion("6.6.1")).isEqualTo(ElasticVersion.V6_6);
-
- assertThat(ElasticVersion.forVersion("6.7.0")).isEqualTo(ElasticVersion.V6_7);
- assertThat(ElasticVersion.forVersion("6.7.1")).isEqualTo(ElasticVersion.V6_7);
-
assertThat(ElasticVersion.forVersion("6.8.0")).isEqualTo(ElasticVersion.V6_8);
assertThat(ElasticVersion.forVersion("6.8.1")).isEqualTo(ElasticVersion.V6_8);
@@ -73,24 +67,20 @@
@Test
public void atLeastMinorVersion() throws Exception {
- assertThat(ElasticVersion.V6_6.isAtLeastMinorVersion(ElasticVersion.V6_7)).isFalse();
- 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
public void version6OrLater() throws Exception {
- assertThat(ElasticVersion.V6_6.isV6OrLater()).isTrue();
- assertThat(ElasticVersion.V6_7.isV6OrLater()).isTrue();
assertThat(ElasticVersion.V6_8.isV6OrLater()).isTrue();
assertThat(ElasticVersion.V7_0.isV6OrLater()).isTrue();
assertThat(ElasticVersion.V7_1.isV6OrLater()).isTrue();
@@ -105,8 +95,6 @@
@Test
public void version7OrLater() throws Exception {
- assertThat(ElasticVersion.V6_6.isV7OrLater()).isFalse();
- assertThat(ElasticVersion.V6_7.isV7OrLater()).isFalse();
assertThat(ElasticVersion.V6_8.isV7OrLater()).isFalse();
assertThat(ElasticVersion.V7_0.isV7OrLater()).isTrue();
assertThat(ElasticVersion.V7_1.isV7OrLater()).isTrue();
diff --git a/javatests/com/google/gerrit/entities/AccessSectionTest.java b/javatests/com/google/gerrit/entities/AccessSectionTest.java
new file mode 100644
index 0000000..06860b0
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/AccessSectionTest.java
@@ -0,0 +1,242 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AccessSectionTest {
+ private static final String REF_PATTERN = "refs/heads/master";
+
+ private AccessSection.Builder accessSection;
+
+ @Before
+ public void setup() {
+ this.accessSection = AccessSection.builder(REF_PATTERN);
+ }
+
+ @Test
+ public void getName() {
+ assertThat(accessSection.getName()).isEqualTo(REF_PATTERN);
+ }
+
+ @Test
+ public void getEmptyPermissions() {
+ AccessSection builtAccessSection = accessSection.build();
+ assertThat(builtAccessSection.getPermissions()).isNotNull();
+ assertThat(builtAccessSection.getPermissions()).isEmpty();
+ }
+
+ @Test
+ public void setAndGetPermissions() {
+ Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+ Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+ accessSection.modifyPermissions(
+ permissions -> {
+ permissions.clear();
+ permissions.add(abandonPermission);
+ permissions.add(rebasePermission);
+ });
+
+ AccessSection builtAccessSection = accessSection.build();
+ assertThat(builtAccessSection.getPermissions()).hasSize(2);
+ assertThat(builtAccessSection.getPermission(abandonPermission.getName())).isNotNull();
+ assertThat(builtAccessSection.getPermission(rebasePermission.getName())).isNotNull();
+
+ Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+ accessSection.modifyPermissions(
+ p -> {
+ p.clear();
+ p.add(submitPermission);
+ });
+ builtAccessSection = accessSection.build();
+ assertThat(builtAccessSection.getPermissions()).hasSize(1);
+ assertThat(builtAccessSection.getPermission(submitPermission.getName())).isNotNull();
+ assertThrows(NullPointerException.class, () -> accessSection.setPermissions(null));
+ }
+
+ @Test
+ public void cannotSetDuplicatePermissions() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ accessSection
+ .addPermission(Permission.builder(Permission.ABANDON))
+ .addPermission(Permission.builder(Permission.ABANDON))
+ .build());
+ }
+
+ @Test
+ public void cannotSetPermissionsWithConflictingNames() {
+ Permission.Builder abandonPermissionLowerCase =
+ Permission.builder(Permission.ABANDON.toLowerCase(Locale.US));
+ Permission.Builder abandonPermissionUpperCase =
+ Permission.builder(Permission.ABANDON.toUpperCase(Locale.US));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ accessSection
+ .addPermission(abandonPermissionLowerCase)
+ .addPermission(abandonPermissionUpperCase)
+ .build());
+ }
+
+ @Test
+ public void getNonExistingPermission() {
+ assertThat(accessSection.build().getPermission("non-existing")).isNull();
+ assertThat(accessSection.build().getPermission("non-existing")).isNull();
+ }
+
+ @Test
+ public void getPermission() {
+ Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+ accessSection.addPermission(submitPermission);
+ assertThat(accessSection.upsertPermission(Permission.SUBMIT)).isEqualTo(submitPermission);
+ assertThrows(NullPointerException.class, () -> accessSection.upsertPermission(null));
+ }
+
+ @Test
+ public void getPermissionWithOtherCase() {
+ Permission.Builder submitPermissionLowerCase =
+ Permission.builder(Permission.SUBMIT.toLowerCase(Locale.US));
+ accessSection.addPermission(submitPermissionLowerCase);
+ assertThat(accessSection.upsertPermission(Permission.SUBMIT.toUpperCase(Locale.US)))
+ .isEqualTo(submitPermissionLowerCase);
+ }
+
+ @Test
+ public void createMissingPermissionOnGet() {
+ assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
+
+ assertThat(accessSection.upsertPermission(Permission.SUBMIT).build())
+ .isEqualTo(Permission.create(Permission.SUBMIT));
+
+ assertThrows(NullPointerException.class, () -> accessSection.upsertPermission(null));
+ }
+
+ @Test
+ public void addPermission() {
+ Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+ Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+
+ accessSection.addPermission(abandonPermission);
+ accessSection.addPermission(rebasePermission);
+ assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
+
+ Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+ accessSection.addPermission(submitPermission);
+ assertThat(accessSection.build().getPermission(Permission.SUBMIT))
+ .isEqualTo(submitPermission.build());
+ assertThat(accessSection.build().getPermissions())
+ .containsExactly(
+ abandonPermission.build(), rebasePermission.build(), submitPermission.build())
+ .inOrder();
+ assertThrows(NullPointerException.class, () -> accessSection.addPermission(null));
+ }
+
+ @Test
+ public void removePermission() {
+ Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+ Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+ Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+
+ accessSection.addPermission(abandonPermission);
+ accessSection.addPermission(rebasePermission);
+ accessSection.addPermission(submitPermission);
+ assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNotNull();
+
+ accessSection.remove(submitPermission);
+ assertThat(accessSection.build().getPermission(Permission.SUBMIT)).isNull();
+ assertThat(accessSection.build().getPermissions())
+ .containsExactly(abandonPermission.build(), rebasePermission.build())
+ .inOrder();
+ assertThrows(NullPointerException.class, () -> accessSection.remove(null));
+ }
+
+ @Test
+ public void removePermissionByName() {
+ Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+ Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+ Permission.Builder submitPermission = Permission.builder(Permission.SUBMIT);
+
+ accessSection.addPermission(abandonPermission);
+ accessSection.addPermission(rebasePermission);
+ accessSection.addPermission(submitPermission);
+ AccessSection builtAccessSection = accessSection.build();
+ assertThat(builtAccessSection.getPermission(Permission.SUBMIT)).isNotNull();
+
+ accessSection.removePermission(Permission.SUBMIT);
+ builtAccessSection = accessSection.build();
+ assertThat(builtAccessSection.getPermission(Permission.SUBMIT)).isNull();
+ assertThat(builtAccessSection.getPermissions())
+ .containsExactly(abandonPermission.build(), rebasePermission.build())
+ .inOrder();
+
+ assertThrows(NullPointerException.class, () -> accessSection.removePermission(null));
+ }
+
+ @Test
+ public void removePermissionByNameOtherCase() {
+ Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+ Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+
+ String submitLowerCase = Permission.SUBMIT.toLowerCase(Locale.US);
+ String submitUpperCase = Permission.SUBMIT.toUpperCase(Locale.US);
+ Permission.Builder submitPermissionLowerCase = Permission.builder(submitLowerCase);
+
+ accessSection.addPermission(abandonPermission);
+ accessSection.addPermission(rebasePermission);
+ accessSection.addPermission(submitPermissionLowerCase);
+ AccessSection builtAccessSection = accessSection.build();
+ assertThat(builtAccessSection.getPermission(submitLowerCase)).isNotNull();
+ assertThat(builtAccessSection.getPermission(submitUpperCase)).isNotNull();
+
+ accessSection.removePermission(submitUpperCase);
+ builtAccessSection = accessSection.build();
+ assertThat(builtAccessSection.getPermission(submitLowerCase)).isNull();
+ assertThat(builtAccessSection.getPermission(submitUpperCase)).isNull();
+ assertThat(builtAccessSection.getPermissions())
+ .containsExactly(abandonPermission.build(), rebasePermission.build())
+ .inOrder();
+ }
+
+ @Test
+ public void testEquals() {
+ Permission.Builder abandonPermission = Permission.builder(Permission.ABANDON);
+ Permission.Builder rebasePermission = Permission.builder(Permission.REBASE);
+
+ accessSection.addPermission(abandonPermission);
+ accessSection.addPermission(rebasePermission);
+
+ AccessSection builtAccessSection = accessSection.build();
+ AccessSection.Builder accessSectionSamePermissionsOtherRef =
+ AccessSection.builder("refs/heads/other");
+ accessSectionSamePermissionsOtherRef.addPermission(abandonPermission);
+ accessSectionSamePermissionsOtherRef.addPermission(rebasePermission);
+ assertThat(builtAccessSection.equals(accessSectionSamePermissionsOtherRef.build())).isFalse();
+
+ AccessSection.Builder accessSectionOther = AccessSection.builder(REF_PATTERN);
+ accessSectionOther.addPermission(abandonPermission);
+ assertThat(builtAccessSection.equals(accessSectionOther.build())).isFalse();
+
+ accessSectionOther.addPermission(rebasePermission);
+ assertThat(builtAccessSection.equals(accessSectionOther.build())).isTrue();
+ }
+}
diff --git a/javatests/com/google/gerrit/entities/LabelFunctionTest.java b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
new file mode 100644
index 0000000..3941564
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/LabelFunctionTest.java
@@ -0,0 +1,140 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import org.junit.Test;
+
+public class LabelFunctionTest {
+ private static final String LABEL_NAME = "Verified";
+ private static final LabelId LABEL_ID = LabelId.create(LABEL_NAME);
+ private static final Change.Id CHANGE_ID = Change.id(100);
+ private static final PatchSet.Id PS_ID = PatchSet.id(CHANGE_ID, 1);
+ private static final LabelType VERIFIED_LABEL = makeLabel();
+ private static final PatchSetApproval APPROVAL_2 = makeApproval(2);
+ private static final PatchSetApproval APPROVAL_1 = makeApproval(1);
+ private static final PatchSetApproval APPROVAL_0 = makeApproval(0);
+ private static final PatchSetApproval APPROVAL_M1 = makeApproval(-1);
+ private static final PatchSetApproval APPROVAL_M2 = makeApproval(-2);
+
+ @Test
+ public void checkLabelNameIsCorrect() {
+ for (LabelFunction function : LabelFunction.values()) {
+ SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
+ assertThat(myLabel.label).isEqualTo("Verified");
+ }
+ }
+
+ @Test
+ public void checkFunctionDoesNothing() {
+ checkNothingHappens(LabelFunction.NO_BLOCK);
+ checkNothingHappens(LabelFunction.NO_OP);
+ checkNothingHappens(LabelFunction.PATCH_SET_LOCK);
+ checkNothingHappens(LabelFunction.ANY_WITH_BLOCK);
+
+ checkLabelIsRequired(LabelFunction.MAX_WITH_BLOCK);
+ checkLabelIsRequired(LabelFunction.MAX_NO_BLOCK);
+ }
+
+ @Test
+ public void checkBlockWorks() {
+ checkBlockWorks(LabelFunction.ANY_WITH_BLOCK);
+ checkBlockWorks(LabelFunction.MAX_WITH_BLOCK);
+ }
+
+ @Test
+ public void checkMaxWorks() {
+ checkMaxIsEnforced(LabelFunction.MAX_NO_BLOCK);
+ checkMaxIsEnforced(LabelFunction.MAX_WITH_BLOCK);
+
+ checkMaxValidatesTheLabel(LabelFunction.MAX_NO_BLOCK);
+ checkMaxValidatesTheLabel(LabelFunction.MAX_WITH_BLOCK);
+ }
+
+ @Test
+ public void checkMaxNoBlockIgnoresMin() {
+ List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_M2, APPROVAL_2, APPROVAL_M2);
+
+ SubmitRecord.Label myLabel = LabelFunction.MAX_NO_BLOCK.check(VERIFIED_LABEL, approvals);
+
+ assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
+ assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
+ }
+
+ private static LabelType makeLabel() {
+ List<LabelValue> values = new ArrayList<>();
+ // The label text is irrelevant here, only the numerical value is used
+ values.add(LabelValue.create((short) -2, "Great job, please fix compilation."));
+ values.add(LabelValue.create((short) -1, "Really good, please make some minor changes."));
+ values.add(LabelValue.create((short) 0, "No vote."));
+ values.add(LabelValue.create((short) 1, "Closest thing perfection."));
+ values.add(LabelValue.create((short) 2, "Perfect!"));
+ return LabelType.create(LABEL_NAME, values);
+ }
+
+ private static PatchSetApproval makeApproval(int value) {
+ return PatchSetApproval.builder()
+ .key(PatchSetApproval.key(PS_ID, Account.id(10000 + value), LABEL_ID))
+ .value(value)
+ .granted(Date.from(Instant.now()))
+ .build();
+ }
+
+ private static void checkBlockWorks(LabelFunction function) {
+ List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_M2, APPROVAL_2);
+
+ SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
+
+ assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.REJECT);
+ assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_M2.accountId());
+ }
+
+ private static void checkNothingHappens(LabelFunction function) {
+ SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
+
+ assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.MAY);
+ assertThat(myLabel.appliedBy).isNull();
+ }
+
+ private static void checkLabelIsRequired(LabelFunction function) {
+ SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, ImmutableList.of());
+
+ assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
+ assertThat(myLabel.appliedBy).isNull();
+ }
+
+ private static void checkMaxIsEnforced(LabelFunction function) {
+ List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_0);
+
+ SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
+
+ assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.NEED);
+ }
+
+ private static void checkMaxValidatesTheLabel(LabelFunction function) {
+ List<PatchSetApproval> approvals = ImmutableList.of(APPROVAL_1, APPROVAL_2, APPROVAL_M1);
+
+ SubmitRecord.Label myLabel = function.check(VERIFIED_LABEL, approvals);
+
+ assertThat(myLabel.status).isEqualTo(SubmitRecord.Label.Status.OK);
+ assertThat(myLabel.appliedBy).isEqualTo(APPROVAL_2.accountId());
+ }
+}
diff --git a/javatests/com/google/gerrit/entities/LabelTypeTest.java b/javatests/com/google/gerrit/entities/LabelTypeTest.java
new file mode 100644
index 0000000..f31f2c9
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/LabelTypeTest.java
@@ -0,0 +1,60 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+
+public class LabelTypeTest {
+ @Test
+ public void sortLabelValues() {
+ LabelValue v0 = LabelValue.create((short) 0, "Zero");
+ LabelValue v1 = LabelValue.create((short) 1, "One");
+ LabelValue v2 = LabelValue.create((short) 2, "Two");
+ LabelType types = LabelType.create("Label", ImmutableList.of(v2, v0, v1));
+ assertThat(types.getValues()).containsExactly(v0, v1, v2).inOrder();
+ }
+
+ @Test
+ public void sortCopyValues() {
+ LabelValue v0 = LabelValue.create((short) 0, "Zero");
+ LabelValue v1 = LabelValue.create((short) 1, "One");
+ LabelValue v2 = LabelValue.create((short) 2, "Two");
+ LabelType types =
+ LabelType.builder("Label", ImmutableList.of(v2, v0, v1))
+ .setCopyValues(ImmutableList.of((short) 2, (short) 0, (short) 1))
+ .build();
+ assertThat(types.getCopyValues()).containsExactly((short) 0, (short) 1, (short) 2).inOrder();
+ }
+
+ @Test
+ public void insertMissingLabelValues() {
+ LabelValue v0 = LabelValue.create((short) 0, "Zero");
+ LabelValue v2 = LabelValue.create((short) 2, "Two");
+ LabelValue v5 = LabelValue.create((short) 5, "Five");
+ LabelType types = LabelType.create("Label", ImmutableList.of(v2, v5, v0));
+ assertThat(types.getValues())
+ .containsExactly(
+ v0,
+ LabelValue.create((short) 1, ""),
+ v2,
+ LabelValue.create((short) 3, ""),
+ LabelValue.create((short) 4, ""),
+ v5)
+ .inOrder();
+ }
+}
diff --git a/javatests/com/google/gerrit/entities/PermissionRuleTest.java b/javatests/com/google/gerrit/entities/PermissionRuleTest.java
new file mode 100644
index 0000000..c2ed93f
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/PermissionRuleTest.java
@@ -0,0 +1,300 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.PermissionRule.Action;
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionRuleTest {
+ private GroupReference groupReference;
+ private PermissionRule permissionRule;
+
+ @Before
+ public void setup() {
+ this.groupReference = GroupReference.create(AccountGroup.uuid("uuid"), "group");
+ this.permissionRule = PermissionRule.create(groupReference);
+ }
+
+ @Test
+ public void mergeFromAnyBlock() {
+ GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+ PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+ GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+ PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.isBlock()).isFalse();
+ assertThat(permissionRule2.isBlock()).isFalse();
+
+ permissionRule2 = permissionRule2.toBuilder().setBlock().build();
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.isBlock()).isTrue();
+ assertThat(permissionRule2.isBlock()).isTrue();
+
+ permissionRule2 = permissionRule2.toBuilder().setDeny().build();
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.isBlock()).isTrue();
+ assertThat(permissionRule2.isBlock()).isFalse();
+
+ permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.isBlock()).isTrue();
+ assertThat(permissionRule2.isBlock()).isFalse();
+ }
+
+ @Test
+ public void mergeFromAnyDeny() {
+ GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+ PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+ GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+ PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.isDeny()).isFalse();
+ assertThat(permissionRule2.isDeny()).isFalse();
+
+ permissionRule2 = permissionRule2.toBuilder().setDeny().build();
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.isDeny()).isTrue();
+ assertThat(permissionRule2.isDeny()).isTrue();
+
+ permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.isDeny()).isTrue();
+ assertThat(permissionRule2.isDeny()).isFalse();
+ }
+
+ @Test
+ public void mergeFromAnyBatch() {
+ GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+ PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+ GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+ PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.getAction()).isNotEqualTo(Action.BATCH);
+ assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
+
+ permissionRule2 = permissionRule2.toBuilder().setAction(Action.BATCH).build();
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
+ assertThat(permissionRule2.getAction()).isEqualTo(Action.BATCH);
+
+ permissionRule2 = permissionRule2.toBuilder().setAction(Action.ALLOW).build();
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.getAction()).isEqualTo(Action.BATCH);
+ assertThat(permissionRule2.getAction()).isNotEqualTo(Action.BATCH);
+ }
+
+ @Test
+ public void mergeFromAnyForce() {
+ GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+ PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+ GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+ PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.getForce()).isFalse();
+ assertThat(permissionRule2.getForce()).isFalse();
+
+ permissionRule2 = permissionRule2.toBuilder().setForce(true).build();
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.getForce()).isTrue();
+ assertThat(permissionRule2.getForce()).isTrue();
+
+ permissionRule2 = permissionRule2.toBuilder().setForce(false).build();
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.getForce()).isTrue();
+ assertThat(permissionRule2.getForce()).isFalse();
+ }
+
+ @Test
+ public void mergeFromMergeRange() {
+ GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+ PermissionRule permissionRule1 =
+ PermissionRule.builder(groupReference1).setRange(-1, 2).build();
+
+ GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+ PermissionRule permissionRule2 =
+ PermissionRule.builder(groupReference2).setRange(-2, 1).build();
+
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.getMin()).isEqualTo(-2);
+ assertThat(permissionRule1.getMax()).isEqualTo(2);
+ assertThat(permissionRule2.getMin()).isEqualTo(-2);
+ assertThat(permissionRule2.getMax()).isEqualTo(1);
+ }
+
+ @Test
+ public void mergeFromGroupNotChanged() {
+ GroupReference groupReference1 = GroupReference.create(AccountGroup.uuid("uuid1"), "group1");
+ PermissionRule permissionRule1 = PermissionRule.builder(groupReference1).build();
+
+ GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+ PermissionRule permissionRule2 = PermissionRule.builder(groupReference2).build();
+
+ permissionRule1 = PermissionRule.merge(permissionRule2, permissionRule1);
+ assertThat(permissionRule1.getGroup()).isEqualTo(groupReference1);
+ assertThat(permissionRule2.getGroup()).isEqualTo(groupReference2);
+ }
+
+ @Test
+ public void asString() {
+ PermissionRule.Builder permissionRule = this.permissionRule.toBuilder();
+
+ assertThat(permissionRule.build().asString(true))
+ .isEqualTo("group " + groupReference.getName());
+
+ permissionRule.setDeny();
+ assertThat(permissionRule.build().asString(true))
+ .isEqualTo("deny group " + groupReference.getName());
+
+ permissionRule.setBlock();
+ assertThat(permissionRule.build().asString(true))
+ .isEqualTo("block group " + groupReference.getName());
+
+ permissionRule.setAction(Action.BATCH);
+ assertThat(permissionRule.build().asString(true))
+ .isEqualTo("batch group " + groupReference.getName());
+
+ permissionRule.setAction(Action.INTERACTIVE);
+ assertThat(permissionRule.build().asString(true))
+ .isEqualTo("interactive group " + groupReference.getName());
+
+ permissionRule.setForce(true);
+ assertThat(permissionRule.build().asString(true))
+ .isEqualTo("interactive +force group " + groupReference.getName());
+
+ permissionRule.setAction(Action.ALLOW);
+ assertThat(permissionRule.build().asString(true))
+ .isEqualTo("+force group " + groupReference.getName());
+
+ permissionRule.setMax(1);
+ assertThat(permissionRule.build().asString(true))
+ .isEqualTo("+force +0..+1 group " + groupReference.getName());
+
+ permissionRule.setMin(-1);
+ assertThat(permissionRule.build().asString(true))
+ .isEqualTo("+force -1..+1 group " + groupReference.getName());
+
+ assertThat(permissionRule.build().asString(false))
+ .isEqualTo("+force group " + groupReference.getName());
+ }
+
+ @Test
+ public void fromString() {
+ PermissionRule permissionRule = PermissionRule.fromString("group A", true);
+ assertPermissionRule(permissionRule, "A", Action.ALLOW, false, 0, 0);
+
+ permissionRule = PermissionRule.fromString("deny group A", true);
+ assertPermissionRule(permissionRule, "A", Action.DENY, false, 0, 0);
+
+ permissionRule = PermissionRule.fromString("block group A", true);
+ assertPermissionRule(permissionRule, "A", Action.BLOCK, false, 0, 0);
+
+ permissionRule = PermissionRule.fromString("batch group A", true);
+ assertPermissionRule(permissionRule, "A", Action.BATCH, false, 0, 0);
+
+ permissionRule = PermissionRule.fromString("interactive group A", true);
+ assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, false, 0, 0);
+
+ permissionRule = PermissionRule.fromString("interactive +force group A", true);
+ assertPermissionRule(permissionRule, "A", Action.INTERACTIVE, true, 0, 0);
+
+ permissionRule = PermissionRule.fromString("+force group A", true);
+ assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
+
+ permissionRule = PermissionRule.fromString("+force +0..+1 group A", true);
+ assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 1);
+
+ permissionRule = PermissionRule.fromString("+force -1..+1 group A", true);
+ assertPermissionRule(permissionRule, "A", Action.ALLOW, true, -1, 1);
+
+ permissionRule = PermissionRule.fromString("+force group A", false);
+ assertPermissionRule(permissionRule, "A", Action.ALLOW, true, 0, 0);
+ }
+
+ @Test
+ public void parseInt() {
+ assertThat(PermissionRule.parseInt("0")).isEqualTo(0);
+ assertThat(PermissionRule.parseInt("+0")).isEqualTo(0);
+ assertThat(PermissionRule.parseInt("-0")).isEqualTo(0);
+ assertThat(PermissionRule.parseInt("1")).isEqualTo(1);
+ assertThat(PermissionRule.parseInt("+1")).isEqualTo(1);
+ assertThat(PermissionRule.parseInt("-1")).isEqualTo(-1);
+ }
+
+ @Test
+ public void testEquals() {
+ GroupReference groupReference2 = GroupReference.create(AccountGroup.uuid("uuid2"), "group2");
+ PermissionRule.Builder permissionRuleOther = PermissionRule.builder(groupReference2);
+ PermissionRule.Builder permissionRule = this.permissionRule.toBuilder();
+
+ assertThat(permissionRule.equals(permissionRuleOther)).isFalse();
+
+ permissionRuleOther.setGroup(groupReference);
+ assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+ permissionRule.setDeny();
+ assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+ permissionRuleOther.setDeny();
+ assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+ permissionRule.setForce(true);
+ assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+ permissionRuleOther.setForce(true);
+ assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+ permissionRule.setMin(-1);
+ assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+ permissionRuleOther.setMin(-1);
+ assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+
+ permissionRule.setMax(1);
+ assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isFalse();
+
+ permissionRuleOther.setMax(1);
+ assertThat(permissionRuleEquals(permissionRule, permissionRuleOther)).isTrue();
+ }
+
+ private static boolean permissionRuleEquals(
+ PermissionRule.Builder r1, PermissionRule.Builder r2) {
+ return r1.build().equals(r2.build());
+ }
+
+ private void assertPermissionRule(
+ PermissionRule permissionRule,
+ String expectedGroupName,
+ Action expectedAction,
+ boolean expectedForce,
+ int expectedMin,
+ int expectedMax) {
+ assertThat(permissionRule.getGroup().getName()).isEqualTo(expectedGroupName);
+ assertThat(permissionRule.getAction()).isEqualTo(expectedAction);
+ assertThat(permissionRule.getForce()).isEqualTo(expectedForce);
+ assertThat(permissionRule.getMin()).isEqualTo(expectedMin);
+ assertThat(permissionRule.getMax()).isEqualTo(expectedMax);
+ }
+}
diff --git a/javatests/com/google/gerrit/entities/PermissionTest.java b/javatests/com/google/gerrit/entities/PermissionTest.java
new file mode 100644
index 0000000..2915f79
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/PermissionTest.java
@@ -0,0 +1,291 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Test;
+
+public class PermissionTest {
+ private static final String PERMISSION_NAME = "foo";
+
+ private Permission.Builder permission;
+
+ @Before
+ public void setup() {
+ this.permission = Permission.builder(PERMISSION_NAME);
+ }
+
+ @Test
+ public void isPermission() {
+ assertThat(Permission.isPermission(Permission.ABANDON)).isTrue();
+ assertThat(Permission.isPermission("no-permission")).isFalse();
+
+ assertThat(Permission.isPermission(Permission.LABEL + "Code-Review")).isTrue();
+ assertThat(Permission.isPermission(Permission.LABEL_AS + "Code-Review")).isTrue();
+ assertThat(Permission.isPermission("Code-Review")).isFalse();
+ }
+
+ @Test
+ public void hasRange() {
+ assertThat(Permission.hasRange(Permission.ABANDON)).isFalse();
+ assertThat(Permission.hasRange("no-permission")).isFalse();
+
+ assertThat(Permission.hasRange(Permission.LABEL + "Code-Review")).isTrue();
+ assertThat(Permission.hasRange(Permission.LABEL_AS + "Code-Review")).isTrue();
+ assertThat(Permission.hasRange("Code-Review")).isFalse();
+ }
+
+ @Test
+ public void isLabel() {
+ assertThat(Permission.isLabel(Permission.ABANDON)).isFalse();
+ assertThat(Permission.isLabel("no-permission")).isFalse();
+
+ assertThat(Permission.isLabel(Permission.LABEL + "Code-Review")).isTrue();
+ assertThat(Permission.isLabel(Permission.LABEL_AS + "Code-Review")).isFalse();
+ assertThat(Permission.isLabel("Code-Review")).isFalse();
+ }
+
+ @Test
+ public void isLabelAs() {
+ assertThat(Permission.isLabelAs(Permission.ABANDON)).isFalse();
+ assertThat(Permission.isLabelAs("no-permission")).isFalse();
+
+ assertThat(Permission.isLabelAs(Permission.LABEL + "Code-Review")).isFalse();
+ assertThat(Permission.isLabelAs(Permission.LABEL_AS + "Code-Review")).isTrue();
+ assertThat(Permission.isLabelAs("Code-Review")).isFalse();
+ }
+
+ @Test
+ public void forLabel() {
+ assertThat(Permission.forLabel("Code-Review")).isEqualTo(Permission.LABEL + "Code-Review");
+ }
+
+ @Test
+ public void forLabelAs() {
+ assertThat(Permission.forLabelAs("Code-Review")).isEqualTo(Permission.LABEL_AS + "Code-Review");
+ }
+
+ @Test
+ public void extractLabel() {
+ assertThat(Permission.extractLabel(Permission.LABEL + "Code-Review")).isEqualTo("Code-Review");
+ assertThat(Permission.extractLabel(Permission.LABEL_AS + "Code-Review"))
+ .isEqualTo("Code-Review");
+ assertThat(Permission.extractLabel("Code-Review")).isNull();
+ assertThat(Permission.extractLabel(Permission.ABANDON)).isNull();
+ }
+
+ @Test
+ public void canBeOnAllProjects() {
+ assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.ABANDON)).isTrue();
+ assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.OWNER)).isFalse();
+ assertThat(Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL + "Code-Review"))
+ .isTrue();
+ assertThat(
+ Permission.canBeOnAllProjects(AccessSection.ALL, Permission.LABEL_AS + "Code-Review"))
+ .isTrue();
+
+ assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.ABANDON)).isTrue();
+ assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.OWNER)).isTrue();
+ assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL + "Code-Review"))
+ .isTrue();
+ assertThat(Permission.canBeOnAllProjects("refs/heads/*", Permission.LABEL_AS + "Code-Review"))
+ .isTrue();
+ }
+
+ @Test
+ public void getName() {
+ assertThat(permission.getName()).isEqualTo(PERMISSION_NAME);
+ }
+
+ @Test
+ public void getLabel() {
+ assertThat(Permission.create(Permission.LABEL + "Code-Review").getLabel())
+ .isEqualTo("Code-Review");
+ assertThat(Permission.create(Permission.LABEL_AS + "Code-Review").getLabel())
+ .isEqualTo("Code-Review");
+ assertThat(Permission.create("Code-Review").getLabel()).isNull();
+ assertThat(Permission.create(Permission.ABANDON).getLabel()).isNull();
+ }
+
+ @Test
+ public void exclusiveGroup() {
+ assertThat(permission.build().getExclusiveGroup()).isFalse();
+
+ permission.setExclusiveGroup(true);
+ assertThat(permission.build().getExclusiveGroup()).isTrue();
+
+ permission.setExclusiveGroup(false);
+ assertThat(permission.build().getExclusiveGroup()).isFalse();
+ }
+
+ @Test
+ public void noExclusiveGroupOnOwnerPermission() {
+ Permission permission = Permission.create(Permission.OWNER);
+ assertThat(permission.getExclusiveGroup()).isFalse();
+
+ permission = permission.toBuilder().setExclusiveGroup(true).build();
+ assertThat(permission.getExclusiveGroup()).isFalse();
+ }
+
+ @Test
+ public void getEmptyRules() {
+ assertThat(permission.getRulesBuilders()).isNotNull();
+ assertThat(permission.getRulesBuilders()).isEmpty();
+ }
+
+ @Test
+ public void setAndGetRules() {
+ PermissionRule.Builder permissionRule1 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+ PermissionRule.Builder permissionRule2 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+ permission.add(permissionRule1);
+ permission.add(permissionRule2);
+ assertThat(permission.getRulesBuilders())
+ .containsExactly(permissionRule1, permissionRule2)
+ .inOrder();
+
+ PermissionRule.Builder permissionRule3 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-3"), "group3"));
+ permission.modifyRules(
+ rules -> {
+ rules.clear();
+ rules.add(permissionRule3);
+ });
+ assertThat(permission.getRulesBuilders()).containsExactly(permissionRule3);
+ }
+
+ @Test
+ public void getNonExistingRule() {
+ GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
+ assertThat(permission.build().getRule(groupReference)).isNull();
+ assertThat(permission.build().getRule(groupReference)).isNull();
+ }
+
+ @Test
+ public void getRule() {
+ GroupReference groupReference = GroupReference.create(AccountGroup.uuid("uuid-1"), "group1");
+ PermissionRule.Builder permissionRule = PermissionRule.builder(groupReference);
+ permission.add(permissionRule);
+ assertThat(permission.build().getRule(groupReference)).isEqualTo(permissionRule.build());
+ }
+
+ @Test
+ public void addRule() {
+ PermissionRule.Builder permissionRule1 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+ PermissionRule.Builder permissionRule2 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+ permission.add(permissionRule1);
+ permission.add(permissionRule2);
+ GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
+ assertThat(permission.build().getRule(groupReference3)).isNull();
+
+ PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
+ permission.add(permissionRule3);
+ assertThat(permission.build().getRule(groupReference3)).isEqualTo(permissionRule3.build());
+ assertThat(permission.build().getRules())
+ .containsExactly(permissionRule1.build(), permissionRule2.build(), permissionRule3.build())
+ .inOrder();
+ }
+
+ @Test
+ public void removeRule() {
+ PermissionRule.Builder permissionRule1 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+ PermissionRule.Builder permissionRule2 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+ GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
+ PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
+
+ permission.add(permissionRule1);
+ permission.add(permissionRule2);
+ permission.add(permissionRule3);
+ assertThat(permission.build().getRule(groupReference3)).isNotNull();
+
+ permission.remove(permissionRule3.build());
+ assertThat(permission.build().getRule(groupReference3)).isNull();
+ assertThat(permission.build().getRules())
+ .containsExactly(permissionRule1.build(), permissionRule2.build())
+ .inOrder();
+ }
+
+ @Test
+ public void removeRuleByGroupReference() {
+ PermissionRule.Builder permissionRule1 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+ PermissionRule.Builder permissionRule2 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+ GroupReference groupReference3 = GroupReference.create(AccountGroup.uuid("uuid-3"), "group3");
+ PermissionRule.Builder permissionRule3 = PermissionRule.builder(groupReference3);
+
+ permission.add(permissionRule1);
+ permission.add(permissionRule2);
+ permission.add(permissionRule3);
+ assertThat(permission.build().getRule(groupReference3)).isNotNull();
+
+ permission.removeRule(groupReference3);
+ assertThat(permission.build().getRule(groupReference3)).isNull();
+ assertThat(permission.build().getRules())
+ .containsExactly(permissionRule1.build(), permissionRule2.build())
+ .inOrder();
+ }
+
+ @Test
+ public void clearRules() {
+ PermissionRule.Builder permissionRule1 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+ PermissionRule.Builder permissionRule2 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+
+ permission.add(permissionRule1);
+ permission.add(permissionRule2);
+ assertThat(permission.build().getRules()).isNotEmpty();
+
+ permission.clearRules();
+ assertThat(permission.build().getRules()).isEmpty();
+ }
+
+ @Test
+ public void testEquals() {
+ PermissionRule.Builder permissionRule1 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-1"), "group1"));
+ PermissionRule.Builder permissionRule2 =
+ PermissionRule.builder(GroupReference.create(AccountGroup.uuid("uuid-2"), "group2"));
+
+ permission.add(permissionRule1);
+ permission.add(permissionRule2);
+
+ Permission.Builder permissionSameRulesOtherName = Permission.builder("bar");
+ permissionSameRulesOtherName.add(permissionRule1);
+ permissionSameRulesOtherName.add(permissionRule2);
+ assertThat(permission.equals(permissionSameRulesOtherName)).isFalse();
+
+ Permission.Builder permissionSameRulesSameNameOtherExclusiveGroup = Permission.builder("foo");
+ permissionSameRulesSameNameOtherExclusiveGroup.add(permissionRule1);
+ permissionSameRulesSameNameOtherExclusiveGroup.add(permissionRule2);
+ permissionSameRulesSameNameOtherExclusiveGroup.setExclusiveGroup(true);
+ assertThat(permission.equals(permissionSameRulesSameNameOtherExclusiveGroup)).isFalse();
+
+ Permission.Builder permissionOther = Permission.builder(PERMISSION_NAME);
+ permissionOther.add(permissionRule1);
+ assertThat(permission.build().equals(permissionOther.build())).isFalse();
+
+ permissionOther.add(permissionRule2);
+ assertThat(permission.build().equals(permissionOther.build())).isTrue();
+ }
+}
diff --git a/javatests/com/google/gerrit/entities/SubmitRecordTest.java b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
new file mode 100644
index 0000000..0e832f4
--- /dev/null
+++ b/javatests/com/google/gerrit/entities/SubmitRecordTest.java
@@ -0,0 +1,70 @@
+// 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.
+
+package com.google.gerrit.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import org.junit.Test;
+
+public class SubmitRecordTest {
+ private static final SubmitRecord OK_RECORD;
+ private static final SubmitRecord FORCED_RECORD;
+ private static final SubmitRecord NOT_READY_RECORD;
+
+ static {
+ OK_RECORD = new SubmitRecord();
+ OK_RECORD.status = SubmitRecord.Status.OK;
+
+ FORCED_RECORD = new SubmitRecord();
+ FORCED_RECORD.status = SubmitRecord.Status.FORCED;
+
+ NOT_READY_RECORD = new SubmitRecord();
+ NOT_READY_RECORD.status = SubmitRecord.Status.NOT_READY;
+ }
+
+ @Test
+ public void okIfAllOkay() {
+ Collection<SubmitRecord> submitRecords = new ArrayList<>();
+ submitRecords.add(OK_RECORD);
+
+ assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+ }
+
+ @Test
+ public void okWhenEmpty() {
+ Collection<SubmitRecord> submitRecords = new ArrayList<>();
+
+ assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+ }
+
+ @Test
+ public void okWhenForced() {
+ Collection<SubmitRecord> submitRecords = new ArrayList<>();
+ submitRecords.add(FORCED_RECORD);
+
+ assertThat(SubmitRecord.allRecordsOK(submitRecords)).isTrue();
+ }
+
+ @Test
+ public void emptyResultIfInvalid() {
+ Collection<SubmitRecord> submitRecords = new ArrayList<>();
+ submitRecords.add(NOT_READY_RECORD);
+ submitRecords.add(OK_RECORD);
+
+ assertThat(SubmitRecord.allRecordsOK(submitRecords)).isFalse();
+ }
+}
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
index 2896ea9..6691587 100644
--- a/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
+++ b/javatests/com/google/gerrit/httpd/raw/IndexHtmlUtilTest.java
@@ -15,12 +15,19 @@
package com.google.gerrit.httpd.raw;
import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.httpd.raw.IndexHtmlUtil.CHANGE_URL_PATTERN;
-import static com.google.gerrit.httpd.raw.IndexHtmlUtil.DIFF_URL_PATTERN;
-import static com.google.gerrit.httpd.raw.IndexHtmlUtil.computeChangeRequestsPath;
+import static com.google.gerrit.httpd.raw.IndexHtmlUtil.dynamicTemplateData;
import static com.google.gerrit.httpd.raw.IndexHtmlUtil.experimentData;
import static com.google.gerrit.httpd.raw.IndexHtmlUtil.staticTemplateData;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.Accounts;
+import com.google.gerrit.extensions.api.config.Config;
+import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.common.ServerInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
import com.google.template.soy.data.SanitizedContent;
import com.google.template.soy.data.UnsafeSanitizedContentOrdainer;
import java.util.HashMap;
@@ -35,12 +42,7 @@
public void noPathAndNoCDN() throws Exception {
assertThat(
staticTemplateData(
- "http://example.com/",
- null,
- null,
- new HashMap<>(),
- IndexHtmlUtilTest::ordain,
- null))
+ "http://example.com/", null, null, new HashMap<>(), IndexHtmlUtilTest::ordain))
.containsExactly("canonicalPath", "", "staticResourcePath", ordain(""));
}
@@ -52,8 +54,7 @@
null,
null,
new HashMap<>(),
- IndexHtmlUtilTest::ordain,
- null))
+ IndexHtmlUtilTest::ordain))
.containsExactly("canonicalPath", "/gerrit", "staticResourcePath", ordain("/gerrit"));
}
@@ -65,8 +66,7 @@
"http://my-cdn.com/foo/bar/",
null,
new HashMap<>(),
- IndexHtmlUtilTest::ordain,
- null))
+ IndexHtmlUtilTest::ordain))
.containsExactly(
"canonicalPath", "", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
}
@@ -79,8 +79,7 @@
"http://my-cdn.com/foo/bar/",
null,
new HashMap<>(),
- IndexHtmlUtilTest::ordain,
- null))
+ IndexHtmlUtilTest::ordain))
.containsExactly(
"canonicalPath", "/gerrit", "staticResourcePath", ordain("http://my-cdn.com/foo/bar/"));
}
@@ -91,51 +90,36 @@
urlParms.put("gf", new String[0]);
assertThat(
staticTemplateData(
- "http://example.com/", null, null, urlParms, IndexHtmlUtilTest::ordain, null))
+ "http://example.com/", null, null, urlParms, IndexHtmlUtilTest::ordain))
.containsExactly(
"canonicalPath", "", "staticResourcePath", ordain(""), "useGoogleFonts", "true");
}
@Test
public void usePreloadRest() throws Exception {
- Map<String, String[]> urlParms = new HashMap<>();
- assertThat(
- staticTemplateData(
- "http://example.com/",
- null,
- null,
- urlParms,
- IndexHtmlUtilTest::ordain,
- "/c/project/+/123"))
- .containsExactly(
- "canonicalPath", "",
- "staticResourcePath", ordain(""),
+ Accounts accountsApi = mock(Accounts.class);
+ when(accountsApi.self()).thenThrow(new AuthException("user needs to be authenticated"));
+
+ Server serverApi = mock(Server.class);
+ when(serverApi.getVersion()).thenReturn("123");
+ when(serverApi.topMenus()).thenReturn(ImmutableList.of());
+ ServerInfo serverInfo = new ServerInfo();
+ serverInfo.defaultTheme = "my-default-theme";
+ when(serverApi.getInfo()).thenReturn(serverInfo);
+
+ Config configApi = mock(Config.class);
+ when(configApi.server()).thenReturn(serverApi);
+
+ GerritApi gerritApi = mock(GerritApi.class);
+ when(gerritApi.accounts()).thenReturn(accountsApi);
+ when(gerritApi.config()).thenReturn(configApi);
+
+ assertThat(dynamicTemplateData(gerritApi, "/c/project/+/123"))
+ .containsAtLeast(
"defaultChangeDetailHex", "916314",
- "defaultDiffDetailHex", "800014",
- "preloadChangePage", "true",
"changeRequestsPath", "changes/project~123");
}
- @Test
- public void computeChangePath() throws Exception {
- assertThat(computeChangeRequestsPath("/c/project/+/123", CHANGE_URL_PATTERN))
- .isEqualTo("changes/project~123");
-
- assertThat(computeChangeRequestsPath("/c/project/+/124/2", CHANGE_URL_PATTERN))
- .isEqualTo("changes/project~124");
-
- assertThat(computeChangeRequestsPath("/c/project/src/+/23", CHANGE_URL_PATTERN))
- .isEqualTo("changes/project%2Fsrc~23");
-
- assertThat(computeChangeRequestsPath("/q/project/src/+/23", CHANGE_URL_PATTERN))
- .isEqualTo(null);
-
- assertThat(computeChangeRequestsPath("/c/Scripts/+/232/1//COMMIT_MSG", CHANGE_URL_PATTERN))
- .isEqualTo(null);
- assertThat(computeChangeRequestsPath("/c/Scripts/+/232/1//COMMIT_MSG", DIFF_URL_PATTERN))
- .isEqualTo("changes/Scripts~232");
- }
-
private static SanitizedContent ordain(String s) {
return UnsafeSanitizedContentOrdainer.ordainAsSafe(
s, SanitizedContent.ContentKind.TRUSTED_RESOURCE_URI);
diff --git a/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
new file mode 100644
index 0000000..e1cccf8
--- /dev/null
+++ b/javatests/com/google/gerrit/httpd/raw/IndexPreloadingUtilTest.java
@@ -0,0 +1,61 @@
+// Copyright (C) 2019 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.httpd.raw;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.httpd.raw.IndexPreloadingUtil.computeChangeRequestsPath;
+import static com.google.gerrit.httpd.raw.IndexPreloadingUtil.parseRequestedPage;
+
+import com.google.gerrit.httpd.raw.IndexPreloadingUtil.RequestedPage;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class IndexPreloadingUtilTest {
+
+ @Test
+ public void computeChangePath() throws Exception {
+ assertThat(computeChangeRequestsPath("/c/project/+/123", RequestedPage.CHANGE))
+ .hasValue("changes/project~123");
+
+ assertThat(computeChangeRequestsPath("/c/project/+/124/2", RequestedPage.CHANGE))
+ .hasValue("changes/project~124");
+
+ assertThat(computeChangeRequestsPath("/c/project/src/+/23", RequestedPage.CHANGE))
+ .hasValue("changes/project%2Fsrc~23");
+
+ assertThat(computeChangeRequestsPath("/q/project/src/+/23", RequestedPage.CHANGE).isPresent())
+ .isFalse();
+
+ assertThat(
+ computeChangeRequestsPath("/c/Scripts/+/232/1//COMMIT_MSG", RequestedPage.CHANGE)
+ .isPresent())
+ .isFalse();
+ assertThat(computeChangeRequestsPath("/c/Scripts/+/232/1//COMMIT_MSG", RequestedPage.DIFF))
+ .hasValue("changes/Scripts~232");
+ }
+
+ @Test
+ public void preloadOnlyForSelfDashboard() throws Exception {
+ assertThat(parseRequestedPage("/dashboard/self")).isEqualTo(RequestedPage.DASHBOARD);
+ assertThat(parseRequestedPage("/dashboard/1085901"))
+ .isEqualTo(RequestedPage.PAGE_WITHOUT_PRELOADING);
+ assertThat(parseRequestedPage("/dashboard/gerrit"))
+ .isEqualTo(RequestedPage.PAGE_WITHOUT_PRELOADING);
+ assertThat(parseRequestedPage("/")).isEqualTo(RequestedPage.DASHBOARD);
+ }
+}
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index 8577c16..76ce956 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -28,9 +28,9 @@
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.UseSsh;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.Permission;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.GerritApi;
diff --git a/javatests/com/google/gerrit/json/JsonEnumMappingTest.java b/javatests/com/google/gerrit/json/JsonEnumMappingTest.java
index dd710f9..0df0d2e 100644
--- a/javatests/com/google/gerrit/json/JsonEnumMappingTest.java
+++ b/javatests/com/google/gerrit/json/JsonEnumMappingTest.java
@@ -15,8 +15,10 @@
package com.google.gerrit.json;
import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
import org.junit.Test;
public class JsonEnumMappingTest {
@@ -46,27 +48,27 @@
}
@Test
- public void mixedCaseEnumValueIsTreatedAsUnset() {
- TestData data = gson.fromJson("{\"value\":\"oNe\"}", TestData.class);
- assertThat(data.value).isNull();
+ public void mixedCaseEnumValueIsRejectedOnParse() {
+ assertThrows(
+ JsonSyntaxException.class, () -> gson.fromJson("{\"value\":\"oNe\"}", TestData.class));
}
@Test
- public void lowerCaseEnumValueIsTreatedAsUnset() {
- TestData data = gson.fromJson("{\"value\":\"one\"}", TestData.class);
- assertThat(data.value).isNull();
+ public void lowerCaseEnumValueIsRejectedOnParse() {
+ assertThrows(
+ JsonSyntaxException.class, () -> gson.fromJson("{\"value\":\"one\"}", TestData.class));
}
@Test
- public void notExistingEnumValueIsTreatedAsUnset() {
- TestData data = gson.fromJson("{\"value\":\"FOUR\"}", TestData.class);
- assertThat(data.value).isNull();
+ public void notExistingEnumValueIsRejectedOnParse() {
+ assertThrows(
+ JsonSyntaxException.class, () -> gson.fromJson("{\"value\":\"FOUR\"}", TestData.class));
}
@Test
- public void emptyEnumValueIsTreatedAsUnset() {
- TestData data = gson.fromJson("{\"value\":\"\"}", TestData.class);
- assertThat(data.value).isNull();
+ public void emptyEnumValueIsRejectedOnParse() {
+ assertThrows(
+ JsonSyntaxException.class, () -> gson.fromJson("{\"value\":\"\"}", TestData.class));
}
private static class TestData {
diff --git a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
index 586f1bc..64fa74f 100644
--- a/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
+++ b/javatests/com/google/gerrit/server/auth/oauth/OAuthTokenCacheTest.java
@@ -19,6 +19,7 @@
import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
import com.google.gerrit.extensions.auth.oauth.OAuthToken;
import com.google.gerrit.proto.testing.SerializedClassSubject;
import com.google.gerrit.server.cache.proto.Cache.OAuthTokenProto;
@@ -71,6 +72,23 @@
assertThat(s.deserialize(serializedWithEmptyString)).isEqualTo(tokenWithNull);
}
+ @Test
+ public void serializeAndDeserializeBackAccountId() {
+ OAuthTokenCache.AccountIdSerializer serializer = OAuthTokenCache.AccountIdSerializer.INSTANCE;
+
+ Account.Id id = Account.id(1234);
+ assertThat(serializer.deserialize(serializer.serialize(id))).isEqualTo(id);
+ }
+
+ // Anonymous classes can break some cache implementations that try to parse the
+ // serializer class name and expect a well-defined class name: test that
+ // OAuthTokenCache.AccountIdSerializer is not an anonymous class.
+ @Test
+ public void accountIdSerializerIsNotAnAnonymousClass() {
+ assertThat(OAuthTokenCache.AccountIdSerializer.INSTANCE.getDeclaringClass().getSimpleName())
+ .isNotEmpty();
+ }
+
/** See {@link SerializedClassSubject} for background and what to do if this test fails. */
@Test
public void oAuthTokenFields() throws Exception {
diff --git a/javatests/com/google/gerrit/server/cache/BUILD b/javatests/com/google/gerrit/server/cache/BUILD
index c255e61..b3b2f5a 100644
--- a/javatests/com/google/gerrit/server/cache/BUILD
+++ b/javatests/com/google/gerrit/server/cache/BUILD
@@ -1,8 +1,9 @@
load("//tools/bzl:junit.bzl", "junit_tests")
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
junit_tests(
name = "tests",
- srcs = glob(["*.java"]),
+ srcs = glob(["*Test.java"]),
deps = [
"//java/com/google/gerrit/server",
"//java/com/google/gerrit/testing:gerrit-test-util",
@@ -10,3 +11,9 @@
"//lib/truth",
],
)
+
+acceptance_tests(
+ srcs = glob(["*IT.java"]),
+ group = "server_cache",
+ labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
index d19073d..5d420d3 100644
--- a/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/PerThreadCacheTest.java
@@ -21,12 +21,16 @@
import org.junit.Test;
public class PerThreadCacheTest {
+
+ @SuppressWarnings("TruthIncompatibleType")
@Test
public void key_respectsClass() {
assertThat(PerThreadCache.Key.create(String.class))
.isEqualTo(PerThreadCache.Key.create(String.class));
assertThat(PerThreadCache.Key.create(String.class))
- .isNotEqualTo(PerThreadCache.Key.create(Integer.class));
+ .isNotEqualTo(
+ /* expected: Key<String>, actual: Key<Integer> */ PerThreadCache.Key.create(
+ Integer.class));
}
@Test
diff --git a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
new file mode 100644
index 0000000..d8c6fe2
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
@@ -0,0 +1,74 @@
+// 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.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.server.ModuleImpl;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class PersistentCacheFactoryIT extends AbstractDaemonTest {
+
+ @Inject PersistentCacheFactory persistentCacheFactory;
+
+ @ModuleImpl(name = CacheModule.PERSISTENT_MODULE)
+ public static class Module extends AbstractModule {
+
+ @Override
+ protected void configure() {
+ bind(PersistentCacheFactory.class).to(TestCacheFactory.class);
+ }
+ }
+
+ @Override
+ public com.google.inject.Module createModule() {
+ return new Module();
+ }
+
+ @Test
+ public void shouldH2PersistentCacheBeReplaceableByADifferentCacheImplementation() {
+ assertThat(persistentCacheFactory).isInstanceOf(TestCacheFactory.class);
+ }
+
+ public static class TestCacheFactory implements PersistentCacheFactory {
+
+ private final MemoryCacheFactory memoryCacheFactory;
+
+ @Inject
+ TestCacheFactory(MemoryCacheFactory memoryCacheFactory) {
+ this.memoryCacheFactory = memoryCacheFactory;
+ }
+
+ @Override
+ public <K, V> com.google.common.cache.Cache<K, V> build(
+ PersistentCacheDef<K, V> def, CacheBackend backend) {
+ return memoryCacheFactory.build(def, backend);
+ }
+
+ @Override
+ public <K, V> LoadingCache<K, V> build(
+ PersistentCacheDef<K, V> def, CacheLoader<K, V> loader, CacheBackend backend) {
+ return memoryCacheFactory.build(def, loader, backend);
+ }
+
+ @Override
+ public void onStop(String plugin) {}
+ }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/BUILD b/javatests/com/google/gerrit/server/cache/serialize/BUILD
index fa6a717..6976d19 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/BUILD
@@ -5,6 +5,7 @@
srcs = glob(["*.java"]),
deps = [
"//java/com/google/gerrit/entities",
+ "//java/com/google/gerrit/extensions:api",
"//java/com/google/gerrit/server",
"//java/com/google/gerrit/server/cache/serialize",
"//java/com/google/gerrit/server/cache/testing",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
new file mode 100644
index 0000000..84f290c
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -0,0 +1,41 @@
+package com.google.gerrit.server.cache.serialize;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.comment.CommentContextCacheImpl.CommentContextSerializer.INSTANCE;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.CommentContext;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.comment.CommentContextKey;
+import org.junit.Test;
+
+public class CommentContextSerializerTest {
+ @Test
+ public void roundTripValue() {
+ CommentContext commentContext =
+ CommentContext.create(ImmutableMap.of(1, "line_1", 2, "line_2"));
+
+ byte[] serialized = INSTANCE.serialize(commentContext);
+ CommentContext deserialized = INSTANCE.deserialize(serialized);
+
+ assertThat(commentContext).isEqualTo(deserialized);
+ }
+
+ @Test
+ public void roundTripKey() {
+ Project.NameKey proj = Project.NameKey.parse("project");
+ Change.Id changeId = Change.Id.tryParse("1234").get();
+
+ CommentContextKey k =
+ CommentContextKey.builder()
+ .project(proj)
+ .changeId(changeId)
+ .id("commentId")
+ .path("pathHash")
+ .patchset(1)
+ .build();
+ byte[] serialized = CommentContextKey.Serializer.INSTANCE.serialize(k);
+ assertThat(k).isEqualTo(CommentContextKey.Serializer.INSTANCE.deserialize(serialized));
+ }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializerTest.java
index 660b9e6..40a8105 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/AccessSectionSerializerTest.java
@@ -18,17 +18,18 @@
import static com.google.gerrit.server.cache.serialize.entities.AccessSectionSerializer.deserialize;
import static com.google.gerrit.server.cache.serialize.entities.AccessSectionSerializer.serialize;
-import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.entities.AccessSection;
import org.junit.Test;
public class AccessSectionSerializerTest {
+ static final AccessSection ALL_VALUES_SET =
+ AccessSection.builder("refs/test")
+ .addPermission(PermissionSerializerTest.ALL_VALUES_SET.toBuilder())
+ .build();
+
@Test
public void roundTrip() {
- AccessSection autoValue =
- AccessSection.builder("refs/test")
- .addPermission(PermissionSerializerTest.ALL_VALUES_SET.toBuilder())
- .build();
- assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+ assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
}
@Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
index 5470553..b84febb 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BUILD
@@ -7,11 +7,13 @@
"//java/com/google/gerrit/common:server",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
+ "//java/com/google/gerrit/server",
"//java/com/google/gerrit/server/cache/serialize",
"//java/com/google/gerrit/server/cache/serialize/entities",
"//java/com/google/gerrit/server/cache/testing",
"//java/com/google/gerrit/testing:gerrit-test-util",
"//lib:guava",
+ "//lib:jgit",
"//lib:protobuf",
"//lib/truth",
"//lib/truth:truth-proto-extension",
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializerTest.java
index f3a0445..10b905a 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/BranchOrderSectionSerializerTest.java
@@ -23,9 +23,11 @@
import org.junit.Test;
public class BranchOrderSectionSerializerTest {
+ static final BranchOrderSection ALL_VALUES_SET =
+ BranchOrderSection.create(ImmutableList.of("master", "stable"));
+
@Test
public void roundTrip() {
- BranchOrderSection autoValue = BranchOrderSection.create(ImmutableList.of("master", "stable"));
- assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+ assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
}
}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
new file mode 100644
index 0000000..c7e09dc
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializerTest.java
@@ -0,0 +1,72 @@
+// 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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.CachedProjectConfigSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.CachedProjectConfigSerializer.serialize;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.AccountsSection;
+import com.google.gerrit.entities.CachedProjectConfig;
+import com.google.gerrit.entities.ConfiguredMimeTypes;
+import com.google.gerrit.entities.PermissionRule;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class CachedProjectConfigSerializerTest {
+ static final CachedProjectConfig MINIMAL_VALUES_SET =
+ CachedProjectConfig.builder()
+ .setProject(ProjectSerializerTest.ALL_VALUES_SET)
+ .setMimeTypes(
+ ConfiguredMimeTypes.create(
+ ImmutableList.of(new ConfiguredMimeTypes.ReType("type", "pattern"))))
+ .setAccountsSection(
+ AccountsSection.create(
+ ImmutableList.of(
+ PermissionRule.create(GroupReferenceSerializerTest.ALL_VALUES_SET))))
+ .setMaxObjectSizeLimit(123)
+ .setCheckReceivedObjects(true)
+ .build();
+
+ static final CachedProjectConfig ALL_VALUES_SET =
+ MINIMAL_VALUES_SET
+ .toBuilder()
+ .addGroup(GroupReferenceSerializerTest.ALL_VALUES_SET)
+ .addAccessSection(AccessSectionSerializerTest.ALL_VALUES_SET)
+ .setBranchOrderSection(Optional.of(BranchOrderSectionSerializerTest.ALL_VALUES_SET))
+ .addNotifySection(NotifyConfigSerializerTest.ALL_VALUES_SET)
+ .addLabelSection(LabelTypeSerializerTest.ALL_VALUES_SET)
+ .addSubscribeSection(SubscribeSectionSerializerTest.ALL_VALUES_SET)
+ .addCommentLinkSection(StoredCommentLinkInfoSerializerTest.HTML_ONLY)
+ .setRevision(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
+ .setRulesId(Optional.of(ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")))
+ .setExtensionPanelSections(ImmutableMap.of("key1", ImmutableList.of("val1", "val2")))
+ .addPluginConfig("foo-plugin", "[plugin \"foo-plugin\"]\n\tkey = value")
+ .addProjectLevelConfig("foo-plugin.config", "[section]\n\tkey = value")
+ .build();
+
+ @Test
+ public void roundTrip() {
+ assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
+ }
+
+ @Test
+ public void roundTripWithMinimalValues() {
+ assertThat(deserialize(serialize(MINIMAL_VALUES_SET))).isEqualTo(MINIMAL_VALUES_SET);
+ }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializerTest.java
index 81372d5..99e3c07 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ContributorAgreementSerializerTest.java
@@ -19,9 +19,9 @@
import static com.google.gerrit.server.cache.serialize.entities.ContributorAgreementSerializer.serialize;
import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.ContributorAgreement;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
import org.junit.Test;
public class ContributorAgreementSerializerTest {
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
new file mode 100644
index 0000000..caf1fbb
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitModifiedFilesCacheKeySerializerTest.java
@@ -0,0 +1,43 @@
+// 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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey.Serializer;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class GitModifiedFilesCacheKeySerializerTest {
+ private static final ObjectId TREE_ID_1 =
+ ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+ private static final ObjectId TREE_ID_2 =
+ ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+ @Test
+ public void roundTrip() {
+ GitModifiedFilesCacheKey key =
+ GitModifiedFilesCacheKey.builder()
+ .project(Project.NameKey.parse("Project/X"))
+ .aTree(TREE_ID_1)
+ .bTree(TREE_ID_2)
+ .renameScore(65)
+ .build();
+ byte[] serialized = Serializer.INSTANCE.serialize(key);
+ assertThat(Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+ }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializerTest.java
index a5092e0..f366337 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GroupReferenceSerializerTest.java
@@ -23,11 +23,12 @@
import org.junit.Test;
public class GroupReferenceSerializerTest {
+ static final GroupReference ALL_VALUES_SET =
+ GroupReference.create(AccountGroup.uuid("uuid"), "name");
+
@Test
public void roundTrip() {
- GroupReference groupReferenceAutoValue =
- GroupReference.create(AccountGroup.uuid("uuid"), "name");
- assertThat(deserialize(serialize(groupReferenceAutoValue))).isEqualTo(groupReferenceAutoValue);
+ assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
}
@Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
index fac662d..a82fdb9 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -19,7 +19,7 @@
import static com.google.gerrit.server.cache.serialize.entities.LabelTypeSerializer.serialize;
import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import org.junit.Test;
@@ -30,22 +30,22 @@
ImmutableList.of(
LabelValue.create((short) 0, "no vote"),
LabelValue.create((short) 1, "approved")))
- .setCanOverride(true)
- .setAllowPostSubmit(true)
- .setIgnoreSelfApproval(true)
+ .setCanOverride(!LabelType.DEF_CAN_OVERRIDE)
+ .setAllowPostSubmit(!LabelType.DEF_ALLOW_POST_SUBMIT)
+ .setIgnoreSelfApproval(!LabelType.DEF_IGNORE_SELF_APPROVAL)
.setRefPatterns(ImmutableList.of("refs/heads/*", "refs/tags/*"))
.setDefaultValue((short) 1)
- .setCopyAnyScore(true)
- .setCopyMaxScore(true)
- .setCopyMinScore(true)
- .setCopyAllScoresOnMergeFirstParentUpdate(true)
- .setCopyAllScoresOnTrivialRebase(true)
- .setCopyAllScoresIfNoCodeChange(true)
- .setCopyAllScoresIfNoChange(true)
+ .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
+ .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
+ .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
+ .setCopyAllScoresOnMergeFirstParentUpdate(
+ !LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE)
+ .setCopyAllScoresOnTrivialRebase(!LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE)
+ .setCopyAllScoresIfNoCodeChange(!LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE)
+ .setCopyAllScoresIfNoChange(!LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE)
.setCopyValues(ImmutableList.of((short) 0, (short) 1))
.setMaxNegative((short) -1)
.setMaxPositive((short) 1)
- .setCanOverride(true)
.build();
@Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
new file mode 100644
index 0000000..b39ba57
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesCacheKeySerializerTest.java
@@ -0,0 +1,42 @@
+// 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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class ModifiedFilesCacheKeySerializerTest {
+ private static final ObjectId COMMIT_ID_1 =
+ ObjectId.fromString("123e9fa8a286255ac7d5ba11b598892735758391");
+ private static final ObjectId COMMIT_ID_2 =
+ ObjectId.fromString("d07a03a9818c120301cb5b4a969b035479400b5f");
+
+ @Test
+ public void roundTrip() {
+ ModifiedFilesCacheKey key =
+ ModifiedFilesCacheKey.builder()
+ .project(Project.NameKey.parse("Project/X"))
+ .aCommit(COMMIT_ID_1)
+ .bCommit(COMMIT_ID_2)
+ .renameScore(65)
+ .build();
+ byte[] serialized = ModifiedFilesCacheKey.Serializer.INSTANCE.serialize(key);
+ assertThat(ModifiedFilesCacheKey.Serializer.INSTANCE.deserialize(serialized)).isEqualTo(key);
+ }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
new file mode 100644
index 0000000..bff0c5d
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ModifiedFilesSerializerTest.java
@@ -0,0 +1,56 @@
+// 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.cache.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheImpl.ValueSerializer;
+import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
+import java.util.Optional;
+import org.junit.Test;
+
+public class ModifiedFilesSerializerTest {
+ @Test
+ public void roundTrip() {
+ ImmutableList.Builder<ModifiedFile> builder = ImmutableList.builder();
+
+ builder.add(
+ ModifiedFile.builder()
+ .changeType(ChangeType.DELETED)
+ .oldPath(Optional.of("file_1.txt"))
+ .newPath(Optional.of("file_2.txt"))
+ .build());
+ builder.add(
+ ModifiedFile.builder()
+ .changeType(ChangeType.ADDED)
+ .oldPath(Optional.empty())
+ .newPath(Optional.of("file_3.txt"))
+ .build());
+
+ // Note: the default value for strings in protocol buffers is the empty string, hence the
+ // serializer will not be able to differentiate between an empty optional and an optional
+ // with an empty string, i.e. if we serialize an optional with an empty string, the deserialized
+ // object will be an empty optional. That should not be problematic in this case because file
+ // paths cannot be empty anyway.
+
+ ImmutableList<ModifiedFile> modifiedFiles = builder.build();
+
+ byte[] serialized = ValueSerializer.INSTANCE.serialize(modifiedFiles);
+
+ assertThat(ValueSerializer.INSTANCE.deserialize(serialized)).isEqualTo(modifiedFiles);
+ }
+}
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializerTest.java
index 3447ae3..5052dfc 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/NotifyConfigSerializerTest.java
@@ -25,18 +25,19 @@
import org.junit.Test;
public class NotifyConfigSerializerTest {
+ static final NotifyConfig ALL_VALUES_SET =
+ NotifyConfig.builder()
+ .setName("foo-bar")
+ .addAddress(Address.create("address@example.com"))
+ .addGroup(GroupReference.create("group-uuid"))
+ .setHeader(NotifyConfig.Header.CC)
+ .setFilter("filter")
+ .setNotify(ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS))
+ .build();
+
@Test
public void roundTrip() {
- NotifyConfig autoValue =
- NotifyConfig.builder()
- .setName("foo-bar")
- .addAddress(Address.create("address@example.com"))
- .addGroup(GroupReference.create("group-uuid"))
- .setHeader(NotifyConfig.Header.CC)
- .setFilter("filter")
- .setNotify(ImmutableSet.of(NotifyConfig.NotifyType.ALL_COMMENTS))
- .build();
- assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+ assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
}
@Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializerTest.java
index 3ce3549..a3e6b93 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionRuleSerializerTest.java
@@ -18,8 +18,8 @@
import static com.google.gerrit.server.cache.serialize.entities.PermissionRuleSerializer.deserialize;
import static com.google.gerrit.server.cache.serialize.entities.PermissionRuleSerializer.serialize;
-import com.google.gerrit.common.data.PermissionRule;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.PermissionRule;
import org.junit.Test;
public class PermissionRuleSerializerTest {
@@ -28,7 +28,7 @@
PermissionRule permissionRuleAutoValue =
PermissionRule.builder(GroupReference.create("name"))
.setAction(PermissionRule.Action.BATCH)
- .setForce(true)
+ .setForce(!PermissionRule.DEF_FORCE)
.setMax(321)
.setMin(123)
.build();
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionSerializerTest.java
index ae399eb..2007bca 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/PermissionSerializerTest.java
@@ -18,15 +18,15 @@
import static com.google.gerrit.server.cache.serialize.entities.PermissionSerializer.deserialize;
import static com.google.gerrit.server.cache.serialize.entities.PermissionSerializer.serialize;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import org.junit.Test;
public class PermissionSerializerTest {
static final Permission ALL_VALUES_SET =
Permission.builder(Permission.ABANDON)
- .setExclusiveGroup(true)
+ .setExclusiveGroup(!Permission.DEF_EXCLUSIVE_GROUP)
.add(PermissionRule.builder(GroupReference.create("group")))
.build();
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
index 8d13247..29fd5ed 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/ProjectSerializerTest.java
@@ -26,26 +26,25 @@
import org.junit.Test;
public class ProjectSerializerTest {
+ static final Project ALL_VALUES_SET =
+ Project.builder(Project.nameKey("test"))
+ .setDescription("desc")
+ .setSubmitType(SubmitType.FAST_FORWARD_ONLY)
+ .setState(ProjectState.HIDDEN)
+ .setParent(Project.nameKey("parent"))
+ .setMaxObjectSizeLimit("11K")
+ .setDefaultDashboard("dashboard1")
+ .setLocalDefaultDashboard("dashboard2")
+ .setConfigRefState("1337")
+ .setBooleanConfig(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE)
+ .setBooleanConfig(
+ BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
+ InheritableBoolean.INHERIT)
+ .build();
+
@Test
public void roundTrip() {
- Project projectAutoValue =
- Project.builder(Project.nameKey("test"))
- .setDescription("desc")
- .setSubmitType(SubmitType.FAST_FORWARD_ONLY)
- .setState(ProjectState.HIDDEN)
- .setParent(Project.nameKey("parent"))
- .setMaxObjectSizeLimit("11K")
- .setDefaultDashboard("dashboard1")
- .setLocalDefaultDashboard("dashboard2")
- .setConfigRefState("1337")
- .setBooleanConfig(
- BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.TRUE)
- .setBooleanConfig(
- BooleanProjectConfig.CREATE_NEW_CHANGE_FOR_ALL_NOT_IN_TARGET,
- InheritableBoolean.INHERIT)
- .build();
-
- assertThat(deserialize(serialize(projectAutoValue))).isEqualTo(projectAutoValue);
+ assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
}
@Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
index ccd2378..3a51b70 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/StoredCommentLinkInfoSerializerTest.java
@@ -22,15 +22,16 @@
import org.junit.Test;
public class StoredCommentLinkInfoSerializerTest {
+ static final StoredCommentLinkInfo HTML_ONLY =
+ StoredCommentLinkInfo.builder("name")
+ .setEnabled(true)
+ .setHtml("<p>html")
+ .setMatch("*")
+ .build();
+
@Test
public void htmlOnly_roundTrip() {
- StoredCommentLinkInfo autoValue =
- StoredCommentLinkInfo.builder("name")
- .setEnabled(true)
- .setHtml("<p>html")
- .setMatch("*")
- .build();
- assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+ assertThat(deserialize(serialize(HTML_ONLY))).isEqualTo(HTML_ONLY);
}
@Test
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializerTest.java
index fc96932..1648eca 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubscribeSectionSerializerTest.java
@@ -18,20 +18,21 @@
import static com.google.gerrit.server.cache.serialize.entities.SubscribeSectionSerializer.deserialize;
import static com.google.gerrit.server.cache.serialize.entities.SubscribeSectionSerializer.serialize;
-import com.google.gerrit.common.data.SubscribeSection;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubscribeSection;
import org.junit.Test;
public class SubscribeSectionSerializerTest {
+ static final SubscribeSection ALL_VALUES_SET =
+ SubscribeSection.builder(Project.nameKey("project"))
+ .addMultiMatchRefSpec("multi")
+ .addMultiMatchRefSpec("multi2")
+ .addMatchingRefSpec("matching1")
+ .addMatchingRefSpec("matching2")
+ .build();
+
@Test
public void roundTrip() {
- SubscribeSection autoValue =
- SubscribeSection.builder(Project.nameKey("project"))
- .addMultiMatchRefSpec("multi")
- .addMultiMatchRefSpec("multi2")
- .addMatchingRefSpec("matching1")
- .addMatchingRefSpec("matching2")
- .build();
- assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
+ assertThat(deserialize(serialize(ALL_VALUES_SET))).isEqualTo(ALL_VALUES_SET);
}
}
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/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index ea210ab..683f5a6 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -15,7 +15,7 @@
package com.google.gerrit.server.change;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
-import static com.google.gerrit.common.data.Permission.forLabel;
+import static com.google.gerrit.entities.Permission.forLabel;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.project.testing.TestLabels.label;
import static com.google.gerrit.server.project.testing.TestLabels.value;
@@ -23,11 +23,11 @@
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.extensions.api.GerritApi;
import com.google.gerrit.extensions.common.ChangeInfo;
diff --git a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
index a618c9e..6309944 100644
--- a/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
+++ b/javatests/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
@@ -18,6 +18,7 @@
import com.google.common.io.CharStreams;
import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
import com.google.common.truth.StringSubject;
import com.google.common.truth.Subject;
import com.google.gerrit.extensions.restapi.RawInput;
@@ -45,9 +46,9 @@
this.modification = modification;
}
- public StringSubject filePath() {
+ public IterableSubject filePaths() {
isNotNull();
- return check("getFilePath()").that(modification.getFilePath());
+ return check("getFilePaths()").that(modification.getFilePaths());
}
public StringSubject newContent() throws IOException {
diff --git a/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java b/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java
new file mode 100644
index 0000000..7e1a23b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/edit/tree/TreeCreatorTest.java
@@ -0,0 +1,153 @@
+// 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.edit.tree;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.RawInputUtil;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class TreeCreatorTest {
+
+ private Repository repository;
+ private TestRepository<?> testRepository;
+
+ @Before
+ public void setUp() throws Exception {
+ repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
+ testRepository = new TestRepository<>(repository);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (testRepository != null) {
+ testRepository.close();
+ }
+ }
+
+ @Test
+ public void fileContentModificationWorksWithEmptyTree() throws Exception {
+ TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+ treeCreator.addTreeModifications(
+ ImmutableList.of(
+ new ChangeFileContentModification("file.txt", RawInputUtil.create("Line 1"))));
+ ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+ String fileContent = getFileContent(newTreeId, "file.txt");
+ assertThat(fileContent).isEqualTo("Line 1");
+ }
+
+ @Test
+ public void renameFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+ TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+ treeCreator.addTreeModifications(
+ ImmutableList.of(new RenameFileModification("oldfileName", "newFileName")));
+ ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+ assertThat(isEmptyTree(newTreeId)).isTrue();
+ }
+
+ @Test
+ public void deleteFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+ TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+ treeCreator.addTreeModifications(ImmutableList.of(new DeleteFileModification("file.txt")));
+ ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+ assertThat(isEmptyTree(newTreeId)).isTrue();
+ }
+
+ @Test
+ public void restoreFileModificationDoesNotComplainAboutEmptyTree() throws Exception {
+ TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+ treeCreator.addTreeModifications(ImmutableList.of(new RestoreFileModification("file.txt")));
+ ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
+
+ assertThat(isEmptyTree(newTreeId)).isTrue();
+ }
+
+ @Test
+ public void modificationsMustNotReferToSameFilePaths() {
+ TreeCreator treeCreator = TreeCreator.basedOnEmptyTree();
+ treeCreator.addTreeModifications(
+ ImmutableList.of(
+ new RenameFileModification("oldFileName", "newFileName"),
+ new ChangeFileContentModification(
+ "newFileName", RawInputUtil.create("Different content"))));
+ IllegalStateException exception =
+ assertThrows(
+ IllegalStateException.class, () -> treeCreator.createNewTreeAndGetId(repository));
+
+ assertThat(exception).hasMessageThat().contains("oldFileName");
+ assertThat(exception).hasMessageThat().contains("newFileName");
+ }
+
+ @Test
+ public void fileContentModificationRefersToModifiedFile() {
+ ChangeFileContentModification contentModification =
+ new ChangeFileContentModification("myFileName", RawInputUtil.create("Some content"));
+ assertThat(contentModification.getFilePaths()).containsExactly("myFileName");
+ }
+
+ @Test
+ public void renameFileModificationRefersToOldAndNewFilePath() {
+ RenameFileModification fileModification =
+ new RenameFileModification("oldFileName", "newFileName");
+ assertThat(fileModification.getFilePaths()).containsExactly("oldFileName", "newFileName");
+ }
+
+ @Test
+ public void deleteFileModificationRefersToDeletedFile() {
+ DeleteFileModification fileModification = new DeleteFileModification("myFileName");
+ assertThat(fileModification.getFilePaths()).containsExactly("myFileName");
+ }
+
+ @Test
+ public void restoreFileModificationRefersToRestoredFile() {
+ RestoreFileModification fileModification = new RestoreFileModification("myFileName");
+ assertThat(fileModification.getFilePaths()).containsExactly("myFileName");
+ }
+
+ private String getFileContent(ObjectId treeId, String filePath) throws Exception {
+ try (RevWalk revWalk = new RevWalk(repository);
+ ObjectReader reader = revWalk.getObjectReader()) {
+ RevTree revTree = revWalk.parseTree(treeId);
+ RevObject revObject = testRepository.get(revTree, filePath);
+ return new String(reader.open(revObject, OBJ_BLOB).getBytes(), UTF_8);
+ }
+ }
+
+ private boolean isEmptyTree(ObjectId treeId) throws Exception {
+ try (TreeWalk treeWalk = new TreeWalk(repository)) {
+ treeWalk.reset(treeId);
+ return !treeWalk.next();
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
index 8b5705b..a3e86a3 100644
--- a/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
+++ b/javatests/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
@@ -15,7 +15,8 @@
package com.google.gerrit.server.fixes;
import static com.google.gerrit.server.edit.tree.TreeModificationSubject.assertThatList;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.OptionalSubject.assertThat;
+import static java.util.Comparator.comparing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -23,12 +24,11 @@
import com.google.gerrit.entities.Comment.Range;
import com.google.gerrit.entities.FixReplacement;
import com.google.gerrit.extensions.restapi.BinaryResult;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.edit.CommitModification;
import com.google.gerrit.server.edit.tree.TreeModification;
import com.google.gerrit.server.project.ProjectState;
import java.util.ArrayList;
-import java.util.Comparator;
import java.util.List;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
@@ -52,8 +52,29 @@
@Test
public void noReplacementsResultInNoTreeModifications() throws Exception {
- List<TreeModification> treeModifications = toTreeModifications();
- assertThatList(treeModifications).isEmpty();
+ CommitModification commitModification = toCommitModification();
+ assertThatList(commitModification.treeModifications()).isEmpty();
+ assertThat(commitModification.newCommitMessage()).isEmpty();
+ }
+
+ @Test
+ public void replacementIsTranslatedToTreeModification() throws Exception {
+ FixReplacement fixReplacement =
+ new FixReplacement(filePath1, new Range(1, 1, 3, 2), "Modified content");
+ mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+ CommitModification commitModification = toCommitModification(fixReplacement);
+ ImmutableList<TreeModification> treeModifications = commitModification.treeModifications();
+ assertThatList(treeModifications)
+ .onlyElement()
+ .asChangeFileContentModification()
+ .filePaths()
+ .containsExactly(filePath1);
+ assertThatList(treeModifications)
+ .onlyElement()
+ .asChangeFileContentModification()
+ .newContent()
+ .isEqualTo("FModified contentird line\n");
}
@Test
@@ -67,14 +88,15 @@
new FixReplacement(filePath2, new Range(2, 0, 3, 0), "Another modified content");
mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
- List<TreeModification> treeModifications =
- toTreeModifications(fixReplacement, fixReplacement3, fixReplacement2);
- List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
+ CommitModification commitModification =
+ toCommitModification(fixReplacement, fixReplacement3, fixReplacement2);
+ List<TreeModification> sortedTreeModifications =
+ getSortedCopy(commitModification.treeModifications());
assertThatList(sortedTreeModifications)
.element(0)
.asChangeFileContentModification()
- .filePath()
- .isEqualTo(filePath1);
+ .filePaths()
+ .containsExactly(filePath1);
assertThatList(sortedTreeModifications)
.element(0)
.asChangeFileContentModification()
@@ -83,8 +105,8 @@
assertThatList(sortedTreeModifications)
.element(1)
.asChangeFileContentModification()
- .filePath()
- .isEqualTo(filePath2);
+ .filePaths()
+ .containsExactly(filePath2);
assertThatList(sortedTreeModifications)
.element(1)
.asChangeFileContentModification()
@@ -93,137 +115,6 @@
}
@Test
- public void replacementsCanDeleteALine() throws Exception {
- FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 0, 3, 0), "");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
- assertThatList(treeModifications)
- .onlyElement()
- .asChangeFileContentModification()
- .newContent()
- .isEqualTo("First line\nThird line\n");
- }
-
- @Test
- public void replacementsCanAddALine() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(2, 0, 2, 0), "A new line\n");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
- assertThatList(treeModifications)
- .onlyElement()
- .asChangeFileContentModification()
- .newContent()
- .isEqualTo("First line\nA new line\nSecond line\nThird line\n");
- }
-
- @Test
- public void replacementsMaySpanMultipleLines() throws Exception {
- FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(1, 6, 3, 1), "and t");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
- assertThatList(treeModifications)
- .onlyElement()
- .asChangeFileContentModification()
- .newContent()
- .isEqualTo("First and third line\n");
- }
-
- @Test
- public void replacementsMayOccurOnSameLine() throws Exception {
- FixReplacement fixReplacement1 = new FixReplacement(filePath1, new Range(2, 0, 2, 6), "A");
- FixReplacement fixReplacement2 =
- new FixReplacement(filePath1, new Range(2, 7, 2, 11), "modification");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- List<TreeModification> treeModifications =
- toTreeModifications(fixReplacement1, fixReplacement2);
- assertThatList(treeModifications)
- .onlyElement()
- .asChangeFileContentModification()
- .newContent()
- .isEqualTo("First line\nA modification\nThird line\n");
- }
-
- @Test()
- public void startAfterEndOfLineMarkThrowsAnException() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(1, 11, 2, 6), "A modification");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
- assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
- }
-
- @Test()
- public void endAfterEndOfLineMarkThrowsAnException() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(2, 0, 2, 12), "A modification");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
- assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
- }
-
- @Test
- public void replacementsMayTouch() throws Exception {
- FixReplacement fixReplacement1 =
- new FixReplacement(filePath1, new Range(1, 6, 2, 7), "modified ");
- FixReplacement fixReplacement2 =
- new FixReplacement(filePath1, new Range(2, 7, 3, 5), "content");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- List<TreeModification> treeModifications =
- toTreeModifications(fixReplacement1, fixReplacement2);
- assertThatList(treeModifications)
- .onlyElement()
- .asChangeFileContentModification()
- .newContent()
- .isEqualTo("First modified content line\n");
- }
-
- @Test
- public void replacementsCanAddContentAtEndOfFile() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(4, 0, 4, 0), "New content");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
- assertThatList(treeModifications)
- .onlyElement()
- .asChangeFileContentModification()
- .newContent()
- .isEqualTo("First line\nSecond line\nThird line\nNew content");
- }
-
- @Test
- public void replacementsCanChangeLastLine() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(3, 0, 4, 0), "New content\n");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
- assertThatList(treeModifications)
- .onlyElement()
- .asChangeFileContentModification()
- .newContent()
- .isEqualTo("First line\nSecond line\nNew content\n");
- }
-
- @Test
- public void replacementsCanChangeLastLineWithoutEOLMark() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(3, 0, 3, 10), "New content\n");
- mockFileContent(filePath1, "First line\nSecond line\nThird line");
-
- List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
- assertThatList(treeModifications)
- .onlyElement()
- .asChangeFileContentModification()
- .newContent()
- .isEqualTo("First line\nSecond line\nNew content\n");
- }
-
- @Test
public void replacementsCanModifySeveralFilesInAnyOrder() throws Exception {
FixReplacement fixReplacement1 =
new FixReplacement(filePath1, new Range(1, 1, 3, 2), "Modified content");
@@ -234,9 +125,10 @@
new FixReplacement(filePath2, new Range(3, 0, 4, 0), "Second modification\n");
mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
- List<TreeModification> treeModifications =
- toTreeModifications(fixReplacement3, fixReplacement1, fixReplacement2);
- List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
+ CommitModification commitModification =
+ toCommitModification(fixReplacement3, fixReplacement1, fixReplacement2);
+ List<TreeModification> sortedTreeModifications =
+ getSortedCopy(commitModification.treeModifications());
assertThatList(sortedTreeModifications)
.element(0)
.asChangeFileContentModification()
@@ -249,98 +141,23 @@
.isEqualTo("1st line\nFirst modification\nSecond modification\n");
}
- @Test
- public void lineSeparatorCanBeChanged() throws Exception {
- FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 11, 3, 0), "\r");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
- assertThatList(treeModifications)
- .onlyElement()
- .asChangeFileContentModification()
- .newContent()
- .isEqualTo("First line\nSecond line\rThird line\n");
- }
-
- @Test
- public void replacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
- FixReplacement fixReplacement1 =
- new FixReplacement(filePath1, new Range(1, 0, 2, 0), "1st modification\n");
- FixReplacement fixReplacement2 =
- new FixReplacement(filePath1, new Range(3, 0, 4, 0), "2nd modification\n");
- FixReplacement fixReplacement3 =
- new FixReplacement(filePath1, new Range(4, 0, 5, 0), "3rd modification\n");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\nFourth line\nFifth line\n");
-
- List<TreeModification> treeModifications =
- toTreeModifications(fixReplacement2, fixReplacement1, fixReplacement3);
- assertThatList(treeModifications)
- .onlyElement()
- .asChangeFileContentModification()
- .newContent()
- .isEqualTo(
- "1st modification\nSecond line\n2nd modification\n3rd modification\nFifth line\n");
- }
-
- @Test
- public void replacementsMustNotReferToNotExistingLine() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(5, 0, 5, 0), "A new line\n");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
- }
-
- @Test
- public void replacementsMustNotReferToZeroLine() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(0, 0, 0, 0), "A new line\n");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
- }
-
- @Test
- public void replacementsMustNotReferToNotExistingOffsetOfIntermediateLine() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(1, 0, 1, 11), "modified");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
- }
-
- @Test
- public void replacementsMustNotReferToNotExistingOffsetOfLastLine() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(3, 0, 3, 11), "modified");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
- }
-
- @Test
- public void replacementsMustNotReferToNegativeOffset() throws Exception {
- FixReplacement fixReplacement =
- new FixReplacement(filePath1, new Range(1, -1, 1, 5), "modified");
- mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
-
- assertThrows(ResourceConflictException.class, () -> toTreeModifications(fixReplacement));
- }
-
private void mockFileContent(String filePath, String fileContent) throws Exception {
when(fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath))
.thenReturn(BinaryResult.create(fileContent));
}
- private List<TreeModification> toTreeModifications(FixReplacement... fixReplacements)
+ private CommitModification toCommitModification(FixReplacement... fixReplacements)
throws Exception {
- return fixReplacementInterpreter.toTreeModifications(
+ return fixReplacementInterpreter.toCommitModification(
repository, projectState, patchSetCommitId, ImmutableList.copyOf(fixReplacements));
}
private static List<TreeModification> getSortedCopy(List<TreeModification> treeModifications) {
List<TreeModification> sortedTreeModifications = new ArrayList<>(treeModifications);
- sortedTreeModifications.sort(Comparator.comparing(TreeModification::getFilePath));
+ // The sorting is only necessary to get a deterministic order. The exact order doesn't matter.
+ sortedTreeModifications.sort(
+ comparing(
+ treeModification -> treeModification.getFilePaths().stream().findFirst().orElse("")));
return sortedTreeModifications;
}
}
diff --git a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
index 861af3e..fa5c47f 100644
--- a/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
+++ b/javatests/com/google/gerrit/server/fixes/fixCalculator/FixCalculatorVariousTest.java
@@ -34,7 +34,7 @@
"First line\nSecond line\nThird line\nFourth line\nFifth line\n";
private static final Text multilineContent = new Text(multilineContentString.getBytes(UTF_8));
- public static FixResult calculateFixSingleReplacement(
+ static FixResult calculateFixSingleReplacement(
String content, int startLine, int startChar, int endLine, int endChar, String replacement)
throws ResourceConflictException {
FixReplacement fixReplacement =
@@ -52,13 +52,66 @@
}
@Test
- public void insertAtTheEndOfSingleLineContentHasEOLMarkInvalidPosition() throws Exception {
+ public void lineNumberMustExist() {
+ assertThrows(
+ ResourceConflictException.class,
+ () -> calculateFixSingleReplacement("First line\nSecond line", 4, 0, 4, 0, "Abc"));
+ }
+
+ @Test
+ public void startOffsetMustNotBeNegative() {
+ assertThrows(
+ ResourceConflictException.class,
+ () -> calculateFixSingleReplacement("First line\nSecond line", 0, -1, 0, 0, "Abc"));
+ }
+
+ @Test
+ public void endOffsetMustNotBeNegative() {
+ assertThrows(
+ ResourceConflictException.class,
+ () -> calculateFixSingleReplacement("First line\nSecond line", 0, 0, 0, -1, "Abc"));
+ }
+
+ @Test
+ public void insertAtTheEndOfSingleLineContentHasEOLMarkInvalidPosition() {
assertThrows(
ResourceConflictException.class,
() -> calculateFixSingleReplacement("First line\n", 1, 11, 1, 11, "Abc"));
}
@Test
+ public void startAfterEndOfLineMarkOfIntermediateLineThrowsAnException() {
+ assertThrows(
+ ResourceConflictException.class,
+ () ->
+ calculateFixSingleReplacement(
+ "First line\nSecond line\nThird line\n", 1, 11, 2, 6, "Abc"));
+ }
+
+ @Test
+ public void startAfterEndOfLineMarkOfLastLineThrowsAnException() {
+ assertThrows(
+ ResourceConflictException.class,
+ () -> calculateFixSingleReplacement("First line\n", 1, 11, 2, 0, "Abc"));
+ }
+
+ @Test
+ public void endAfterEndOfLineMarkOfIntermediateLineThrowsAnException() {
+ assertThrows(
+ ResourceConflictException.class,
+ () ->
+ calculateFixSingleReplacement(
+ "First line\nSecond line\nThird line\n", 2, 0, 2, 12, "Abc"));
+ }
+
+ @Test
+ public void endAfterEndOfLineMarkOfLastLineThrowsAnException() {
+ assertThrows(
+ ResourceConflictException.class,
+ () -> calculateFixSingleReplacement("First line\nSecond line\n", 2, 0, 2, 12, "Abc"));
+ }
+
+ @Test
public void severalChangesInTheSameLineNonSorted() throws Exception {
FixReplacement replace = new FixReplacement("path", new Range(2, 1, 2, 3), "ABC");
FixReplacement insert = new FixReplacement("path", new Range(2, 5, 2, 5), "DEFG");
@@ -146,7 +199,18 @@
assertThat(result)
.text()
.isEqualTo(
- "FiAB\nC\nDEFG\nQ\nrd line\nFourth lneQWERTY\nSixth line\nSevXY line\nEighth KLMNO\nASDFline\nNinth line\nTenine\n");
+ "FiAB\n"
+ + "C\n"
+ + "DEFG\n"
+ + "Q\n"
+ + "rd line\n"
+ + "Fourth lneQWERTY\n"
+ + "Sixth line\n"
+ + "SevXY line\n"
+ + "Eighth KLMNO\n"
+ + "ASDFline\n"
+ + "Ninth line\n"
+ + "Tenine\n");
assertThat(result).edits().hasSize(3);
assertThat(result).edits().element(0).isReplace(0, 5, 0, 6);
assertThat(result)
@@ -165,4 +229,23 @@
assertThat(result).edits().element(2).isReplace(9, 1, 11, 1);
assertThat(result).edits().element(2).internalEdits().onlyElement().isDelete(3, 4, 3);
}
+
+ @Test
+ public void changesMayTouch() throws Exception {
+ FixReplacement firstReplace = new FixReplacement("path", new Range(1, 6, 2, 7), "modified ");
+ FixReplacement consecutiveReplace =
+ new FixReplacement("path", new Range(2, 7, 3, 5), "content");
+ FixResult result =
+ FixCalculator.calculateFix(
+ multilineContent, ImmutableList.of(firstReplace, consecutiveReplace));
+ assertThat(result).text().isEqualTo("First modified content line\nFourth line\nFifth line\n");
+ assertThat(result).edits().hasSize(1);
+ Edit edit = result.edits.get(0);
+ assertThat(edit).isReplace(0, 3, 0, 1);
+ // The current code creates two inline edits even though only one would be necessary. It
+ // shouldn't make a visual difference to the user and hence we can ignore this.
+ assertThat(edit).internalEdits().hasSize(2);
+ assertThat(edit).internalEdits().element(0).isReplace(6, 12, 6, 9);
+ assertThat(edit).internalEdits().element(1).isReplace(18, 10, 15, 7);
+ }
}
diff --git a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
index 698acd8..7eb6bc7 100644
--- a/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
+++ b/javatests/com/google/gerrit/server/git/receive/ReceivePackRefCacheTest.java
@@ -24,6 +24,7 @@
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.RefNames;
import java.util.Map;
import org.eclipse.jgit.lib.ObjectId;
@@ -60,17 +61,18 @@
}
@Test
- public void noCache_tipsFromObjectIdDelegatesToRefDbAndFiltersByPrefix() throws Exception {
+ public void noCache_tipsFromObjectIdDelegatesToRefDb() throws Exception {
Ref refBla = newRef("refs/bla", "badc0feebadc0feebadc0feebadc0feebadc0fee");
- Ref refheads = newRef(RefNames.REFS_HEADS, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
+ String patchSetRef = RefNames.REFS_CHANGES + "01/1/1";
+ Ref patchSet = newRef(patchSetRef, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
RefDatabase mockRefDb = mock(RefDatabase.class);
ReceivePackRefCache cache = ReceivePackRefCache.noCache(mockRefDb);
when(mockRefDb.getTipsWithSha1(ObjectId.zeroId()))
- .thenReturn(ImmutableSet.of(refBla, refheads));
+ .thenReturn(ImmutableSet.of(refBla, patchSet));
- assertThat(cache.tipsFromObjectId(ObjectId.zeroId(), RefNames.REFS_HEADS))
- .containsExactly(refheads);
+ assertThat(cache.patchSetIdsFromObjectId(ObjectId.zeroId()))
+ .containsExactly(PatchSet.Id.fromRef(patchSetRef));
verify(mockRefDb).getTipsWithSha1(ObjectId.zeroId());
verifyNoMoreInteractions(mockRefDb);
}
@@ -107,25 +109,14 @@
}
@Test
- public void advertisedRefs_tipsFromObjectIdWithNoPrefix() throws Exception {
+ public void advertisedRefs_patchSetIdsFromObjectId() throws Exception {
Map<String, Ref> refs = setupTwoChanges();
ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
assertThat(
- cache.tipsFromObjectId(
- ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), null))
- .containsExactly(refs.get("refs/changes/01/1/1"));
- }
-
- @Test
- public void advertisedRefs_tipsFromObjectIdWithPrefix() throws Exception {
- Map<String, Ref> refs = setupTwoChanges();
- ReceivePackRefCache cache = ReceivePackRefCache.withAdvertisedRefs(() -> refs);
-
- assertThat(
- cache.tipsFromObjectId(
- ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee"), "/refs/some"))
- .isEmpty();
+ cache.patchSetIdsFromObjectId(
+ ObjectId.fromString("badc0feebadc0feebadc0feebadc0feebadc0fee")))
+ .containsExactly(PatchSet.Id.fromRef("refs/changes/01/1/1"));
}
private static Ref newRef(String name, String sha1) {
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 47877b6..40a6978 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -22,10 +22,10 @@
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Table;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.util.time.TimeUtil;
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index a936d28..521af2f 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -44,6 +44,7 @@
null,
null,
null,
+ null,
indexes,
null,
null,
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 733d784..8d019f3 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -24,8 +24,8 @@
import com.google.inject.Guice;
import com.google.inject.Inject;
import com.google.inject.Injector;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.eclipse.jgit.lib.Config;
@@ -76,7 +76,7 @@
// Create a performance log record.
TraceContext.newTimer("test").close();
- SortedMap<String, SortedSet<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
+ Map<String, ? extends Set<Object>> tagMap = LoggingContext.getInstance().getTags().asMap();
assertThat(tagMap.keySet()).containsExactly("foo");
assertThat(tagMap.get("foo")).containsExactly("bar");
assertForceLogging(true);
@@ -90,7 +90,7 @@
() -> {
// Verify that the tags and force logging flag have been propagated to the new
// thread.
- SortedMap<String, SortedSet<Object>> threadTagMap =
+ Map<String, ? extends Set<Object>> threadTagMap =
LoggingContext.getInstance().getTags().asMap();
expect.that(threadTagMap.keySet()).containsExactly("foo");
expect.that(threadTagMap.get("foo")).containsExactly("bar");
diff --git a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
index f6f3b46..200c49d 100644
--- a/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
+++ b/javatests/com/google/gerrit/server/logging/MutableTagsTest.java
@@ -21,8 +21,7 @@
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSetMultimap;
import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
import org.junit.Before;
import org.junit.Test;
@@ -157,7 +156,7 @@
}
private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
- SortedMap<String, SortedSet<Object>> actualTagMap = tags.getTags().asMap();
+ Map<String, ? extends Set<Object>> actualTagMap = tags.getTags().asMap();
assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
assertThat(actualTagMap.get(expectedEntry.getKey()))
diff --git a/javatests/com/google/gerrit/server/logging/TraceContextTest.java b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
index 13f2035..6a3632d 100644
--- a/javatests/com/google/gerrit/server/logging/TraceContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/TraceContextTest.java
@@ -21,8 +21,7 @@
import com.google.common.collect.ImmutableSet;
import com.google.gerrit.server.logging.TraceContext.TraceIdConsumer;
import java.util.Map;
-import java.util.SortedMap;
-import java.util.SortedSet;
+import java.util.Set;
import org.junit.After;
import org.junit.Test;
@@ -254,7 +253,7 @@
}
private void assertTags(ImmutableMap<String, ImmutableSet<String>> expectedTagMap) {
- SortedMap<String, SortedSet<Object>> actualTagMap =
+ Map<String, ? extends Set<Object>> actualTagMap =
LoggingContext.getInstance().getTags().asMap();
assertThat(actualTagMap.keySet()).containsExactlyElementsIn(expectedTagMap.keySet());
for (Map.Entry<String, ImmutableSet<String>> expectedEntry : expectedTagMap.entrySet()) {
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index bf9b187..83c6542 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -18,7 +18,6 @@
import static java.util.concurrent.TimeUnit.SECONDS;
import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Comment;
@@ -26,6 +25,7 @@
import com.google.gerrit.entities.HumanComment;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.config.FactoryModule;
import com.google.gerrit.metrics.DisabledMetricMaker;
import com.google.gerrit.metrics.MetricMaker;
@@ -38,6 +38,7 @@
import com.google.gerrit.server.account.FakeRealm;
import com.google.gerrit.server.account.GroupBackend;
import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.AllUsersNameProvider;
import com.google.gerrit.server.config.AnonymousCowardName;
@@ -165,6 +166,7 @@
bind(ExecutorService.class)
.annotatedWith(FanOutExecutor.class)
.toInstance(assertableFanOutExecutor);
+ bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
}
});
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index f1b7198..321e4da 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -25,8 +25,6 @@
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.SubmitRecord;
-import com.google.gerrit.common.data.SubmitRequirement;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
@@ -37,6 +35,8 @@
import com.google.gerrit.entities.LabelId;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -634,6 +634,39 @@
}
@Test
+ public void serializeAllAttentionSetUpdates() throws Exception {
+ assertRoundTrip(
+ newBuilder()
+ .allAttentionSetUpdates(
+ ImmutableList.of(
+ AttentionSetUpdate.createFromRead(
+ Instant.EPOCH.plusSeconds(23),
+ Account.id(1000),
+ AttentionSetUpdate.Operation.ADD,
+ "reason 1"),
+ AttentionSetUpdate.createFromRead(
+ Instant.EPOCH.plusSeconds(42),
+ Account.id(2000),
+ AttentionSetUpdate.Operation.REMOVE,
+ "reason 2")))
+ .build(),
+ newProtoBuilder()
+ .addAllAttentionSetUpdate(
+ AttentionSetUpdateProto.newBuilder()
+ .setTimestampMillis(23_000) // epoch millis
+ .setAccount(1000)
+ .setOperation("ADD")
+ .setReason("reason 1"))
+ .addAllAttentionSetUpdate(
+ AttentionSetUpdateProto.newBuilder()
+ .setTimestampMillis(42_000) // epoch millis
+ .setAccount(2000)
+ .setOperation("REMOVE")
+ .setReason("reason 2"))
+ .build());
+ }
+
+ @Test
public void serializeAssigneeUpdates() throws Exception {
assertRoundTrip(
newBuilder()
@@ -793,6 +826,9 @@
"attentionSet",
new TypeLiteral<ImmutableSet<AttentionSetUpdate>>() {}.getType())
.put(
+ "allAttentionSetUpdates",
+ new TypeLiteral<ImmutableList<AttentionSetUpdate>>() {}.getType())
+ .put(
"assigneeUpdates",
new TypeLiteral<ImmutableList<AssigneeStatusUpdate>>() {}.getType())
.put("submitRecords", new TypeLiteral<ImmutableList<SubmitRecord>>() {}.getType())
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index b0b1c1e..cc0b109 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -36,7 +36,6 @@
import com.google.common.collect.Iterables;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
@@ -49,6 +48,7 @@
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.server.AssigneeStatusUpdate;
import com.google.gerrit.server.CurrentUser;
@@ -699,6 +699,13 @@
}
@Test
+ public void defaultAttentionSetUpdatesIsEmpty() throws Exception {
+ Change c = newChange();
+ ChangeNotes notes = newNotes(c);
+ assertThat(notes.getAttentionSetUpdates()).isEmpty();
+ }
+
+ @Test
public void addAttentionStatus() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
@@ -712,6 +719,19 @@
}
@Test
+ public void addAllAttentionUpdates() throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, changeOwner);
+ AttentionSetUpdate attentionSetUpdate =
+ AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+ update.addToPlannedAttentionSetUpdates(ImmutableSet.of(attentionSetUpdate));
+ update.commit();
+
+ ChangeNotes notes = newNotes(c);
+ assertThat(notes.getAttentionSetUpdates()).containsExactly(addTimestamp(attentionSetUpdate, c));
+ }
+
+ @Test
public void filterLatestAttentionStatus() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
@@ -730,6 +750,28 @@
}
@Test
+ public void DoesNotFilterLatestAttentionSetUpdates() throws Exception {
+ Change c = newChange();
+ ChangeUpdate update = newUpdate(c, changeOwner);
+ AttentionSetUpdate firstAttentionSetUpdate =
+ AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
+ update.addToPlannedAttentionSetUpdates(ImmutableSet.of(firstAttentionSetUpdate));
+ update.commit();
+ update = newUpdate(c, changeOwner);
+ firstAttentionSetUpdate = addTimestamp(firstAttentionSetUpdate, c);
+
+ AttentionSetUpdate secondAttentionSetUpdate =
+ AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.REMOVE, "test");
+ update.addToPlannedAttentionSetUpdates(ImmutableSet.of(secondAttentionSetUpdate));
+ update.commit();
+ secondAttentionSetUpdate = addTimestamp(secondAttentionSetUpdate, c);
+
+ ChangeNotes notes = newNotes(c);
+ assertThat(notes.getAttentionSetUpdates())
+ .containsExactly(secondAttentionSetUpdate, firstAttentionSetUpdate);
+ }
+
+ @Test
public void addAttentionStatus_rejectTimestamp() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
@@ -767,6 +809,8 @@
public void addAttentionStatusForMultipleUsers() throws Exception {
Change c = newChange();
ChangeUpdate update = newUpdate(c, changeOwner);
+ // put the user as cc to ensure that the user took part in this change.
+ update.putReviewer(otherUser.getAccount().id(), CC);
AttentionSetUpdate attentionSetUpdate0 =
AttentionSetUpdate.createForWrite(changeOwner.getAccountId(), Operation.ADD, "test");
AttentionSetUpdate attentionSetUpdate1 =
diff --git a/javatests/com/google/gerrit/server/patch/MagicFileTest.java b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
new file mode 100644
index 0000000..93928f0
--- /dev/null
+++ b/javatests/com/google/gerrit/server/patch/MagicFileTest.java
@@ -0,0 +1,395 @@
+// 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.patch;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import java.util.Date;
+import java.util.TimeZone;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Test;
+
+public class MagicFileTest {
+
+ private final GitRepositoryManager repositoryManager = new InMemoryRepositoryManager();
+
+ @Test
+ public void magicFileContentIsBuiltCorrectly() {
+ MagicFile magicFile =
+ MagicFile.builder()
+ .generatedContent("Generated 1\n")
+ .modifiableContent("Modifiable 1\n")
+ .build();
+
+ assertThat(magicFile.getFileContent()).isEqualTo("Generated 1\nModifiable 1\n");
+ }
+
+ @Test
+ public void generatedContentMayBeEmpty() {
+ MagicFile magicFile = MagicFile.builder().modifiableContent("Modifiable 1\n").build();
+
+ assertThat(magicFile.getFileContent()).isEqualTo("Modifiable 1\n");
+ }
+
+ @Test
+ public void modifiableContentMayBeEmpty() {
+ MagicFile magicFile = MagicFile.builder().generatedContent("Generated 1\n").build();
+
+ assertThat(magicFile.getFileContent()).isEqualTo("Generated 1\n");
+ }
+
+ @Test
+ public void generatedContentAlwaysHasNewlineAtEnd() {
+ MagicFile magicFile = MagicFile.builder().generatedContent("Generated 1").build();
+
+ assertThat(magicFile.generatedContent()).isEqualTo("Generated 1\n");
+ }
+
+ @Test
+ public void modifiableContentAlwaysHasNewlineAtEnd() {
+ MagicFile magicFile = MagicFile.builder().modifiableContent("Modifiable 1").build();
+
+ assertThat(magicFile.modifiableContent()).isEqualTo("Modifiable 1\n");
+ }
+
+ @Test
+ public void startOfModifiableContentIsIndicatedCorrectlyWhenGeneratedContentIsPresent() {
+ MagicFile magicFile =
+ MagicFile.builder()
+ .generatedContent("Line 1\nLine2\n")
+ .modifiableContent("Line 3\n")
+ .build();
+
+ // Generated content. -> Modifiable content starts in line 3.
+ assertThat(magicFile.getStartLineOfModifiableContent()).isEqualTo(3);
+ }
+
+ @Test
+ public void startOfModifiableContentIsIndicatedCorrectlyWhenGeneratedContentIsEmpty() {
+ MagicFile magicFile = MagicFile.builder().modifiableContent("Line 1\n").build();
+
+ assertThat(magicFile.getStartLineOfModifiableContent()).isEqualTo(1);
+ }
+
+ @Test
+ public void commitMessageFileOfRootCommitContainsCorrectContent() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+
+ Instant authorTime =
+ LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
+ PersonIdent author =
+ new PersonIdent(
+ "Alfred",
+ "alfred@example.com",
+ Date.from(authorTime),
+ TimeZone.getTimeZone(ZoneOffset.UTC));
+
+ Instant committerTime =
+ LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
+ PersonIdent committer =
+ new PersonIdent(
+ "Luise",
+ "luise@example.com",
+ Date.from(committerTime),
+ TimeZone.getTimeZone(ZoneOffset.UTC));
+
+ ObjectId commit =
+ testRepo
+ .commit()
+ .message("Subject line\n\nFurther explanations.\n")
+ .author(author)
+ .committer(committer)
+ .noParents()
+ .create();
+
+ MagicFile commitMessageFile = MagicFile.forCommitMessage(objectReader, commit);
+
+ // The content of the commit message file must not change over time as existing comments
+ // would otherwise refer to different content than when they were originally left.
+ // -> Keep this format stable over time.
+ assertThat(commitMessageFile.getFileContent())
+ .isEqualTo(
+ "Author: Alfred <alfred@example.com>\n"
+ + "AuthorDate: 2020-04-23 19:30:27 +0000\n"
+ + "Commit: Luise <luise@example.com>\n"
+ + "CommitDate: 2021-01-06 05:12:55 +0000\n"
+ + "\n"
+ + "Subject line\n"
+ + "\n"
+ + "Further explanations.\n");
+ }
+ }
+
+ @Test
+ public void commitMessageFileOfNonMergeCommitContainsCorrectContent() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+
+ Instant authorTime =
+ LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
+ PersonIdent author =
+ new PersonIdent(
+ "Alfred",
+ "alfred@example.com",
+ Date.from(authorTime),
+ TimeZone.getTimeZone(ZoneOffset.UTC));
+
+ Instant committerTime =
+ LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
+ PersonIdent committer =
+ new PersonIdent(
+ "Luise",
+ "luise@example.com",
+ Date.from(committerTime),
+ TimeZone.getTimeZone(ZoneOffset.UTC));
+
+ RevCommit parent =
+ testRepo.commit().message("Parent subject\n\nParent further details.").create();
+ ObjectId commit =
+ testRepo
+ .commit()
+ .message("Subject line\n\nFurther explanations.\n")
+ .author(author)
+ .committer(committer)
+ .parent(parent)
+ .create();
+
+ MagicFile commitMessageFile = MagicFile.forCommitMessage(objectReader, commit);
+
+ // The content of the commit message file must not change over time as existing comments
+ // would otherwise refer to different content than when they were originally left.
+ // -> Keep this format stable over time.
+ assertThat(commitMessageFile.getFileContent())
+ .isEqualTo(
+ String.format(
+ "Parent: %s (Parent subject)\n"
+ + "Author: Alfred <alfred@example.com>\n"
+ + "AuthorDate: 2020-04-23 19:30:27 +0000\n"
+ + "Commit: Luise <luise@example.com>\n"
+ + "CommitDate: 2021-01-06 05:12:55 +0000\n"
+ + "\n"
+ + "Subject line\n"
+ + "\n"
+ + "Further explanations.\n",
+ parent.name().substring(0, 8)));
+ }
+ }
+
+ @Test
+ public void commitMessageFileOfMergeCommitContainsCorrectContent() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("repo1"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+
+ Instant authorTime =
+ LocalDateTime.of(2020, Month.APRIL, 23, 19, 30, 27).atZone(ZoneOffset.UTC).toInstant();
+ PersonIdent author =
+ new PersonIdent(
+ "Alfred",
+ "alfred@example.com",
+ Date.from(authorTime),
+ TimeZone.getTimeZone(ZoneOffset.UTC));
+
+ Instant committerTime =
+ LocalDateTime.of(2021, Month.JANUARY, 6, 5, 12, 55).atZone(ZoneOffset.UTC).toInstant();
+ PersonIdent committer =
+ new PersonIdent(
+ "Luise",
+ "luise@example.com",
+ Date.from(committerTime),
+ TimeZone.getTimeZone(ZoneOffset.UTC));
+
+ RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
+ RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
+ ObjectId commit =
+ testRepo
+ .commit()
+ .message("Subject line\n\nFurther explanations.\n")
+ .author(author)
+ .committer(committer)
+ .parent(parent1)
+ .parent(parent2)
+ .create();
+
+ MagicFile commitMessageFile = MagicFile.forCommitMessage(objectReader, commit);
+
+ // The content of the commit message file must not change over time as existing comments
+ // would otherwise refer to different content than when they were originally left.
+ // -> Keep this format stable over time.
+ String expectedContent =
+ String.format(
+ "Merge Of: %s (Parent 1)\n"
+ + " %s (Parent 2)\n"
+ + "Author: Alfred <alfred@example.com>\n"
+ + "AuthorDate: 2020-04-23 19:30:27 +0000\n"
+ + "Commit: Luise <luise@example.com>\n"
+ + "CommitDate: 2021-01-06 05:12:55 +0000\n"
+ + "\n"
+ + "Subject line\n"
+ + "\n"
+ + "Further explanations.\n",
+ parent1.name().substring(0, 8), parent2.name().substring(0, 8));
+ assertThat(commitMessageFile.getFileContent()).isEqualTo(expectedContent);
+ }
+ }
+
+ @Test
+ public void commitMessageFileEndsWithEmptyLineIfCommitMessageIsEmpty() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+ RevCommit commit = testRepo.commit().message("").create();
+
+ MagicFile commitMessageFile = MagicFile.forCommitMessage(objectReader, commit);
+ assertThat(commitMessageFile.getFileContent()).endsWith("\n\n");
+ }
+ }
+
+ @Test
+ public void commitMessageFileContainsFullCommitMessageAsModifiablePart() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+ RevCommit commit =
+ testRepo.commit().message("Subject line\n\nFurther explanations.\n").create();
+
+ MagicFile commitMessageFile = MagicFile.forCommitMessage(objectReader, commit);
+ assertThat(commitMessageFile.modifiableContent())
+ .isEqualTo("Subject line\n\nFurther explanations.\n");
+ }
+ }
+
+ @Test
+ public void mergeListFileContainsCorrectContentForDiffAgainstFirstParent() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+ RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
+ RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
+ ObjectId commit = testRepo.commit().parent(parent1).parent(parent2).create();
+
+ MagicFile mergeListFile =
+ MagicFile.forMergeList(ComparisonType.againstParent(1), objectReader, commit);
+
+ // The content of the merge list file must not change over time as existing comments
+ // would otherwise refer to different content than when they were originally left.
+ // -> Keep this format stable over time.
+ String expectedContent =
+ String.format("Merge List:\n\n* %s Parent 2\n", parent2.name().substring(0, 8));
+ assertThat(mergeListFile.getFileContent()).isEqualTo(expectedContent);
+ }
+ }
+
+ @Test
+ public void mergeListFileContainsCorrectContentForDiffAgainstSecondParent() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+ RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
+ RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
+ ObjectId commit = testRepo.commit().parent(parent1).parent(parent2).create();
+
+ MagicFile mergeListFile =
+ MagicFile.forMergeList(ComparisonType.againstParent(2), objectReader, commit);
+
+ // The content of the merge list file must not change over time as existing comments
+ // would otherwise refer to different content than when they were originally left.
+ // -> Keep this format stable over time.
+ String expectedContent =
+ String.format("Merge List:\n\n* %s Parent 1\n", parent1.name().substring(0, 8));
+ assertThat(mergeListFile.getFileContent()).isEqualTo(expectedContent);
+ }
+ }
+
+ @Test
+ public void mergeListFileContainsCorrectContentForDiffAgainstAutoMerge() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+ RevCommit parent1 = testRepo.commit().message("Parent 1\n\nExplanation 1.").create();
+ RevCommit parent2 = testRepo.commit().message("Parent 2\n\nExplanation 2.").create();
+ ObjectId commit = testRepo.commit().parent(parent1).parent(parent2).create();
+
+ MagicFile mergeListFile =
+ MagicFile.forMergeList(ComparisonType.againstAutoMerge(), objectReader, commit);
+
+ // When auto-merge is chosen, we fall back to the diff against the first parent.
+ String expectedContent =
+ String.format("Merge List:\n\n* %s Parent 2\n", parent2.name().substring(0, 8));
+ assertThat(mergeListFile.getFileContent()).isEqualTo(expectedContent);
+ }
+ }
+
+ @Test
+ public void mergeListFileIsEmptyForRootCommit() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+ ObjectId commit = testRepo.commit().noParents().create();
+
+ MagicFile mergeListFile =
+ MagicFile.forMergeList(ComparisonType.againstParent(1), objectReader, commit);
+
+ assertThat(mergeListFile.getFileContent()).isEmpty();
+ }
+ }
+
+ @Test
+ public void mergeListFileIsEmptyForNonMergeCommit() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+ RevCommit parent = testRepo.commit().message("Parent 1\n").create();
+ ObjectId commit = testRepo.commit().parent(parent).create();
+
+ MagicFile mergeListFile =
+ MagicFile.forMergeList(ComparisonType.againstParent(1), objectReader, commit);
+
+ assertThat(mergeListFile.getFileContent()).isEmpty();
+ }
+ }
+
+ @Test
+ public void mergeListFileDoesNotHaveAModifiablePart() throws Exception {
+ try (Repository repository = repositoryManager.createRepository(Project.nameKey("myRepo"));
+ TestRepository<Repository> testRepo = new TestRepository<>(repository);
+ ObjectReader objectReader = repository.newObjectReader()) {
+ RevCommit parent1 = testRepo.commit().message("Parent 1\n").create();
+ RevCommit parent2 = testRepo.commit().message("Parent 2\n").create();
+ ObjectId commit = testRepo.commit().parent(parent1).parent(parent2).create();
+
+ MagicFile mergeListFile =
+ MagicFile.forMergeList(ComparisonType.againstParent(1), objectReader, commit);
+
+ // Nothing in the merge list file represents something users may modify.
+ assertThat(mergeListFile.modifiableContent()).isEmpty();
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
index 305e81b..6c5eb7a 100644
--- a/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
+++ b/javatests/com/google/gerrit/server/permissions/DefaultPermissionsMappingTest.java
@@ -17,7 +17,7 @@
import static com.google.common.truth.Truth8.assertThat;
import static com.google.gerrit.server.permissions.DefaultPermissionMappings.refPermission;
-import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.entities.Permission;
import org.junit.Test;
public class DefaultPermissionsMappingTest {
diff --git a/javatests/com/google/gerrit/server/permissions/RefControlTest.java b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
index 9029301..81cb732 100644
--- a/javatests/com/google/gerrit/server/permissions/RefControlTest.java
+++ b/javatests/com/google/gerrit/server/permissions/RefControlTest.java
@@ -23,12 +23,12 @@
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.labelPermissionKey;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
-import static com.google.gerrit.common.data.Permission.EDIT_TOPIC_NAME;
-import static com.google.gerrit.common.data.Permission.LABEL;
-import static com.google.gerrit.common.data.Permission.OWNER;
-import static com.google.gerrit.common.data.Permission.PUSH;
-import static com.google.gerrit.common.data.Permission.READ;
-import static com.google.gerrit.common.data.Permission.SUBMIT;
+import static com.google.gerrit.entities.Permission.EDIT_TOPIC_NAME;
+import static com.google.gerrit.entities.Permission.LABEL;
+import static com.google.gerrit.entities.Permission.OWNER;
+import static com.google.gerrit.entities.Permission.PUSH;
+import static com.google.gerrit.entities.Permission.READ;
+import static com.google.gerrit.entities.Permission.SUBMIT;
import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
import static com.google.gerrit.server.group.SystemGroupBackend.CHANGE_OWNER;
@@ -39,9 +39,9 @@
import com.google.common.collect.Lists;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.PermissionRange;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.PermissionRange;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.InvalidNameException;
import com.google.gerrit.server.CurrentUser;
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index 5af8a1e..1035fe7 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -17,7 +17,7 @@
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.deny;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.permissionKey;
-import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.entities.Permission.READ;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static org.eclipse.jgit.lib.Constants.R_REFS;
import static org.junit.Assert.assertFalse;
@@ -26,13 +26,13 @@
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate;
-import com.google.gerrit.common.data.AccessSection;
import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.server.account.AccountManager;
import com.google.gerrit.server.account.AuthRequest;
diff --git a/javatests/com/google/gerrit/server/project/GroupListTest.java b/javatests/com/google/gerrit/server/project/GroupListTest.java
index f3295f8..853507d 100644
--- a/javatests/com/google/gerrit/server/project/GroupListTest.java
+++ b/javatests/com/google/gerrit/server/project/GroupListTest.java
@@ -26,6 +26,7 @@
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.GroupReference;
import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.git.ValidationError;
import java.io.IOException;
import java.util.Collection;
@@ -39,7 +40,7 @@
private static final String TEXT =
"# UUID \tGroup Name\n"
+ "#\n"
- + "d96b998f8a66ff433af50befb975d0e2bb6e0999\tNon-Interactive Users\n"
+ + "d96b998f8a66ff433af50befb975d0e2bb6e0999\tService Users\n"
+ "ebe31c01aec2c9ac3b3c03e87a47450829ff4310\tAdministrators\n";
private GroupList groupList;
@@ -57,7 +58,7 @@
GroupReference groupReference = groupList.byUUID(uuid);
assertEquals(uuid, groupReference.getUUID());
- assertEquals("Non-Interactive Users", groupReference.getName());
+ assertEquals(ServiceUserClassifier.SERVICE_USERS, groupReference.getName());
}
@Test
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index 606e147..a39821e 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -20,14 +20,15 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.entities.AccessSection;
import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.AccountsSection;
import com.google.gerrit.entities.BranchOrderSection;
+import com.google.gerrit.entities.ContributorAgreement;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.StoredCommentLinkInfo;
@@ -57,6 +58,8 @@
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
import org.eclipse.jgit.util.RawParseUtils;
import org.junit.Before;
import org.junit.Rule;
@@ -486,9 +489,12 @@
update(rev);
ProjectConfig cfg = read(rev);
- PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
- pluginCfg.setString("key1", "updatedValue1");
- pluginCfg.setStringList("key2", Arrays.asList("updatedValue2a", "updatedValue2b"));
+ cfg.updatePluginConfig(
+ "somePlugin",
+ pluginCfg -> {
+ pluginCfg.setString("key1", "updatedValue1");
+ pluginCfg.setStringList("key2", Arrays.asList("updatedValue2a", "updatedValue2b"));
+ });
rev = commit(cfg);
assertThat(text(rev, "project.config"))
.isEqualTo(
@@ -510,7 +516,7 @@
ProjectConfig cfg = read(rev);
PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
assertThat(pluginCfg.getNames()).hasSize(1);
- assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
+ assertThat(pluginCfg.getGroupReference("key1").get()).isEqualTo(developers);
}
@Test
@@ -539,11 +545,10 @@
update(rev);
ProjectConfig cfg = read(rev);
- PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
- assertThat(pluginCfg.getNames()).hasSize(1);
- assertThat(pluginCfg.getGroupReference("key1")).isEqualTo(developers);
-
- pluginCfg.setGroupReference("key1", staff);
+ assertThat(cfg.getPluginConfig("somePlugin").getNames()).hasSize(1);
+ assertThat(cfg.getPluginConfig("somePlugin").getGroupReference("key1").get())
+ .isEqualTo(developers);
+ cfg.updatePluginConfig("somePlugin", pluginCfg -> pluginCfg.setGroupReference("key1", staff));
rev = commit(cfg);
assertThat(text(rev, "project.config"))
.isEqualTo("[plugin \"somePlugin\"]\n\tkey1 = " + staff.toConfigValue() + "\n");
@@ -598,6 +603,27 @@
}
@Test
+ public void readCommentLinksNoHtmlOrLinkAndMissingEnabled() throws Exception {
+ RevCommit rev =
+ tr.commit()
+ .add(
+ "project.config",
+ "[commentlink \"bugzilla\"]\n \tlink = http://bugs.example.com/show_bug.cgi?id=$2"
+ + "\n \tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n")
+ .create();
+ ProjectConfig cfg = read(rev);
+ assertThat(cfg.getCommentLinkSections())
+ .containsExactly(
+ StoredCommentLinkInfo.builder("bugzilla")
+ .setMatch("(bug\\s+#?)(\\d+)")
+ .setLink("http://bugs.example.com/show_bug.cgi?id=$2")
+ .build());
+ StoredCommentLinkInfo stored = Iterables.getOnlyElement(cfg.getCommentLinkSections());
+ assertThat(StoredCommentLinkInfo.fromInfo(stored.toInfo(), stored.getEnabled()))
+ .isEqualTo(stored);
+ }
+
+ @Test
public void readCommentLinkInvalidPattern() throws Exception {
RevCommit rev =
tr.commit()
@@ -789,14 +815,40 @@
update(rev);
ProjectConfig cfg = read(rev);
- PluginConfig pluginCfg = cfg.getPluginConfig("somePlugin");
- pluginCfg.unset("key");
+ cfg.updatePluginConfig("somePlugin", pluginCfg -> pluginCfg.unset("key"));
rev = commit(cfg);
assertThat(text(rev, "project.config"))
.isEqualTo(
"[commentlink \"bugzilla\"]\n\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
}
+ @Test
+ public void allProjectsProjectConfigInSitePaths_hashForKeyChangesWithnFileChanges()
+ throws Exception {
+ Path tmp = Files.createTempFile("gerrit_test_", "_site");
+ Files.deleteIfExists(tmp);
+ SitePaths sitePaths = new SitePaths(tmp);
+ byte[] hashedContents =
+ ProjectCacheImpl.allProjectsFileProjectConfigHash(ALL_PROJECTS, sitePaths);
+ assertThat(hashedContents).isEqualTo(new byte[16]); // Empty/absent config
+ FileBasedConfig fileBasedConfig =
+ new FileBasedConfig(
+ sitePaths
+ .etc_dir
+ .resolve(ALL_PROJECTS.get())
+ .resolve(ProjectConfig.PROJECT_CONFIG)
+ .toFile(),
+ FS.DETECTED);
+ fileBasedConfig.setString("plugin", "my-plugin", "key", "value");
+ fileBasedConfig.save();
+ hashedContents = ProjectCacheImpl.allProjectsFileProjectConfigHash(ALL_PROJECTS, sitePaths);
+ assertThat(hashedContents)
+ .isEqualTo(
+ new byte[] {
+ -53, -97, -1, 52, -119, 104, -13, -41, -7, 82, -90, 126, -32, -13, -91, 49
+ });
+ }
+
private Path writeDefaultAllProjectsConfig(String... lines) throws IOException {
Path dir = sitePaths.etc_dir.resolve(ALL_PROJECTS.get());
Files.createDirectories(dir);
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index a013145..1de548f 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -44,16 +44,16 @@
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.Patch;
import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.PermissionRule;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.GerritApi;
@@ -81,6 +81,7 @@
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
import com.google.gerrit.index.FieldDef;
import com.google.gerrit.index.IndexConfig;
import com.google.gerrit.index.Schema;
@@ -196,22 +197,6 @@
private String systemTimeZone;
- // These queries must be kept in sync with PolyGerrit:
- // polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
-
- protected static final String DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY = "has:draft";
- protected static final String DASHBOARD_ASSIGNED_QUERY =
- "assignee:${user} (-is:wip OR " + "owner:self OR assignee:self) is:open -is:ignored";
- protected static final String DASHBOARD_WORK_IN_PROGRESS_QUERY = "is:open owner:${user} is:wip";
- protected static final String DASHBOARD_OUTGOING_QUERY =
- "is:open owner:${user} -is:wip -is:ignored";
- protected static final String DASHBOARD_INCOMING_QUERY =
- "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user})";
- protected static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
- "is:closed -is:ignored (-is:wip OR owner:self) "
- + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
- + "OR cc:${user})";
-
protected abstract Injector createInjector();
@Before
@@ -2752,6 +2737,18 @@
return assertQueryByIds(query.replaceAll("\\$\\{user}", viewedUser), ids);
}
+ protected List<ChangeInfo> assertDashboardQueryWithStart(
+ String viewedUser, String query, int start, DashboardChangeState... expected)
+ throws Exception {
+ Change.Id[] ids = new Change.Id[expected.length];
+ for (int i = 0; i < expected.length; i++) {
+ ids[i] = expected[i].id;
+ }
+ QueryRequest queryRequest = newQuery(query.replaceAll("\\$\\{user}", viewedUser));
+ queryRequest.withStart(start);
+ return assertQueryByIds(queryRequest, ids);
+ }
+
@Test
public void dashboardHasUnpublishedDrafts() throws Exception {
TestRepository<Repo> repo = createProject("repo");
@@ -2766,7 +2763,8 @@
.draftAndDeleteCommentBy(user.getAccountId())
.create(repo);
- assertDashboardQuery("self", DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY, hasUnpublishedDraft);
+ assertDashboardQuery(
+ "self", IndexPreloadingUtil.DASHBOARD_HAS_UNPUBLISHED_DRAFTS_QUERY, hasUnpublishedDraft);
}
@Test
@@ -2790,11 +2788,13 @@
.assignTo(user.getAccountId())
.mergeBy(user.getAccountId());
- assertDashboardQuery("self", DASHBOARD_ASSIGNED_QUERY, selfOpenWip, otherOpenWip);
+ assertDashboardQuery(
+ "self", IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, selfOpenWip, otherOpenWip);
// Viewing another user's dashboard.
requestContext.setContext(newRequestContext(otherAccountId));
- assertDashboardQuery(user.getUserName().get(), DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
+ assertDashboardQuery(
+ user.getUserName().get(), IndexPreloadingUtil.DASHBOARD_ASSIGNED_QUERY, otherOpenWip);
}
@Test
@@ -2808,7 +2808,8 @@
new DashboardChangeState(user.getAccountId()).mergeBy(user.getAccountId()).create(repo);
new DashboardChangeState(createAccount("other")).wip().create(repo);
- assertDashboardQuery("self", DASHBOARD_WORK_IN_PROGRESS_QUERY, ownedOpenWip);
+ assertDashboardQuery(
+ "self", IndexPreloadingUtil.DASHBOARD_WORK_IN_PROGRESS_QUERY, ownedOpenWip);
}
@Test
@@ -2826,11 +2827,17 @@
// Viewing one's own dashboard.
assertDashboardQuery(
- "self", DASHBOARD_OUTGOING_QUERY, ownedOpenReviewableIgnoredByOther, ownedOpenReviewable);
+ "self",
+ IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
+ ownedOpenReviewableIgnoredByOther,
+ ownedOpenReviewable);
// Viewing another user's dashboard.
requestContext.setContext(newRequestContext(otherAccountId));
- assertDashboardQuery(user.getUserName().get(), DASHBOARD_OUTGOING_QUERY, ownedOpenReviewable);
+ assertDashboardQuery(
+ user.getUserName().get(),
+ IndexPreloadingUtil.DASHBOARD_OUTGOING_QUERY,
+ ownedOpenReviewable);
}
@Test
@@ -2862,13 +2869,17 @@
.create(repo);
// Viewing one's own dashboard.
- assertDashboardQuery("self", DASHBOARD_INCOMING_QUERY, assignedReviewable, reviewingReviewable);
+ assertDashboardQuery(
+ "self",
+ IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
+ assignedReviewable,
+ reviewingReviewable);
// Viewing another user's dashboard.
requestContext.setContext(newRequestContext(otherAccountId));
assertDashboardQuery(
user.getUserName().get(),
- DASHBOARD_INCOMING_QUERY,
+ IndexPreloadingUtil.DASHBOARD_INCOMING_QUERY,
assignedReviewableIgnoredByAssignee,
assignedReviewable,
reviewingReviewableIgnoredByReviewer,
@@ -2980,7 +2991,7 @@
// Viewing one's own dashboard.
assertDashboardQuery(
"self",
- DASHBOARD_RECENTLY_CLOSED_QUERY,
+ IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
abandonedAssigned,
abandonedReviewing,
abandonedOwnedWipIgnoredByOther,
@@ -2990,14 +3001,16 @@
mergedAssigned,
mergedCced,
mergedReviewing,
- mergedOwnedIgnoredByOther,
- mergedOwned);
+ mergedOwnedIgnoredByOther);
+
+ assertDashboardQueryWithStart(
+ "self", IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY, 10, mergedOwned);
// Viewing another user's dashboard.
requestContext.setContext(newRequestContext(otherAccountId));
assertDashboardQuery(
user.getUserName().get(),
- DASHBOARD_RECENTLY_CLOSED_QUERY,
+ IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
abandonedAssignedWipIgnoredByUser,
abandonedAssignedWip,
abandonedAssignedIgnoredByUser,
@@ -3007,7 +3020,12 @@
abandonedOwned,
mergedAssignedIgnoredByUser,
mergedAssigned,
- mergedCced,
+ mergedCced);
+
+ assertDashboardQueryWithStart(
+ user.getUserName().get(),
+ IndexPreloadingUtil.DASHBOARD_RECENTLY_CLOSED_QUERY,
+ 10,
mergedReviewingIgnoredByUser,
mergedReviewing,
mergedOwned);
@@ -3037,6 +3055,14 @@
gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
Account.Id user2Id =
accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+
+ // Add the second user as cc to ensure that user took part of the change and can be added to the
+ // attention set.
+ AddReviewerInput addReviewerInput = new AddReviewerInput();
+ addReviewerInput.reviewer = user2Id.toString();
+ addReviewerInput.state = ReviewerState.CC;
+ gApi.changes().id(change.getChangeId()).addReviewer(addReviewerInput);
+
input = new AttentionSetInput(user2Id.toString(), "reason 2");
gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
@@ -3067,6 +3093,7 @@
assertQuery("-assignee:" + user.getUserName().get(), change2);
}
+ @GerritConfig(name = "accounts.visibility", value = "NONE")
@Test
public void userDestination() throws Exception {
TestRepository<Repo> repo1 = createProject("repo1");
@@ -3078,6 +3105,8 @@
.hasMessageThat()
.isEqualTo("Unknown named destination: foo");
+ Account.Id anotherUserId =
+ accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
String destination1 = "refs/heads/master\trepo1";
String destination2 = "refs/heads/master\trepo2";
String destination3 = "refs/heads/master\trepo1\nrefs/heads/master\trepo2";
@@ -3093,8 +3122,32 @@
allUsers.branch(refsUsers).commit().add("destinations/destination4", destination4).create();
allUsers.branch(refsUsers).commit().add("destinations/destination5", destination5).create();
+ String anotherRefsUsers = RefNames.refsUsers(anotherUserId);
+ allUsers
+ .branch(anotherRefsUsers)
+ .commit()
+ .add("destinations/destination6", destination1)
+ .create();
+ allUsers
+ .branch(anotherRefsUsers)
+ .commit()
+ .add("destinations/destination7", destination2)
+ .create();
+ allUsers
+ .branch(anotherRefsUsers)
+ .commit()
+ .add("destinations/destination8", destination3)
+ .create();
+ allUsers
+ .branch(anotherRefsUsers)
+ .commit()
+ .add("destinations/destination9", destination4)
+ .create();
+
Ref userRef = allUsers.getRepository().exactRef(refsUsers);
+ Ref anotherUserRef = allUsers.getRepository().exactRef(anotherRefsUsers);
assertThat(userRef).isNotNull();
+ assertThat(anotherUserRef).isNotNull();
}
assertQuery("destination:destination1", change1);
@@ -3102,38 +3155,87 @@
assertQuery("destination:destination3", change2, change1);
assertQuery("destination:destination4");
assertQuery("destination:destination5");
+ assertQuery("destination:destination6,user=" + anotherUserId, change1);
+ assertQuery("destination:name=destination6,user=" + anotherUserId, change1);
+ assertQuery("destination:user=" + anotherUserId + ",destination7", change2);
+ assertQuery("destination:user=" + anotherUserId + ",name=destination8", change2, change1);
+ assertQuery("destination:destination9,user=" + anotherUserId);
+
+ assertThatQueryException("destination:destination3,user=" + anotherUserId)
+ .hasMessageThat()
+ .isEqualTo("Unknown named destination: destination3");
+ assertThatQueryException("destination:destination3,user=test")
+ .hasMessageThat()
+ .isEqualTo("Account 'test' not found");
+
+ requestContext.setContext(newRequestContext(anotherUserId));
+ // account 1000000 is not visible to 'anotheruser' as they are not an admin
+ assertThatQueryException("destination:destination3,user=" + userId)
+ .hasMessageThat()
+ .isEqualTo("Account '1000000' not found");
}
+ @GerritConfig(name = "accounts.visibility", value = "NONE")
@Test
public void userQuery() throws Exception {
TestRepository<Repo> repo = createProject("repo");
Change change1 = insert(repo, newChange(repo));
Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
+ Account.Id anotherUserId =
+ accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
String queryListText =
"query1\tproject:repo\n"
+ "query2\tproject:repo status:open\n"
+ "query3\tproject:repo branch:stable\n"
+ "query4\tproject:repo branch:other";
+ String anotherQueryListText =
+ "query5\tproject:repo\n"
+ + "query6\tproject:repo status:merged\n"
+ + "query7\tproject:repo branch:stable\n"
+ + "query8\tproject:repo branch:other";
try (TestRepository<Repo> allUsers =
new TestRepository<>(repoManager.openRepository(allUsersName));
- MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName)) {
+ MetaDataUpdate md = metaDataUpdateFactory.create(allUsersName);
+ MetaDataUpdate anotherMd = metaDataUpdateFactory.create(allUsersName)) {
VersionedAccountQueries queries = VersionedAccountQueries.forUser(userId);
queries.load(md);
queries.setQueryList(queryListText);
queries.commit(md);
+ VersionedAccountQueries anotherQueries = VersionedAccountQueries.forUser(anotherUserId);
+ anotherQueries.load(anotherMd);
+ anotherQueries.setQueryList(anotherQueryListText);
+ anotherQueries.commit(anotherMd);
}
assertThatQueryException("query:foo").hasMessageThat().isEqualTo("Unknown named query: foo");
+ assertThatQueryException("query:query1,user=" + anotherUserId)
+ .hasMessageThat()
+ .isEqualTo("Unknown named query: query1");
+ assertThatQueryException("query:query1,user=test")
+ .hasMessageThat()
+ .isEqualTo("Account 'test' not found");
+
+ requestContext.setContext(newRequestContext(anotherUserId));
+ // account 1000000 is not visible to 'anotheruser' as they are not an admin
+ assertThatQueryException("query:query1,user=" + userId)
+ .hasMessageThat()
+ .isEqualTo("Account '1000000' not found");
+ requestContext.setContext(newRequestContext(userId));
assertQuery("query:query1", change2, change1);
assertQuery("query:query2", change2, change1);
+ assertQuery("query:name=query5,user=" + anotherUserId, change2, change1);
+ assertQuery("query:user=" + anotherUserId + ",name=query6");
gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
gApi.changes().id(change1.getChangeId()).current().submit();
assertQuery("query:query2", change2);
assertQuery("query:query3", change2);
assertQuery("query:query4");
+ assertQuery("query:query6,user=" + anotherUserId, change1);
+ assertQuery("query:user=" + anotherUserId + ",query7", change2);
+ assertQuery("query:query8,user=" + anotherUserId);
}
@Test
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index e5b51e7..0258e5d 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -22,6 +22,7 @@
"//java/com/google/gerrit/common:server",
"//java/com/google/gerrit/entities",
"//java/com/google/gerrit/extensions:api",
+ "//java/com/google/gerrit/httpd",
"//java/com/google/gerrit/index",
"//java/com/google/gerrit/index:query_exception",
"//java/com/google/gerrit/lifecycle",
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
new file mode 100644
index 0000000..d0398e9
--- /dev/null
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -0,0 +1,268 @@
+// 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.restapi.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.util.Comparator.comparing;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyShort;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.patch.ComparisonType;
+import com.google.gerrit.server.patch.PatchList;
+import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.truth.NullAwareCorrespondence;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class CommentPorterTest {
+
+ private final ObjectId dummyObjectId =
+ ObjectId.fromString("0123456789012345678901234567890123456789");
+
+ @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+ @Mock private PatchListCache patchListCache;
+ @Mock private CommentsUtil commentsUtil;
+
+ private int uuidCounter = 0;
+
+ @Test
+ public void commentsAreNotDroppedWhenDiffNotAvailable() throws Exception {
+ Project.NameKey project = Project.nameKey("myProject");
+ Change.Id changeId = Change.id(1);
+ Change change = createChange(project, changeId);
+ PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+ PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+ ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
+
+ CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+ HumanComment comment = createComment(patchset1.id(), "myFile");
+ when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+ .thenReturn(Optional.of(dummyObjectId));
+ when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+ .thenThrow(PatchListNotAvailableException.class);
+ ImmutableList<HumanComment> portedComments =
+ commentPorter.portComments(
+ changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
+
+ assertThat(portedComments).isNotEmpty();
+ }
+
+ @Test
+ public void commentsAreNotDroppedWhenDiffHasUnexpectedError() throws Exception {
+ Project.NameKey project = Project.nameKey("myProject");
+ Change.Id changeId = Change.id(1);
+ Change change = createChange(project, changeId);
+ PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+ PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+ ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
+
+ CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+ HumanComment comment = createComment(patchset1.id(), "myFile");
+ when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+ .thenReturn(Optional.of(dummyObjectId));
+ when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+ .thenThrow(IllegalStateException.class);
+ ImmutableList<HumanComment> portedComments =
+ commentPorter.portComments(
+ changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
+
+ assertThat(portedComments).isNotEmpty();
+ }
+
+ @Test
+ public void commentsAreNotDroppedWhenRetrievingCommitSha1sHasUnexpectedError() {
+ Project.NameKey project = Project.nameKey("myProject");
+ Change.Id changeId = Change.id(1);
+ Change change = createChange(project, changeId);
+ PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+ PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+ ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
+
+ CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+ HumanComment comment = createComment(patchset1.id(), "myFile");
+ when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+ .thenThrow(IllegalStateException.class);
+ ImmutableList<HumanComment> portedComments =
+ commentPorter.portComments(
+ changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
+
+ assertThat(portedComments).isNotEmpty();
+ }
+
+ @Test
+ public void commentsAreMappedToPatchsetLevelOnDiffError() throws Exception {
+ Project.NameKey project = Project.nameKey("myProject");
+ Change.Id changeId = Change.id(1);
+ Change change = createChange(project, changeId);
+ PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+ PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+ ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
+
+ CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+ HumanComment comment = createComment(patchset1.id(), "myFile");
+ when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+ .thenReturn(Optional.of(dummyObjectId));
+ when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+ .thenThrow(IllegalStateException.class);
+ ImmutableList<HumanComment> portedComments =
+ commentPorter.portComments(
+ changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
+
+ assertThat(portedComments)
+ .comparingElementsUsing(hasFilePath())
+ .containsExactly(Patch.PATCHSET_LEVEL);
+ }
+
+ @Test
+ public void commentsAreStillPortedWhenDiffOfOtherCommentsHasError() throws Exception {
+ Project.NameKey project = Project.nameKey("myProject");
+ Change.Id changeId = Change.id(1);
+ Change change = createChange(project, changeId);
+ PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+ PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+ PatchSet patchset3 = createPatchset(PatchSet.id(changeId, 3));
+ ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2, patchset3);
+
+ CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+ // Place the comments on different patchsets to have two different diff requests.
+ HumanComment comment1 = createComment(patchset1.id(), "myFile");
+ HumanComment comment2 = createComment(patchset2.id(), "myFile");
+ when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+ .thenReturn(Optional.of(dummyObjectId));
+ PatchList emptyDiff = getEmptyDiff();
+ // Throw an exception on the first diff request but return an actual value on the second.
+ when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+ .thenThrow(IllegalStateException.class)
+ .thenReturn(emptyDiff);
+ ImmutableList<HumanComment> portedComments =
+ commentPorter.portComments(
+ changeNotes, patchset3, ImmutableList.of(comment1, comment2), ImmutableList.of());
+
+ // One of the comments should still be ported as usual. -> Keeps its file name as the diff was
+ // empty.
+ assertThat(portedComments).comparingElementsUsing(hasFilePath()).contains("myFile");
+ }
+
+ @Test
+ public void commentsWithInvalidPatchsetsAreIgnored() throws Exception {
+ Project.NameKey project = Project.nameKey("myProject");
+ Change.Id changeId = Change.id(1);
+ Change change = createChange(project, changeId);
+ PatchSet patchset1 = createPatchset(PatchSet.id(changeId, 1));
+ PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
+ // Leave out patchset 1 (e.g. reserved for draft patchsets in the past).
+ ChangeNotes changeNotes = mockChangeNotes(project, change, patchset2);
+
+ CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil);
+ HumanComment comment = createComment(patchset1.id(), "myFile");
+ when(commentsUtil.determineCommitId(any(), any(), anyShort()))
+ .thenReturn(Optional.of(dummyObjectId));
+ PatchList emptyDiff = getEmptyDiff();
+ when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+ .thenReturn(emptyDiff);
+ ImmutableList<HumanComment> portedComments =
+ commentPorter.portComments(
+ changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
+
+ assertThat(portedComments).isEmpty();
+ }
+
+ private Change createChange(Project.NameKey project, Change.Id changeId) {
+ return new Change(
+ Change.key("changeKey"),
+ changeId,
+ Account.id(123),
+ BranchNameKey.create(project, "myBranch"),
+ new Timestamp(12345));
+ }
+
+ private PatchSet createPatchset(PatchSet.Id id) {
+ return PatchSet.builder()
+ .id(id)
+ .commitId(dummyObjectId)
+ .uploader(Account.id(123))
+ .createdOn(new Timestamp(12345))
+ .build();
+ }
+
+ private ChangeNotes mockChangeNotes(
+ Project.NameKey project, Change change, PatchSet... patchsets) {
+ ChangeNotes changeNotes = mock(ChangeNotes.class);
+ when(changeNotes.getProjectName()).thenReturn(project);
+ when(changeNotes.getChange()).thenReturn(change);
+ when(changeNotes.getChangeId()).thenReturn(change.getId());
+ ImmutableSortedMap<PatchSet.Id, PatchSet> sortedPatchsets =
+ Arrays.stream(patchsets)
+ .collect(
+ ImmutableSortedMap.toImmutableSortedMap(
+ comparing(PatchSet.Id::get), PatchSet::id, patchset -> patchset));
+ when(changeNotes.getPatchSets()).thenReturn(sortedPatchsets);
+ return changeNotes;
+ }
+
+ private HumanComment createComment(PatchSet.Id patchsetId, String filePath) {
+ return new HumanComment(
+ new Comment.Key(getUniqueUuid(), filePath, patchsetId.get()),
+ Account.id(100),
+ new Timestamp(1234),
+ (short) 1,
+ "Comment text",
+ "serverId",
+ true);
+ }
+
+ private String getUniqueUuid() {
+ return "commentUuid" + uuidCounter++;
+ }
+
+ private Correspondence<HumanComment, String> hasFilePath() {
+ return NullAwareCorrespondence.transforming(comment -> comment.key.filename, "hasFilePath");
+ }
+
+ private PatchList getEmptyDiff() {
+ return new PatchList(
+ dummyObjectId,
+ dummyObjectId,
+ false,
+ ComparisonType.againstOtherPatchSet(),
+ new PatchListEntry[0]);
+ }
+}
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index daefd7c..5cefe74 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -32,6 +32,8 @@
@RunWith(JUnit4.class)
public class ListChangeCommentsTest {
+
+ @SuppressWarnings("TruthIncompatibleType")
@Test
public void commentsLinkedToChangeMessagesIgnoreGerritAutoGenTaggedMessages() {
/* Comments should not be linked to Gerrit's autogenerated messages */
@@ -54,7 +56,9 @@
// Make sure no comment is linked to the auto-gen message
assertThat(comments.stream().map(c -> c.changeMessageId).collect(Collectors.toSet()))
- .doesNotContain(getChangeMessage(changeMessages, "cmAutoGenByGerrit"));
+ .doesNotContain(
+ /* expected: String, actual: ChangeMessage */ getChangeMessage(
+ changeMessages, "cmAutoGenByGerrit"));
}
@Test
diff --git a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
index 9d7afbc..871c871 100644
--- a/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
+++ b/javatests/com/google/gerrit/server/rules/GerritCommonTest.java
@@ -19,7 +19,7 @@
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
-import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.server.project.testing.TestLabels;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.AbstractModule;
diff --git a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
index 858f6a2..cf5e8fe 100644
--- a/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
+++ b/javatests/com/google/gerrit/server/rules/IgnoreSelfApprovalRuleTest.java
@@ -17,10 +17,10 @@
import static com.google.common.truth.Truth.assertThat;
import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.PatchSetApproval;
diff --git a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
index 18d279f..e6a6497 100644
--- a/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
+++ b/javatests/com/google/gerrit/server/schema/AllProjectsCreatorTest.java
@@ -23,14 +23,15 @@
import static com.google.gerrit.truth.ConfigSubject.assertThat;
import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.LabelType;
import com.google.gerrit.entities.AccountGroup;
import com.google.gerrit.entities.BooleanProjectConfig;
import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LabelValue;
import com.google.gerrit.extensions.client.InheritableBoolean;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.Sequences;
@@ -88,11 +89,11 @@
expectedConfig.fromText(getDefaultAllProjectsWithAllDefaultSections());
GroupReference adminsGroup = createGroupReference("Administrators");
- GroupReference batchUsersGroup = createGroupReference("Non-Interactive Users");
+ GroupReference batchUsersGroup = createGroupReference(ServiceUserClassifier.SERVICE_USERS);
AllProjectsInput allProjectsInput =
AllProjectsInput.builder()
.administratorsGroup(adminsGroup)
- .batchUsersGroup(batchUsersGroup)
+ .serviceUsersGroup(batchUsersGroup)
.build();
allProjectsCreator.create(allProjectsInput);
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
index beb0e32..646f0cd 100644
--- a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -94,7 +94,7 @@
args =
new NoteDbSchemaVersion.Arguments(
- repoManager, allProjectsName, allUsersName, null, null, null);
+ repoManager, allProjectsName, allUsersName, null, null, null, null);
NoteDbSchemaVersionManager versionManager =
new NoteDbSchemaVersionManager(allProjectsName, repoManager);
updater =
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index d58713a..fc6b412 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -18,12 +18,16 @@
import static com.google.common.truth.Truth8.assertThat;
import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.LabelFunction;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.entities.GroupReference;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.server.account.ServiceUserClassifier;
import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.db.GroupNameNotes;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.testing.InMemoryModule;
import com.google.inject.Inject;
@@ -37,12 +41,11 @@
public class SchemaCreatorImplTest {
@Inject private AllProjectsName allProjects;
-
@Inject private GitRepositoryManager repoManager;
-
@Inject private SchemaCreator schemaCreator;
-
@Inject private ProjectConfig.Factory projectConfigFactory;
+ @Inject private GitRepositoryManager repositoryManager;
+ @Inject private AllUsersName allUsersName;
@Before
public void setUp() throws Exception {
@@ -78,6 +81,12 @@
assertValueRange(codeReview, -2, -1, 0, 1, 2);
}
+ @Test
+ public void groupIsCreatedWhenSchemaIsCreated() throws Exception {
+ assertThat(hasGroup(ServiceUserClassifier.SERVICE_USERS)).isTrue();
+ assertThat(hasGroup("Non-Interactive Users")).isFalse();
+ }
+
private void assertValueRange(LabelType label, Integer... range) {
List<Integer> rangeList = Arrays.asList(range);
assertThat(rangeList).isNotEmpty();
@@ -93,4 +102,11 @@
assertThat(v.getText()).isNotEmpty();
}
}
+
+ private boolean hasGroup(String name) throws Exception {
+ try (Repository repo = repositoryManager.openRepository(allUsersName)) {
+ List<GroupReference> nameNotes = GroupNameNotes.loadAllGroups(repo);
+ return nameNotes.stream().anyMatch(g -> g.getName().equals(name));
+ }
+ }
}
diff --git a/javatests/com/google/gerrit/server/submit/BUILD b/javatests/com/google/gerrit/server/submit/BUILD
new file mode 100644
index 0000000..7425bc8
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/BUILD
@@ -0,0 +1,21 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+ name = "submit_tests",
+ size = "small",
+ srcs = glob(
+ ["**/*.java"],
+ ),
+ visibility = ["//visibility:public"],
+ deps = [
+ "//java/com/google/gerrit/entities",
+ "//java/com/google/gerrit/server",
+ "//java/com/google/gerrit/testing:gerrit-test-util",
+ "//lib:jgit",
+ "//lib/mockito",
+ "//lib/truth",
+ "//lib/truth:truth-java8-extension",
+ "//lib/truth:truth-proto-extension",
+ "@jgit//org.eclipse.jgit.junit:junit",
+ ],
+)
diff --git a/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
new file mode 100644
index 0000000..313e697
--- /dev/null
+++ b/javatests/com/google/gerrit/server/submit/SubmoduleCommitsTest.java
@@ -0,0 +1,225 @@
+// 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.submit;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.submit.MergeOpRepoManager.OpenRepo;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheBuilder;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.AnyObjectId;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+public class SubmoduleCommitsTest {
+
+ private static final String MASTER = "refs/heads/master";
+ private static final Project.NameKey superProject = Project.nameKey("superproject");
+ private static final Project.NameKey subProject = Project.nameKey("subproject");
+
+ private static final PersonIdent ident = new PersonIdent("submodule-test", "a@b.com");
+
+ private InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+ private MergeOpRepoManager mergeOpRepoManager;
+
+ @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+ @Mock private ProjectCache mockProjectCache;
+ @Mock private ProjectState mockProjectState;
+
+ @Test
+ public void createGitlinksCommit_subprojectMoved() throws Exception {
+ createRepo(subProject, MASTER);
+ createRepo(superProject, MASTER);
+
+ when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+ mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+ ObjectId subprojectCommit = getTip(subProject, MASTER);
+ RevCommit superprojectTip =
+ directUpdateSubmodule(superProject, MASTER, Project.nameKey("dir-x"), subprojectCommit);
+ assertThat(readGitLink(superProject, superprojectTip, "dir-x")).isEqualTo(subprojectCommit);
+
+ RevCommit newSubprojectCommit = addCommit(subProject, MASTER);
+
+ BranchNameKey superBranch = BranchNameKey.create(superProject, MASTER);
+ BranchNameKey subBranch = BranchNameKey.create(subProject, MASTER);
+ SubmoduleSubscription ss = new SubmoduleSubscription(superBranch, subBranch, "dir-x");
+ SubmoduleCommits helper = new SubmoduleCommits(mergeOpRepoManager, ident, new Config());
+ Optional<CodeReviewCommit> newGitLinksCommit =
+ helper.composeGitlinksCommit(
+ BranchNameKey.create(superProject, MASTER), ImmutableList.of(ss));
+
+ assertThat(newGitLinksCommit).isPresent();
+ assertThat(newGitLinksCommit.get().getParent(0)).isEqualTo(superprojectTip);
+ assertThat(readGitLink(superProject, newGitLinksCommit.get(), "dir-x"))
+ .isEqualTo(newSubprojectCommit);
+ }
+
+ @Test
+ public void amendGitlinksCommit_subprojectMoved() throws Exception {
+ createRepo(subProject, MASTER);
+ createRepo(superProject, MASTER);
+
+ when(mockProjectCache.get(any())).thenReturn(Optional.of(mockProjectState));
+ mergeOpRepoManager = new MergeOpRepoManager(repoManager, mockProjectCache, null, null);
+
+ ObjectId subprojectCommit = getTip(subProject, MASTER);
+ CodeReviewCommit superprojectTip =
+ directUpdateSubmodule(superProject, MASTER, Project.nameKey("dir-x"), subprojectCommit);
+ assertThat(readGitLink(superProject, superprojectTip, "dir-x")).isEqualTo(subprojectCommit);
+
+ RevCommit newSubprojectCommit = addCommit(subProject, MASTER);
+
+ BranchNameKey superBranch = BranchNameKey.create(superProject, MASTER);
+ BranchNameKey subBranch = BranchNameKey.create(subProject, MASTER);
+ SubmoduleSubscription ss = new SubmoduleSubscription(superBranch, subBranch, "dir-x");
+ SubmoduleCommits helper = new SubmoduleCommits(mergeOpRepoManager, ident, new Config());
+ CodeReviewCommit amendedCommit =
+ helper.amendGitlinksCommit(
+ BranchNameKey.create(superProject, MASTER), superprojectTip, ImmutableList.of(ss));
+
+ assertThat(amendedCommit.getParent(0)).isEqualTo(superprojectTip.getParent(0));
+ assertThat(readGitLink(superProject, amendedCommit, "dir-x")).isEqualTo(newSubprojectCommit);
+ }
+
+ /** Create repo with a commit on refName */
+ private void createRepo(Project.NameKey projectKey, String refName) throws Exception {
+ Repository repo = repoManager.createRepository(projectKey);
+ try (TestRepository<Repository> git = new TestRepository<>(repo)) {
+ RevCommit newCommit = git.commit().message("Initial commit for " + projectKey).create();
+ git.update(refName, newCommit);
+ }
+ }
+
+ private ObjectId getTip(Project.NameKey projectKey, String refName)
+ throws RepositoryNotFoundException, IOException {
+ return repoManager.openRepository(projectKey).exactRef(refName).getObjectId();
+ }
+
+ private RevCommit addCommit(Project.NameKey projectKey, String refName) throws Exception {
+ try (Repository serverRepo = repoManager.openRepository(projectKey);
+ RevWalk rw = new RevWalk(serverRepo);
+ TestRepository<Repository> git = new TestRepository<>(serverRepo, rw)) {
+ Ref ref = serverRepo.exactRef(refName);
+ assertWithMessage(refName).that(ref).isNotNull();
+
+ RevCommit originalTip = rw.parseCommit(ref.getObjectId());
+ RevCommit newTip =
+ git.commit().parent(originalTip).message("Added commit to " + projectKey).create();
+ git.update(refName, newTip);
+ return newTip;
+ }
+ }
+
+ private CodeReviewCommit directUpdateSubmodule(
+ Project.NameKey project, String refName, Project.NameKey path, AnyObjectId id)
+ throws Exception {
+ OpenRepo or = mergeOpRepoManager.getRepo(project);
+ Repository serverRepo = or.repo;
+ ObjectInserter ins = or.ins;
+ CodeReviewRevWalk rw = or.rw;
+ Ref ref = serverRepo.exactRef(refName);
+ assertWithMessage(refName).that(ref).isNotNull();
+ ObjectId oldCommitId = ref.getObjectId();
+
+ DirCache dc = DirCache.newInCore();
+ DirCacheBuilder b = dc.builder();
+ b.addTree(new byte[0], DirCacheEntry.STAGE_0, rw.getObjectReader(), rw.parseTree(oldCommitId));
+ b.finish();
+ DirCacheEditor e = dc.editor();
+ e.add(
+ new PathEdit(path.get()) {
+ @Override
+ public void apply(DirCacheEntry ent) {
+ ent.setFileMode(FileMode.GITLINK);
+ ent.setObjectId(id);
+ }
+ });
+ e.finish();
+
+ CommitBuilder cb = new CommitBuilder();
+ cb.addParentId(oldCommitId);
+ cb.setTreeId(dc.writeTree(ins));
+
+ cb.setAuthor(ident);
+ cb.setCommitter(ident);
+ cb.setMessage("Direct update submodule " + path);
+ ObjectId newCommitId = ins.insert(cb);
+ ins.flush();
+
+ RefUpdate ru = serverRepo.updateRef(refName);
+ ru.setExpectedOldObjectId(oldCommitId);
+ ru.setNewObjectId(newCommitId);
+ assertThat(ru.update()).isEqualTo(RefUpdate.Result.FAST_FORWARD);
+ return rw.parseCommit(newCommitId);
+ }
+
+ private ObjectId readGitLink(Project.NameKey projectKey, RevCommit commit, String path)
+ throws IOException, NoSuchProjectException {
+ // SubmoduleCommitHelper used mergeOpRepoManager to create the commit
+ // Read the repo from mergeOpRepoManager to get also the RevWalk that created the commit
+ return readGitLinkInCommit(mergeOpRepoManager.getRepo(projectKey).rw, commit, path);
+ }
+
+ private ObjectId readGitLinkInCommit(RevWalk rw, RevCommit commit, String path)
+ throws IOException {
+ DirCache dc = DirCache.newInCore();
+ DirCacheBuilder b = dc.builder();
+ b.addTree(
+ new byte[0], // no prefix path
+ DirCacheEntry.STAGE_0, // standard stage
+ rw.getObjectReader(),
+ commit.getTree());
+ b.finish();
+ DirCacheEntry entry = dc.getEntry(path);
+ assertThat(entry.getFileMode()).isEqualTo(FileMode.GITLINK);
+ return entry.getObjectId();
+ }
+}
diff --git a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
index 5f71544..fb995fd 100644
--- a/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
+++ b/javatests/com/google/gerrit/server/submit/SubscriptionGraphTest.java
@@ -22,10 +22,10 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.data.SubscribeSection;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.SubmoduleSubscription;
+import com.google.gerrit.entities.SubscribeSection;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.submit.SubscriptionGraph.DefaultFactory;
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 083493d..287a7fe 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -22,12 +22,12 @@
import com.google.common.cache.Cache;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmissionId;
+import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.change.ChangeInserter;
diff --git a/lib/auto/BUILD b/lib/auto/BUILD
index b60a101..18b9b91 100644
--- a/lib/auto/BUILD
+++ b/lib/auto/BUILD
@@ -18,12 +18,37 @@
],
)
+java_plugin(
+ name = "auto-oneof-plugin",
+ processor_class = "com.google.auto.value.processor.AutoOneOfProcessor",
+ deps = [
+ "@auto-value-annotations//jar",
+ "@auto-value//jar",
+ ],
+)
+
+java_plugin(
+ name = "auto-value-gson-plugin",
+ processor_class = "com.ryanharter.auto.value.gson.factory.AutoValueGsonAdapterFactoryProcessor",
+ deps = [
+ "@auto-value-annotations//jar",
+ "@auto-value-gson-extension//jar",
+ "@auto-value-gson-factory//jar",
+ "@auto-value-gson-runtime//jar",
+ "@auto-value//jar",
+ "@autotransient//jar",
+ "@gson//jar",
+ "@javapoet//jar",
+ ],
+)
+
java_library(
name = "auto-value",
data = ["//lib:LICENSE-Apache2.0"],
exported_plugins = [
":auto-annotation-plugin",
":auto-value-plugin",
+ ":auto-oneof-plugin",
],
visibility = ["//visibility:public"],
exports = ["@auto-value//jar"],
@@ -35,7 +60,22 @@
exported_plugins = [
":auto-annotation-plugin",
":auto-value-plugin",
+ ":auto-oneof-plugin",
],
visibility = ["//visibility:public"],
exports = ["@auto-value-annotations//jar"],
)
+
+java_library(
+ name = "auto-value-gson",
+ data = ["//lib:LICENSE-Apache2.0"],
+ exported_plugins = [
+ ":auto-value-gson-plugin",
+ ],
+ visibility = ["//visibility:public"],
+ exports = [
+ "@auto-value-gson-extension//jar",
+ "@auto-value-gson-factory//jar",
+ "@auto-value-gson-runtime//jar",
+ ],
+)
diff --git a/modules/jgit b/modules/jgit
index 8e79d5a..dd16976 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 8e79d5a290843b929f073a536a0d678fc74382ca
+Subproject commit dd169769bf42115e1dee749efeecab84544b28c4
diff --git a/package.json b/package.json
index 7ba108d..46f20c7 100644
--- a/package.json
+++ b/package.json
@@ -2,11 +2,12 @@
"name": "gerrit",
"version": "3.1.0-SNAPSHOT",
"description": "Gerrit Code Review",
- "dependencies": {},
+ "dependencies": {
+ "@bazel/rollup": "^2.2.2",
+ "@bazel/terser": "^2.2.2",
+ "@bazel/typescript": "^2.2.2"
+ },
"devDependencies": {
- "@bazel/rollup": "^1.6.1",
- "@bazel/terser": "^1.7.0",
- "@bazel/typescript": "^1.6.1",
"eslint": "^6.6.0",
"eslint-config-google": "^0.13.0",
"eslint-plugin-html": "^6.0.0",
@@ -17,7 +18,7 @@
"polymer-cli": "^1.9.11",
"prettier": "2.0.5",
"terser": "^4.8.0",
- "typescript": "3.8.2"
+ "typescript": "3.9.5"
},
"scripts": {
"clean": "git clean -fdx && bazel clean --expunge",
diff --git a/plugins/delete-project b/plugins/delete-project
index 7cb59ec..60ce67d 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 7cb59ecacbbe7bc995873ae112e48cf0ff521d2a
+Subproject commit 60ce67dd53ad64c33a2c34aae31e9ee823979109
diff --git a/plugins/download-commands b/plugins/download-commands
index fd650ca..87e3930 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit fd650ca386c382b42d30e7ad72279bfeb311aee4
+Subproject commit 87e3930cea7c06aea454998abdddf6515a9f103b
diff --git a/plugins/hooks b/plugins/hooks
index 7ed555f..ad4f877 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 7ed555fe88f4be028acbfd5c245ac78537ac3666
+Subproject commit ad4f877749928b69ef94b62176c5797f6648887d
diff --git a/plugins/package.json b/plugins/package.json
index e0227d1..9f5c649 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -1,6 +1,6 @@
{
"name": "polygerrit-plugin-dependencies-placeholder",
- "description": "Gerrit Code Review - Polygerrit plugin dependencies placeholder, expected to be overriden by plugins",
+ "description": "Gerrit Code Review - Polygerrit plugin dependencies placeholder, expected to be overridden by plugins",
"browser": true,
"dependencies": {},
"license": "Apache-2.0",
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index 783f5c6..00e5794 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit 783f5c65c7dca522658efe10d57d1ac9ab5f9007
+Subproject commit 00e57948f4f112c226028bc5c8d8fe60f770038f
diff --git a/plugins/replication b/plugins/replication
index 9d4d19a..5f5c0d3 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 9d4d19a579fc4962ab7f85f6b5cb12501ed048ad
+Subproject commit 5f5c0d372b0006cdee9b880716ffecabe6b29cef
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 05f0ddd..fb0390a 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 05f0ddd30928d0d050696f3d269dea0899334513
+Subproject commit fb0390a8b49f0d601e11f8a1ac0658c429727f21
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 3e95e42..2266ba0 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -1,12 +1,5 @@
# Gerrit Polymer Frontend
-**Warning**: DON'T ADD MORE TYPESCRIPT FILES/TYPES. Gerrit Polymer Frontend
-contains several typescript files and uses typescript compiler. This is a
-preparation for the upcoming migration to typescript and we actively working on
-it. We want to avoid massive typescript-related changes until the preparation
-work is done. Thanks for your understanding!
-
-
Follow the
[setup instructions for Gerrit backend developers](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
where applicable, the most important command is:
@@ -279,6 +272,245 @@
npm run polylint
```
+## Migrating tests to Typescript
+
+You can use the following steps for migrating tests to Typescript:
+
+1. Rename the `_test.js` file to `_test.ts`
+2. Remove `.js` extensions from all imports:
+ ```
+ // Before:
+ import ... from 'x/y/z.js`
+
+ // After
+ import .. from 'x/y/z'
+ ```
+3. Fix typescript and eslint errors.
+
+Common errors and fixes are:
+
+* An object in the test doesn't have all required properties. You can use
+existing helpers to create an object with all required properties:
+```
+// Before:
+sinon.stub(element.$.restAPI, 'getPreferences').returns(
+ Promise.resolve({default_diff_view: 'UNIFIED'}));
+
+// After:
+Promise.resolve({
+ ...createPreferences(),
+ default_diff_view: DiffViewMode.UNIFIED,
+})
+```
+
+Some helpers receive parameters:
+```
+// Before
+element._change = {
+ change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+ revisions: {
+ rev1: {_number: 1, commit: {parents: []}},
+ rev2: {_number: 2, commit: {parents: []}},
+ },
+ current_revision: 'rev1',
+ status: ChangeStatus.MERGED,
+ labels: {},
+ actions: {},
+};
+
+// After
+element._change = {
+ ...createChange(),
+ // The change_id is set by createChange.
+ // The exact change_id is not important in the test, so it was removed.
+ revisions: {
+ rev1: createRevision(1), // _number is a parameter here
+ rev2: createRevision(2), // _number is a parameter here
+ },
+ current_revision: 'rev1' as CommitId,
+ status: ChangeStatus.MERGED,
+ labels: {},
+ actions: {},
+};
+```
+* Typescript reports some weird messages about `window` property - sometimes an
+IDE adds wrong import. Just remove it.
+```
+// The wrong import added by IDE, must be removed
+import window = Mocha.reporters.Base.window;
+```
+
+* `TS2531: Object is possibly 'null'`. To fix use either non-null assertion
+operator `!` or nullish coalescing operator `?.`:
+```
+// Before:
+const rows = element
+ .shadowRoot.querySelector('table')
+ .querySelectorAll('tbody tr');
+...
+// The _robotCommentThreads declared as _robotCommentThreads?: CommentThread
+assert.equal(element._robotCommentThreads.length, 2);
+
+// Fix with non-null assertion operator:
+const rows = element
+ .shadowRoot!.querySelector('table')! // '!' after shadowRoot and querySelector
+ .querySelectorAll('tbody tr');
+
+assert.equal(element._robotCommentThreads!.length, 2);
+
+// Fix with nullish coalescing operator:
+ assert.equal(element._robotCommentThreads?.length, 2);
+```
+Usually the fix with `!` is preferable, because it gives more clear error
+when an intermediate property is `null/undefined`. If the _robotComments is
+`undefined` in the example above, the `element._robotCommentThreads!.length`
+crashes with the error `Cannot read property 'length' of undefined`. At the
+same time the fix with
+`?.` doesn't distinct between 2 cases: _robotCommentThreads is `undefined`
+and `length` is `undefined`.
+
+* `TS2339: Property '...' does not exist on type 'Element'.` for elements
+returned by `querySelector/querySelectorAll`. To fix it, use generic versions
+of those methods:
+```
+// Before:
+const radios = parentTable
+ .querySelectorAll('input[type=radio]');
+const radio = parentRow
+ .querySelector('input[type=radio]');
+
+// After:
+const radios = parentTable
+ .querySelectorAll<HTMLInputElement>('input[type=radio]');
+const radio = parentRow
+ .querySelector<HTMLInputElement>('input[type=radio]');
+```
+
+* Sinon: `TS2339: Property 'lastCall' does not exist on type '...` (the same
+for other sinon properties). Store stub/spy in a variable and then use the
+variable:
+```
+// Before:
+sinon.stub(GerritNav, 'getUrlForChange')
+...
+assert.equal(GerritNav.getUrlForChange.lastCall.args[4], '#message-a12345');
+
+// After:
+const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
+...
+assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+```
+
+If you need to define a type for such variable, you can use one of the following
+options:
+```
+suite('my suite', () => {
+ // Non static members, option 1
+ let updateHeightSpy: SinonSpyMember<typeof element._updateRelatedChangeMaxHeight>;
+ // Non static members, option 2
+ let updateHeightSpy_prototype: SinonSpyMember<typeof GrChangeView.prototype._updateRelatedChangeMaxHeight>;
+ // Static members
+ let navigateToChangeStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;
+ // For interfaces
+ let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
+});
+```
+
+* Typescript reports errors when stubbing/faking methods:
+```
+// The JS code:
+const reloadStub = sinon
+ .stub(element, '_reload')
+ .callsFake(() => Promise.resolve());
+
+stub('gr-rest-api-interface', {
+ getDiffComments() { return Promise.resolve({}); },
+ getDiffRobotComments() { return Promise.resolve({}); },
+ getDiffDrafts() { return Promise.resolve({}); },
+ _fetchSharedCacheURL() { return Promise.resolve({}); },
+});
+```
+
+In such cases, validate the input and output of a stub/fake method. Quite often
+tests return null instead of undefined or `[]` instead of `{}`, etc...
+Fix types if they are not correct:
+```
+const reloadStub = sinon
+ .stub(element, '_reload')
+ // GrChangeView._reload method returns an array
+ .callsFake(() => Promise.resolve([])); // return [] here
+
+stub('gr-rest-api-interface', {
+ ...
+ // Fix return type:
+ _fetchSharedCacheURL() { return Promise.resolve({} as ParsedJSON); },
+});
+```
+
+If a method has multiple overloads, you can use one of 2 options:
+```
+// Option 1: less accurate, but shorter:
+function getCommentsStub() {
+ return Promise.resolve({});
+}
+
+stub('gr-rest-api-interface', {
+ ...
+ getDiffComments: (getCommentsStub as unknown) as RestApiService['getDiffComments'],
+ getDiffRobotComments: (getCommentsStub as unknown) as RestApiService['getDiffRobotComments'],
+ getDiffDrafts: (getCommentsStub as unknown) as RestApiService['getDiffDrafts'],
+ ...
+});
+
+// Option 2: more accurate, but longer.
+// Step 1: define the same overloads for stub:
+function getDiffCommentsStub(
+ changeNum: NumericChangeId
+): Promise<PathToCommentsInfoMap | undefined>;
+function getDiffCommentsStub(
+ changeNum: NumericChangeId,
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ path: string
+): Promise<GetDiffCommentsOutput>;
+
+// Step 2: implement stub method for differnt input
+function getDiffCommentsStub(
+ _: NumericChangeId,
+ basePatchNum?: PatchSetNum,
+):
+ | Promise<PathToCommentsInfoMap | undefined>
+ | Promise<GetDiffCommentsOutput> {
+ if (basePatchNum) {
+ return Promise.resolve({
+ baseComments: [],
+ comments: [],
+ });
+ }
+ return Promise.resolve({});
+}
+
+// Step 3: use stubbed function:
+stub('gr-rest-api-interface', {
+ ...
+ getDiffComments: getDiffCommentsStub,
+ ...
+});
+```
+
+* If a test requires a `@types/...` library, install the required library
+in the `polygerrit_ui/node_modules` and update the `typeRoots` in the
+`polygerrit-ui/app/tsconfig_bazel_test.json` file.
+
+The same update should be done if a test requires a .d.ts file from a library
+that already exists in `polygerrit_ui/node_modules`.
+
+**Note:** Types from a library located in `polygerrit_ui/app/node_modules` are
+handle automatically.
+
+* If a test imports a library from `polygerrit_ui/node_modules` - update
+`paths` in `polygerrit-ui/app/tsconfig_bazel_test.json`.
+
## Contributing
Our users report bugs / feature requests related to the UI through [Monorail Issues - PolyGerrit](https://bugs.chromium.org/p/gerrit/issues/list?q=component%3APolyGerrit).
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index 1106ade..9834ddc 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -30,14 +30,22 @@
"es6": true
},
"rules": {
+ // https://eslint.org/docs/rules/no-confusing-arrow
"no-confusing-arrow": "error",
+ // https://eslint.org/docs/rules/newline-per-chained-call
"newline-per-chained-call": ["error", {"ignoreChainWithDepth": 2}],
+ // https://eslint.org/docs/rules/arrow-body-style
"arrow-body-style": ["error", "as-needed",
{"requireReturnForObjectLiteral": true}],
+ // https://eslint.org/docs/rules/arrow-parens
"arrow-parens": ["error", "as-needed"],
+ // https://eslint.org/docs/rules/block-spacing
"block-spacing": ["error", "always"],
+ // https://eslint.org/docs/rules/brace-style
"brace-style": ["error", "1tbs", {"allowSingleLine": true}],
+ // https://eslint.org/docs/rules/camelcase
"camelcase": "off",
+ // https://eslint.org/docs/rules/comma-dangle
"comma-dangle": ["error", {
"arrays": "always-multiline",
"objects": "always-multiline",
@@ -45,7 +53,9 @@
"exports": "always-multiline",
"functions": "never"
}],
+ // https://eslint.org/docs/rules/eol-last
"eol-last": "off",
+ // https://eslint.org/docs/rules/indent
"indent": ["error", 2, {
"MemberExpression": 2,
"FunctionDeclaration": {"body": 1, "parameters": 2},
@@ -55,8 +65,11 @@
"ObjectExpression": 1,
"SwitchCase": 1
}],
+ // https://eslint.org/docs/rules/keyword-spacing
"keyword-spacing": ["error", {"after": true, "before": true}],
+ // https://eslint.org/docs/rules/lines-between-class-members
"lines-between-class-members": ["error", "always"],
+ // https://eslint.org/docs/rules/max-len
"max-len": [
"error",
80,
@@ -66,14 +79,26 @@
"ignorePattern": "^import .*;$"
}
],
+ // https://eslint.org/docs/rules/new-cap
"new-cap": ["error", {
"capIsNewExceptions": ["Polymer", "GestureEventListeners"],
"capIsNewExceptionPattern": "^.*Mixin$"
}],
- "no-console": "off",
+ // https://eslint.org/docs/rules/no-console
+ "no-console": ["error", { allow: ["warn", "error", "info", "assert", "group", "groupEnd"] }],
+ // https://eslint.org/docs/rules/no-multiple-empty-lines
"no-multiple-empty-lines": ["error", {"max": 1}],
+ // https://eslint.org/docs/rules/no-prototype-builtins
"no-prototype-builtins": "off",
+ // https://eslint.org/docs/rules/no-redeclare
"no-redeclare": "off",
+ // https://eslint.org/docs/rules/no-trailing-spaces
+ "no-trailing-spaces": "error",
+ // https://eslint.org/docs/rules/no-irregular-whitespace
+ "no-irregular-whitespace": "error",
+ // https://eslint.org/docs/rules/array-callback-return
+ "array-callback-return": ['error', { allowImplicit: true }],
+ // https://eslint.org/docs/rules/no-restricted-syntax
"no-restricted-syntax": [
"error",
{
@@ -87,11 +112,17 @@
],
// no-undef disables global variable.
// "globals" declares allowed global variables.
+ // https://eslint.org/docs/rules/no-undef
"no-undef": ["error"],
+ // https://eslint.org/docs/rules/no-useless-escape
"no-useless-escape": "off",
+ // https://eslint.org/docs/rules/no-var
"no-var": "error",
+ // https://eslint.org/docs/rules/operator-linebreak
"operator-linebreak": "off",
+ // https://eslint.org/docs/rules/object-shorthand
"object-shorthand": ["error", "always"],
+ // https://eslint.org/docs/rules/padding-line-between-statements
"padding-line-between-statements": [
"error",
{
@@ -105,42 +136,76 @@
"next": "class"
}
],
+ // https://eslint.org/docs/rules/prefer-arrow-callback
"prefer-arrow-callback": "error",
+ // https://eslint.org/docs/rules/prefer-const
"prefer-const": "error",
+ // https://eslint.org/docs/rules/prefer-promise-reject-errors
"prefer-promise-reject-errors": "error",
+ // https://eslint.org/docs/rules/prefer-spread
"prefer-spread": "error",
+ // https://eslint.org/docs/rules/prefer-object-spread
+ "prefer-object-spread": "error",
+ // https://eslint.org/docs/rules/quote-props
"quote-props": ["error", "consistent-as-needed"],
+ // https://eslint.org/docs/rules/semi
"semi": ["error", "always"],
+ // https://eslint.org/docs/rules/template-curly-spacing
"template-curly-spacing": "error",
+ // https://eslint.org/docs/rules/require-jsdoc
"require-jsdoc": 0,
+ // https://eslint.org/docs/rules/valid-jsdoc
"valid-jsdoc": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-alignment
"jsdoc/check-alignment": 2,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-examples
"jsdoc/check-examples": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-indentation
"jsdoc/check-indentation": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-param-names
"jsdoc/check-param-names": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-syntax
"jsdoc/check-syntax": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-tag-names
"jsdoc/check-tag-names": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-types
"jsdoc/check-types": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-implements-on-classes
"jsdoc/implements-on-classes": 2,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
"jsdoc/match-description": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
"jsdoc/newline-after-description": 2,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
"jsdoc/no-types": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
"jsdoc/no-undefined-types": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description
"jsdoc/require-description": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description-complete-sentence
"jsdoc/require-description-complete-sentence": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-example
"jsdoc/require-example": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-hyphen-before-param-description
"jsdoc/require-hyphen-before-param-description": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-jsdoc
"jsdoc/require-jsdoc": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param
"jsdoc/require-param": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-description
"jsdoc/require-param-description": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-name
"jsdoc/require-param-name": 2,
- "jsdoc/require-param-type": 2,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns
"jsdoc/require-returns": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-check
"jsdoc/require-returns-check": 0,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-description
"jsdoc/require-returns-description": 0,
- "jsdoc/require-returns-type": 2,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-valid-types
"jsdoc/valid-types": 2,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-file-overview
"jsdoc/require-file-overview": ["error", {
"tags": {
"license": {
@@ -149,16 +214,21 @@
}
}
}],
+ // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-self-import.md
"import/no-self-import": 2,
// The no-cycle rule is slow, because it doesn't cache dependencies.
// Disable it.
+ // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-cycle.md
"import/no-cycle": 0,
+ // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-useless-path-segments.md
"import/no-useless-path-segments": 2,
+ // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-unused-modules.md
"import/no-unused-modules": 2,
+ // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
"import/no-default-export": 2,
- // Custom rule from the //tools/js/eslint-rules directory.
- // See //tools/js/eslint-rules/README.md for details
- "goog-module-id": 2,
+ // Prevents certain identifiers being used.
+ // Prefer flush() over flushAsynchronousOperations().
+ "id-blacklist": ["error", "flushAsynchronousOperations"],
},
// List of allowed globals in all files
@@ -179,6 +249,10 @@
// .js-only rules
"files": ["**/*.js"],
"rules": {
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-type
+ "jsdoc/require-param-type": 2,
+ // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-type
+ "jsdoc/require-returns-type": 2,
// The rule is required for .js files only, because typescript compiler
// always checks import.
"import/no-unresolved": 2,
@@ -192,30 +266,32 @@
"files": ["**/*.ts"],
"extends": [require.resolve("gts/.eslintrc.json")],
"rules": {
+ "no-restricted-imports": ["error", {
+ name: "@polymer/decorators/lib/decorators",
+ message: "Use @polymer/decorators instead",
+ }],
// The following rules is required to match internal google rules
"@typescript-eslint/restrict-plus-operands": "error",
+ // https://github.com/mysticatea/eslint-plugin-node/blob/master/docs/rules/no-unsupported-features/node-builtins.md
+ "node/no-unsupported-features/node-builtins": "off",
+ // Disable no-invalid-this for ts files, because it incorrectly reports
+ // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
+ // At the same time, we are using typescript in a strict mode and
+ // it catches almost all errors related to invalid usage of this.
+ "no-invalid-this": "off",
+
+ "node/no-extraneous-import": "off",
+
+ // Typescript already checks for undef
+ "no-undef": "off",
+
+ "jsdoc/no-types": 2,
},
"parserOptions": {
"project": path.resolve(__dirname, "./tsconfig_eslint.json"),
}
},
{
- "files": ["**/*.ts"],
- "excludedFiles": "*.d.ts",
- "rules": {
- // Custom rule from the //tools/js/eslint-rules directory.
- // See //tools/js/eslint-rules/README.md for details
- "ts-imports-js": 2,
- }
- },
- {
- "files": ["**/*.d.ts"],
- "rules": {
- // See details in the //tools/js/eslint-rules/report-ts-error.js file.
- "report-ts-error": "error",
- }
- },
- {
"files": ["*.html", "test.js", "test-infra.js"],
"rules": {
"jsdoc/require-file-overview": "off"
@@ -224,8 +300,6 @@
{
"files": [
"*.html",
- "common-test-setup.js",
- "common-test-setup-karma.js",
"*_test.js",
"a11y-test-utils.js",
],
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 41c3f17..c29663c 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -3,7 +3,8 @@
package(default_visibility = ["//visibility:public"])
-# This list must be in sync with the "include" list in the tsconfig.json file
+# This list must be in sync with the "include" list in the follwoing files:
+# tsconfig.json, tsconfig_bazel.json, tsconfig_bazel_test.json
src_dirs = [
"constants",
"elements",
@@ -27,6 +28,7 @@
]],
exclude = [
"**/*_test.js",
+ "**/*_test.ts",
],
),
# The same outdir also appears in the following files:
@@ -40,6 +42,7 @@
[
"**/*.js",
"**/*.ts",
+ "test/@types/*.d.ts",
],
exclude = [
"node_modules/**",
@@ -48,6 +51,7 @@
"rollup.config.js",
],
),
+ include_tests = True,
# The same outdir also appears in the following files:
# wct_test.sh
# karma.conf.js
diff --git a/polygerrit-ui/app/constants/constants.d.ts b/polygerrit-ui/app/constants/constants.d.ts
deleted file mode 100644
index 036d6ea..0000000
--- a/polygerrit-ui/app/constants/constants.d.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-export type ChangeStatus = any;
-export namespace ChangeStatus {
- export const ABANDONED: string;
- export const MERGED: string;
- export const NEW: string;
-}
diff --git a/polygerrit-ui/app/constants/constants.js b/polygerrit-ui/app/constants/constants.js
deleted file mode 100644
index 3a4cb5e..0000000
--- a/polygerrit-ui/app/constants/constants.js
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-goog.declareModuleId('polygerrit.constants.constants');
-
-/**
- * @enum
- * @desc Tab names for primary tabs on change view page.
- */
-export const PrimaryTab = {
- FILES: 'files',
- /**
- * When renaming this, the links in UrlFormatter must be updated.
- */
- COMMENT_THREADS: 'comments',
- FINDINGS: 'findings',
-};
-
-/**
- * @enum
- * @desc Tab names for secondary tabs on change view page.
- */
-export const SecondaryTab = {
- CHANGE_LOG: '_changeLog',
-};
-
-/**
- * @enum
- * @desc Tag names of change log messages.
- */
-export const MessageTag = {
- TAG_DELETE_REVIEWER: 'autogenerated:gerrit:deleteReviewer',
- TAG_NEW_PATCHSET: 'autogenerated:gerrit:newPatchSet',
- TAG_NEW_WIP_PATCHSET: 'autogenerated:gerrit:newWipPatchSet',
- TAG_REVIEWER_UPDATE: 'autogenerated:gerrit:reviewerUpdate',
- TAG_SET_PRIVATE: 'autogenerated:gerrit:setPrivate',
- TAG_UNSET_PRIVATE: 'autogenerated:gerrit:unsetPrivate',
- TAG_SET_READY: 'autogenerated:gerrit:setReadyForReview',
- TAG_SET_WIP: 'autogenerated:gerrit:setWorkInProgress',
- TAG_SET_ASSIGNEE: 'autogenerated:gerrit:setAssignee',
- TAG_UNSET_ASSIGNEE: 'autogenerated:gerrit:deleteAssignee',
-};
-
-/**
- * @enum
- * @desc Modes for gr-diff-cursor
- * The scroll behavior for the cursor. Values are 'never' and
- * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
- * the viewport.
- */
-export const ScrollMode = {
- KEEP_VISIBLE: 'keep-visible',
- NEVER: 'never',
-};
-
-/**
- * @enum
- * @desc Specifies status for a change
- */
-export const ChangeStatus = {
- ABANDONED: 'ABANDONED',
- MERGED: 'MERGED',
- NEW: 'NEW',
-};
-
-/**
- * @enum
- * @desc Special file paths
- */
-export const SpecialFilePath = {
- PATCHSET_LEVEL_COMMENTS: '/PATCHSET_LEVEL',
- COMMIT_MESSAGE: '/COMMIT_MSG',
- MERGE_LIST: '/MERGE_LIST',
-};
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
new file mode 100644
index 0000000..b64a7d1
--- /dev/null
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -0,0 +1,402 @@
+/**
+ * @license
+ * 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.
+ */
+
+/**
+ * @desc Tab names for primary tabs on change view page.
+ */
+export enum PrimaryTab {
+ FILES = 'files',
+ /**
+ * When renaming this, the links in UrlFormatter must be updated.
+ */
+ COMMENT_THREADS = 'comments',
+ FINDINGS = 'findings',
+}
+
+/**
+ * @desc Tab names for secondary tabs on change view page.
+ */
+export enum SecondaryTab {
+ CHANGE_LOG = '_changeLog',
+}
+
+/**
+ * @desc Tag names of change log messages.
+ */
+export enum MessageTag {
+ TAG_DELETE_REVIEWER = 'autogenerated:gerrit:deleteReviewer',
+ TAG_NEW_PATCHSET = 'autogenerated:gerrit:newPatchSet',
+ TAG_NEW_WIP_PATCHSET = 'autogenerated:gerrit:newWipPatchSet',
+ TAG_REVIEWER_UPDATE = 'autogenerated:gerrit:reviewerUpdate',
+ TAG_SET_PRIVATE = 'autogenerated:gerrit:setPrivate',
+ TAG_UNSET_PRIVATE = 'autogenerated:gerrit:unsetPrivate',
+ TAG_SET_READY = 'autogenerated:gerrit:setReadyForReview',
+ TAG_SET_WIP = 'autogenerated:gerrit:setWorkInProgress',
+ TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
+ TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
+}
+
+/**
+ * @desc Modes for gr-diff-cursor
+ * The scroll behavior for the cursor. Values are 'never' and
+ * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+ * the viewport.
+ */
+export enum ScrollMode {
+ KEEP_VISIBLE = 'keep-visible',
+ NEVER = 'never',
+}
+
+/**
+ * @desc Specifies status for a change
+ */
+export enum ChangeStatus {
+ ABANDONED = 'ABANDONED',
+ MERGED = 'MERGED',
+ NEW = 'NEW',
+}
+
+/**
+ * @desc Special file paths
+ */
+export enum SpecialFilePath {
+ PATCHSET_LEVEL_COMMENTS = '/PATCHSET_LEVEL',
+ COMMIT_MESSAGE = '/COMMIT_MSG',
+ MERGE_LIST = '/MERGE_LIST',
+}
+
+/**
+ * @desc The reviewer state
+ */
+export enum RequirementStatus {
+ OK = 'OK',
+ NOT_READY = 'NOT_READY',
+ RULE_ERROR = 'RULE_ERROR',
+}
+
+/**
+ * @desc The reviewer state
+ */
+export enum ReviewerState {
+ REVIEWER = 'REVIEWER',
+ CC = 'CC',
+ REMOVED = 'REMOVED',
+}
+
+/**
+ * @desc The patchset kind
+ */
+export enum RevisionKind {
+ REWORK = 'REWORK',
+ TRIVIAL_REBASE = 'TRIVIAL_REBASE',
+ MERGE_FIRST_PARENT_UPDATE = 'MERGE_FIRST_PARENT_UPDATE',
+ NO_CODE_CHANGE = 'NO_CODE_CHANGE',
+ NO_CHANGE = 'NO_CHANGE',
+}
+
+/**
+ * @desc The status of fixing the problem
+ */
+export enum ProblemInfoStatus {
+ FIXED = 'FIXED',
+ FIX_FAILED = 'FIX_FAILED',
+}
+
+/**
+ * @desc The status of the file
+ */
+export enum FileInfoStatus {
+ ADDED = 'A',
+ DELETED = 'D',
+ RENAMED = 'R',
+ COPIED = 'C',
+ REWRITTEN = 'W',
+ // Modifed = 'M', // but API not set it if the file was modified
+ UNMODIFIED = 'U', // Not returned by BE, but added by UI for certain files
+}
+
+/**
+ * @desc The status of the file
+ */
+export enum GpgKeyInfoStatus {
+ BAD = 'BAD',
+ OK = 'OK',
+ TRUSTED = 'TRUSTED',
+}
+
+/**
+ * @desc Used for server config of accounts
+ */
+export enum DefaultDisplayNameConfig {
+ USERNAME = 'USERNAME',
+ FIRST_NAME = 'FIRST_NAME',
+ FULL_NAME = 'FULL_NAME',
+}
+
+/**
+ * @desc The state of the projects
+ */
+export enum ProjectState {
+ ACTIVE = 'ACTIVE',
+ READ_ONLY = 'READ_ONLY',
+ HIDDEN = 'HIDDEN',
+}
+
+export enum Side {
+ LEFT = 'left',
+ RIGHT = 'right',
+}
+
+/**
+ * The type in ConfigParameterInfo entity.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
+ */
+export enum ConfigParameterInfoType {
+ // Should be kept in sync with
+ // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
+ STRING = 'STRING',
+ INT = 'INT',
+ LONG = 'LONG',
+ BOOLEAN = 'BOOLEAN',
+ LIST = 'LIST',
+ ARRAY = 'ARRAY',
+}
+
+/**
+ * All supported submit types.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
+ */
+export enum SubmitType {
+ MERGE_IF_NECESSARY = 'MERGE_IF_NECESSARY',
+ FAST_FORWARD_ONLY = 'FAST_FORWARD_ONLY',
+ REBASE_IF_NECESSARY = 'REBASE_IF_NECESSARY',
+ REBASE_ALWAYS = 'REBASE_ALWAYS',
+ MERGE_ALWAYS = 'MERGE_ALWAYS ',
+ CHERRY_PICK = 'CHERRY_PICK',
+ INHERIT = 'INHERIT',
+}
+
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export enum MergeStrategy {
+ RECURSIVE = 'recursive',
+ RESOLVE = 'resolve',
+ SIMPLE_TWO_WAY_IN_CORE = 'simple-two-way-in-core',
+ OURS = 'ours',
+ THEIRS = 'theirs',
+}
+
+/*
+ * Enum for possible configured value in InheritedBooleanInfo.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
+ */
+export enum InheritedBooleanInfoConfiguredValue {
+ TRUE = 'TRUE',
+ FALSE = 'FALSE',
+ INHERITED = 'INHERITED',
+}
+
+export enum AccountTag {
+ SERVICE_USER = 'SERVICE_USER',
+}
+
+/**
+ * Enum for possible PermissionRuleInfo actions
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#permission-info
+ */
+export enum PermissionAction {
+ ALLOW = 'ALLOW',
+ DENY = 'DENY',
+ BLOCK = 'BLOCK',
+ // Special values for global capabilities
+ INTERACTIVE = 'INTERACTIVE',
+ BATCH = 'BATCH',
+}
+
+/**
+ * This capability allows users to use the thread pool reserved for 'Non-Interactive Users'.
+ * https://gerrit-review.googlesource.com/Documentation/access-control.html#capability_priority
+ */
+export enum UserPriority {
+ BATCH = 'BATCH',
+ INTERACTIVE = 'INTERACTIVE',
+}
+
+/**
+ * Enum for all http methods used in Gerrit.
+ */
+export enum HttpMethod {
+ HEAD = 'HEAD',
+ POST = 'POST',
+ GET = 'GET',
+ DELETE = 'DELETE',
+ PUT = 'PUT',
+}
+
+/**
+ * The side on which the comment was added
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
+ */
+export enum CommentSide {
+ REVISION = 'REVISION',
+ PARENT = 'PARENT',
+}
+
+/**
+ * Allowed app themes
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum AppTheme {
+ DARK = 'DARK',
+ LIGHT = 'LIGHT',
+}
+
+/**
+ * Date formats in preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum DateFormat {
+ STD = 'STD',
+ US = 'US',
+ ISO = 'ISO',
+ EURO = 'EURO',
+ UK = 'UK',
+}
+
+/**
+ * Time formats in preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum TimeFormat {
+ HHMM_12 = 'HHMM_12',
+ HHMM_24 = 'HHMM_24',
+}
+
+/**
+ * Diff type in preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum DiffViewMode {
+ SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+ UNIFIED = 'UNIFIED_DIFF',
+}
+
+/**
+ * The type of email strategy to use.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum EmailStrategy {
+ ENABLED = 'ENABLED',
+ CC_ON_OWN_COMMENTS = 'CC_ON_OWN_COMMENTS',
+ ATTENTION_SET_ONLY = 'ATTENTION_SET_ONLY',
+ DISABLED = 'DISABLED',
+}
+
+/**
+ * The type of email format to use.
+ * Doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo.
+ */
+
+export enum EmailFormat {
+ PLAINTEXT = 'PLAINTEXT',
+ HTML_PLAINTEXT = 'HTML_PLAINTEXT',
+}
+
+/**
+ * The base which should be pre-selected in the 'Diff Against' drop-down list when the change screen is opened for a merge commit
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ */
+export enum DefaultBase {
+ AUTO_MERGE = 'AUTO_MERGE',
+ FIRST_PARENT = 'FIRST_PARENT',
+}
+
+/**
+ * Whether whitespace changes should be ignored and if yes, which whitespace changes should be ignored
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-input
+ */
+export enum IgnoreWhitespaceType {
+ IGNORE_NONE = 'IGNORE_NONE',
+ IGNORE_TRAILING = 'IGNORE_TRAILING',
+ IGNORE_LEADING_AND_TRAILING = 'IGNORE_LEADING_AND_TRAILING',
+ IGNORE_ALL = 'IGNORE_ALL',
+}
+
+/**
+ * how draft comments are handled
+ */
+export enum DraftsAction {
+ PUBLISH = 'PUBLISH',
+ PUBLISH_ALL_REVISIONS = 'PUBLISH_ALL_REVISIONS',
+ KEEP = 'KEEP',
+}
+
+export enum NotifyType {
+ NONE = 'NONE',
+ OWNER = 'OWNER',
+ OWNER_REVIEWERS = 'OWNER_REVIEWERS',
+ ALL = 'ALL',
+}
+
+/**
+ * The authentication type that is configured on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum AuthType {
+ OPENID = 'OPENID',
+ OPENID_SSO = 'OPENID_SSO',
+ OAUTH = 'OAUTH',
+ HTTP = 'HTTP',
+ HTTP_LDAP = 'HTTP_LDAP',
+ CLIENT_SSL_CERT_LDAP = 'CLIENT_SSL_CERT_LDAP',
+ LDAP = 'LDAP',
+ LDAP_BIND = 'LDAP_BIND',
+ CUSTOM_EXTENSION = 'CUSTOM_EXTENSION',
+ DEVELOPMENT_BECOME_ANY_ACCOUNT = 'DEVELOPMENT_BECOME_ANY_ACCOUNT',
+}
+
+/**
+ * Controls visibility of other users' dashboard pages and completion suggestions to web users
+ * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#accounts.visibility
+ */
+export enum AccountsVisibility {
+ ALL = 'ALL',
+ SAME_GROUP = 'SAME_GROUP',
+ VISIBLE_GROUP = 'VISIBLE_GROUP',
+ NONE = 'NONE',
+}
+
+/**
+ * Account fields that are editable
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum EditableAccountField {
+ FULL_NAME = 'FULL_NAME',
+ USER_NAME = 'USER_NAME',
+ REGISTER_NEW_EMAIL = 'REGISTER_NEW_EMAIL',
+}
+
+/**
+ * This setting determines when Gerrit computes if a change is mergeable or not.
+ * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#change.mergeabilityComputationBehavior
+ */
+export enum MergeabilityComputationBehavior {
+ API_REF_UPDATED_AND_CHANGE_REINDEX = 'API_REF_UPDATED_AND_CHANGE_REINDEX',
+ REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
+ NEVER = 'NEVER',
+}
diff --git a/polygerrit-ui/app/constants/messages.js b/polygerrit-ui/app/constants/messages.js
deleted file mode 100644
index 8562cd9..0000000
--- a/polygerrit-ui/app/constants/messages.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-/** @desc Default message shown when no threads in gr-thread-list */
-export const NO_THREADS_MSG =
- 'There are no inline comment threads on any diff for this change.';
-
-/** @desc Message shown when no threads in gr-thread-list for robot comments */
-export const NO_ROBOT_COMMENTS_THREADS_MSG =
- 'There are no findings for this patchset.';
-
diff --git a/polygerrit-ui/app/constants/messages.ts b/polygerrit-ui/app/constants/messages.ts
new file mode 100644
index 0000000..5b4a534
--- /dev/null
+++ b/polygerrit-ui/app/constants/messages.ts
@@ -0,0 +1,20 @@
+/**
+ * @license
+ * 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.
+ */
+
+/** @desc Message shown when no threads in gr-thread-list for robot comments */
+export const NO_ROBOT_COMMENTS_THREADS_MSG =
+ 'There are no findings for this patchset.';
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
deleted file mode 100644
index d1255d2..0000000
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
+++ /dev/null
@@ -1,307 +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-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-permission/gr-permission.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {htmlTemplate} from './gr-access-section_html.js';
-import {AccessPermissions, toSortedPermissionsArray} from '../../../utils/access-util.js';
-
-/**
- * Fired when the section has been modified or removed.
- *
- * @event access-modified
- */
-
-/**
- * Fired when a section that was previously added was removed.
- *
- * @event added-section-removed
- */
-
-const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
-
-// The name that gets automatically input when a new reference is added.
-const NEW_NAME = 'refs/heads/*';
-const REFS_NAME = 'refs/';
-const ON_BEHALF_OF = '(On Behalf Of)';
-const LABEL = 'Label';
-
-/**
- * @extends PolymerElement
- */
-class GrAccessSection extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-access-section'; }
-
- static get properties() {
- return {
- capabilities: Object,
- /** @type {?} */
- section: {
- type: Object,
- notify: true,
- observer: '_updateSection',
- },
- groups: Object,
- labels: Object,
- editing: {
- type: Boolean,
- value: false,
- observer: '_handleEditingChanged',
- },
- canUpload: Boolean,
- ownerOf: Array,
- _originalId: String,
- _editingRef: {
- type: Boolean,
- value: false,
- },
- _deleted: {
- type: Boolean,
- value: false,
- },
- _permissions: Array,
- };
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('access-saved',
- () => this._handleAccessSaved());
- }
-
- _updateSection(section) {
- this._permissions = toSortedPermissionsArray(section.value.permissions);
- this._originalId = section.id;
- }
-
- _handleAccessSaved() {
- // Set a new 'original' value to keep track of after the value has been
- // saved.
- this._updateSection(this.section);
- }
-
- _handleValueChange() {
- if (!this.section.value.added) {
- this.section.value.modified = this.section.id !== this._originalId;
- // Allows overall access page to know a change has been made.
- // For a new section, this is not fired because new permissions and
- // rules have to be added in order to save, modifying the ref is not
- // enough.
- this.dispatchEvent(new CustomEvent(
- 'access-modified', {bubbles: true, composed: true}));
- }
- this.section.value.updatedId = this.section.id;
- }
-
- _handleEditingChanged(editing, editingOld) {
- // Ignore when editing gets set initially.
- if (!editingOld) { return; }
- // Restore original values if no longer editing.
- if (!editing) {
- this._editingRef = false;
- this._deleted = false;
- delete this.section.value.deleted;
- // Restore section ref.
- this.set(['section', 'id'], this._originalId);
- // Remove any unsaved but added permissions.
- this._permissions = this._permissions.filter(p => !p.value.added);
- for (const key of Object.keys(this.section.value.permissions)) {
- if (this.section.value.permissions[key].added) {
- delete this.section.value.permissions[key];
- }
- }
- }
- }
-
- _computePermissions(name, capabilities, labels) {
- let allPermissions;
- if (!this.section || !this.section.value) {
- return [];
- }
- if (name === GLOBAL_NAME) {
- allPermissions = toSortedPermissionsArray(capabilities);
- } else {
- const labelOptions = this._computeLabelOptions(labels);
- allPermissions = labelOptions.concat(
- toSortedPermissionsArray(AccessPermissions));
- }
- return allPermissions
- .filter(permission => !this.section.value.permissions[permission.id]);
- }
-
- _computeHideEditClass(section) {
- return section.id === 'GLOBAL_CAPABILITIES' ? 'hide' : '';
- }
-
- _handleAddedPermissionRemoved(e) {
- const index = e.model.index;
- this._permissions = this._permissions.slice(0, index).concat(
- this._permissions.slice(index + 1, this._permissions.length));
- }
-
- _computeLabelOptions(labels) {
- const labelOptions = [];
- if (!labels) { return []; }
- for (const labelName of Object.keys(labels)) {
- labelOptions.push({
- id: 'label-' + labelName,
- value: {
- name: `${LABEL} ${labelName}`,
- id: 'label-' + labelName,
- },
- });
- labelOptions.push({
- id: 'labelAs-' + labelName,
- value: {
- name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
- id: 'labelAs-' + labelName,
- },
- });
- }
- return labelOptions;
- }
-
- _computePermissionName(name, permission, capabilities) {
- if (name === GLOBAL_NAME) {
- return capabilities[permission.id].name;
- } else if (AccessPermissions[permission.id]) {
- return AccessPermissions[permission.id].name;
- } else if (permission.value.label) {
- let behalfOf = '';
- if (permission.id.startsWith('labelAs-')) {
- behalfOf = ON_BEHALF_OF;
- }
- return `${LABEL} ${permission.value.label}${behalfOf}`;
- }
- }
-
- _computeSectionName(name) {
- // When a new section is created, it doesn't yet have a ref. Set into
- // edit mode so that the user can input one.
- if (!name) {
- this._editingRef = true;
- // Needed for the title value. This is the same default as GWT.
- name = NEW_NAME;
- // Needed for the input field value.
- this.set('section.id', name);
- }
- if (name === GLOBAL_NAME) {
- return 'Global Capabilities';
- } else if (name.startsWith(REFS_NAME)) {
- return `Reference: ${name}`;
- }
- return name;
- }
-
- _handleRemoveReference() {
- if (this.section.value.added) {
- this.dispatchEvent(new CustomEvent(
- 'added-section-removed', {bubbles: true, composed: true}));
- }
- this._deleted = true;
- this.section.value.deleted = true;
- this.dispatchEvent(
- new CustomEvent('access-modified', {bubbles: true, composed: true}));
- }
-
- _handleUndoRemove() {
- this._deleted = false;
- delete this.section.value.deleted;
- }
-
- editRefInput() {
- return dom(this.root).querySelector(PolymerElement ?
- 'iron-input.editRefInput' :
- 'input[is=iron-input].editRefInput');
- }
-
- editReference() {
- this._editingRef = true;
- this.editRefInput().focus();
- }
-
- _isEditEnabled(canUpload, ownerOf, sectionId) {
- return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
- }
-
- _computeSectionClass(editing, canUpload, ownerOf, editingRef, deleted) {
- const classList = [];
- if (editing
- && this._isEditEnabled(canUpload, ownerOf, this.section.id)) {
- classList.push('editing');
- }
- if (editingRef) {
- classList.push('editingRef');
- }
- if (deleted) {
- classList.push('deleted');
- }
- return classList.join(' ');
- }
-
- _computeEditBtnClass(name) {
- return name === GLOBAL_NAME ? 'global' : '';
- }
-
- _handleAddPermission() {
- const value = this.$.permissionSelect.value;
- const permission = {
- id: value,
- value: {rules: {}, added: true},
- };
-
- // This is needed to update the 'label' property of the
- // 'label-<label-name>' permission.
- //
- // The value from the add permission dropdown will either be
- // label-<label-name> or labelAs-<labelName>.
- // But, the format of the API response is as such:
- // "permissions": {
- // "label-Code-Review": {
- // "label": "Code-Review",
- // "rules": {...}
- // }
- // }
- // }
- // When we add a new item, we have to push the new permission in the same
- // format as the ones that have been returned by the API.
- if (value.startsWith('label')) {
- permission.value.label =
- value.replace('label-', '').replace('labelAs-', '');
- }
- // Add to the end of the array (used in dom-repeat) and also to the
- // section object that is two way bound with its parent element.
- this.push('_permissions', permission);
- this.set(['section.value.permissions', permission.id],
- permission.value);
- }
-}
-
-customElements.define(GrAccessSection.is, GrAccessSection);
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
new file mode 100644
index 0000000..3c155f85
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.ts
@@ -0,0 +1,388 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-permission/gr-permission';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {htmlTemplate} from './gr-access-section_html';
+import {
+ AccessPermissions,
+ PermissionArray,
+ PermissionArrayItem,
+ toSortedPermissionsArray,
+} from '../../../utils/access-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+ EditablePermissionInfo,
+ PermissionAccessSection,
+ EditableProjectAccessGroups,
+} from '../gr-repo-access/gr-repo-access-interfaces';
+import {
+ CapabilityInfoMap,
+ GitRef,
+ LabelNameToLabelTypeInfoMap,
+} from '../../../types/common';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+
+/**
+ * Fired when the section has been modified or removed.
+ *
+ * @event access-modified
+ */
+
+/**
+ * Fired when a section that was previously added was removed.
+ *
+ * @event added-section-removed
+ */
+
+const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
+
+// The name that gets automatically input when a new reference is added.
+const NEW_NAME = 'refs/heads/*';
+const REFS_NAME = 'refs/';
+const ON_BEHALF_OF = '(On Behalf Of)';
+const LABEL = 'Label';
+
+export interface GrAccessSection {
+ $: {
+ permissionSelect: HTMLSelectElement;
+ };
+}
+
+@customElement('gr-access-section')
+export class GrAccessSection extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ capabilities?: CapabilityInfoMap;
+
+ @property({type: Object, notify: true, observer: '_updateSection'})
+ section?: PermissionAccessSection;
+
+ @property({type: Object})
+ groups?: EditableProjectAccessGroups;
+
+ @property({type: Object})
+ labels?: LabelNameToLabelTypeInfoMap;
+
+ @property({type: Boolean, observer: '_handleEditingChanged'})
+ editing = false;
+
+ @property({type: Boolean})
+ canUpload?: boolean;
+
+ @property({type: Array})
+ ownerOf?: GitRef[];
+
+ @property({type: String})
+ _originalId?: GitRef;
+
+ @property({type: Boolean})
+ _editingRef = false;
+
+ @property({type: Boolean})
+ _deleted = false;
+
+ @property({type: Array})
+ _permissions?: PermissionArray<EditablePermissionInfo>;
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('access-saved', () => this._handleAccessSaved());
+ }
+
+ _updateSection(section: PermissionAccessSection) {
+ this._permissions = toSortedPermissionsArray(section.value.permissions);
+ this._originalId = section.id as GitRef;
+ }
+
+ _handleAccessSaved() {
+ if (!this.section) {
+ return;
+ }
+ // Set a new 'original' value to keep track of after the value has been
+ // saved.
+ this._updateSection(this.section);
+ }
+
+ _handleValueChange() {
+ if (!this.section) {
+ return;
+ }
+ if (!this.section.value.added) {
+ this.section.value.modified = this.section.id !== this._originalId;
+ // Allows overall access page to know a change has been made.
+ // For a new section, this is not fired because new permissions and
+ // rules have to be added in order to save, modifying the ref is not
+ // enough.
+ this.dispatchEvent(
+ new CustomEvent('access-modified', {bubbles: true, composed: true})
+ );
+ }
+ this.section.value.updatedId = this.section.id;
+ }
+
+ _handleEditingChanged(editing: boolean, editingOld: boolean) {
+ // Ignore when editing gets set initially.
+ if (!editingOld) {
+ return;
+ }
+ if (!this.section || !this._permissions) {
+ return;
+ }
+ // Restore original values if no longer editing.
+ if (!editing) {
+ this._editingRef = false;
+ this._deleted = false;
+ delete this.section.value.deleted;
+ // Restore section ref.
+ this.set(['section', 'id'], this._originalId);
+ // Remove any unsaved but added permissions.
+ this._permissions = this._permissions.filter(p => !p.value.added);
+ for (const key of Object.keys(this.section.value.permissions)) {
+ if (this.section.value.permissions[key].added) {
+ delete this.section.value.permissions[key];
+ }
+ }
+ }
+ }
+
+ _computePermissions(
+ name: string,
+ capabilities?: CapabilityInfoMap,
+ labels?: LabelNameToLabelTypeInfoMap
+ ) {
+ let allPermissions;
+ const section = this.section;
+ if (!section || !section.value) {
+ return [];
+ }
+ if (name === GLOBAL_NAME) {
+ allPermissions = toSortedPermissionsArray(capabilities);
+ } else {
+ const labelOptions = this._computeLabelOptions(labels);
+ allPermissions = labelOptions.concat(
+ toSortedPermissionsArray(AccessPermissions)
+ );
+ }
+ return allPermissions.filter(
+ permission => !section.value.permissions[permission.id]
+ );
+ }
+
+ _computeHideEditClass(section: PermissionAccessSection) {
+ return section.id === 'GLOBAL_CAPABILITIES' ? 'hide' : '';
+ }
+
+ _handleAddedPermissionRemoved(e: PolymerDomRepeatEvent) {
+ if (!this._permissions) {
+ return;
+ }
+ const index = e.model.index;
+ this._permissions = this._permissions
+ .slice(0, index)
+ .concat(this._permissions.slice(index + 1, this._permissions.length));
+ }
+
+ _computeLabelOptions(labels?: LabelNameToLabelTypeInfoMap) {
+ const labelOptions = [];
+ if (!labels) {
+ return [];
+ }
+ for (const labelName of Object.keys(labels)) {
+ labelOptions.push({
+ id: 'label-' + labelName,
+ value: {
+ name: `${LABEL} ${labelName}`,
+ id: 'label-' + labelName,
+ },
+ });
+ labelOptions.push({
+ id: 'labelAs-' + labelName,
+ value: {
+ name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
+ id: 'labelAs-' + labelName,
+ },
+ });
+ }
+ return labelOptions;
+ }
+
+ _computePermissionName(
+ name: string,
+ permission: PermissionArrayItem<EditablePermissionInfo>,
+ capabilities: CapabilityInfoMap
+ ) {
+ if (name === GLOBAL_NAME) {
+ return capabilities[permission.id].name;
+ } else if (AccessPermissions[permission.id]) {
+ return AccessPermissions[permission.id].name;
+ } else if (permission.value.label) {
+ let behalfOf = '';
+ if (permission.id.startsWith('labelAs-')) {
+ behalfOf = ON_BEHALF_OF;
+ }
+ return `${LABEL} ${permission.value.label}${behalfOf}`;
+ }
+ return undefined;
+ }
+
+ _computeSectionName(name: string) {
+ // When a new section is created, it doesn't yet have a ref. Set into
+ // edit mode so that the user can input one.
+ if (!name) {
+ this._editingRef = true;
+ // Needed for the title value. This is the same default as GWT.
+ name = NEW_NAME;
+ // Needed for the input field value.
+ this.set('section.id', name);
+ }
+ if (name === GLOBAL_NAME) {
+ return 'Global Capabilities';
+ } else if (name.startsWith(REFS_NAME)) {
+ return `Reference: ${name}`;
+ }
+ return name;
+ }
+
+ _handleRemoveReference() {
+ if (!this.section) {
+ return;
+ }
+ if (this.section.value.added) {
+ this.dispatchEvent(
+ new CustomEvent('added-section-removed', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ this._deleted = true;
+ this.section.value.deleted = true;
+ this.dispatchEvent(
+ new CustomEvent('access-modified', {bubbles: true, composed: true})
+ );
+ }
+
+ _handleUndoRemove() {
+ if (!this.section) {
+ return;
+ }
+ this._deleted = false;
+ delete this.section.value.deleted;
+ }
+
+ editRefInput() {
+ return this.root!.querySelector(
+ PolymerElement
+ ? 'iron-input.editRefInput'
+ : 'input[is=iron-input].editRefInput'
+ ) as HTMLInputElement;
+ }
+
+ editReference() {
+ this._editingRef = true;
+ this.editRefInput().focus();
+ }
+
+ _isEditEnabled(
+ canUpload: boolean | undefined,
+ ownerOf: GitRef[] | undefined,
+ sectionId: GitRef
+ ) {
+ return canUpload || (ownerOf && ownerOf.indexOf(sectionId) >= 0);
+ }
+
+ _computeSectionClass(
+ editing: boolean,
+ canUpload: boolean | undefined,
+ ownerOf: GitRef[] | undefined,
+ editingRef: boolean,
+ deleted: boolean
+ ) {
+ const classList = [];
+ if (
+ editing &&
+ this.section &&
+ this._isEditEnabled(canUpload, ownerOf, this.section.id as GitRef)
+ ) {
+ classList.push('editing');
+ }
+ if (editingRef) {
+ classList.push('editingRef');
+ }
+ if (deleted) {
+ classList.push('deleted');
+ }
+ return classList.join(' ');
+ }
+
+ _computeEditBtnClass(name: string) {
+ return name === GLOBAL_NAME ? 'global' : '';
+ }
+
+ _handleAddPermission() {
+ const value = this.$.permissionSelect.value;
+ const permission: PermissionArrayItem<EditablePermissionInfo> = {
+ id: value,
+ value: {rules: {}, added: true},
+ };
+
+ // This is needed to update the 'label' property of the
+ // 'label-<label-name>' permission.
+ //
+ // The value from the add permission dropdown will either be
+ // label-<label-name> or labelAs-<labelName>.
+ // But, the format of the API response is as such:
+ // "permissions": {
+ // "label-Code-Review": {
+ // "label": "Code-Review",
+ // "rules": {...}
+ // }
+ // }
+ // }
+ // When we add a new item, we have to push the new permission in the same
+ // format as the ones that have been returned by the API.
+ if (value.startsWith('label')) {
+ permission.value.label = value
+ .replace('label-', '')
+ .replace('labelAs-', '');
+ }
+ // Add to the end of the array (used in dom-repeat) and also to the
+ // section object that is two way bound with its parent element.
+ this.push('_permissions', permission);
+ this.set(['section.value.permissions', permission.id], permission.value);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-access-section': GrAccessSection;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
index 8749158..97a7fa3 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
@@ -71,7 +71,7 @@
},
};
element._updateSection(element.section);
- flushAsynchronousOperations();
+ flush();
});
test('_updateSection', () => {
@@ -327,7 +327,7 @@
},
};
element._updateSection(element.section);
- flushAsynchronousOperations();
+ flush();
});
test('classes are assigned correctly', () => {
@@ -355,7 +355,7 @@
};
element.capabilities = {};
element._updateSection(element.section);
- flushAsynchronousOperations();
+ flush();
});
test('classes are assigned correctly', () => {
@@ -365,7 +365,7 @@
element.editing = true;
element.canUpload = true;
element.ownerOf = [];
- flushAsynchronousOperations();
+ flush();
assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
});
@@ -376,7 +376,7 @@
assert.equal(Object.keys(element.section.value.permissions).length,
1);
MockInteractions.tap(element.$.addBtn);
- flushAsynchronousOperations();
+ flush();
// The permission is added to both the permissions array and also
// the section's permission object.
@@ -399,7 +399,7 @@
element.$.permissionSelect.value = 'abandon';
MockInteractions.tap(element.$.addBtn);
- flushAsynchronousOperations();
+ flush();
permission = {
id: 'abandon',
@@ -479,14 +479,14 @@
assert.isFalse(element._deleted);
assert.isNotOk(element.section.value.deleted);
MockInteractions.tap(element.$.deleteBtn);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element._deleted);
assert.isTrue(element.section.value.deleted);
assert.isTrue(element.$.section.classList.contains('deleted'));
assert.isTrue(element.section.value.deleted);
MockInteractions.tap(element.$.undoRemoveBtn);
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element._deleted);
assert.isNotOk(element.section.value.deleted);
@@ -506,7 +506,7 @@
new CustomEvent('added-permission-removed', {
composed: true, bubbles: true,
}));
- flushAsynchronousOperations();
+ flush();
assert.equal(element._permissions.length, 0);
});
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
deleted file mode 100644
index fd1f492..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
+++ /dev/null
@@ -1,185 +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-dialog/gr-dialog.js';
-import '../../shared/gr-list-view/gr-list-view.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-group-dialog/gr-create-group-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-admin-group-list_html.js';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/**
- * @appliesMixin ListViewMixin
- * @extends PolymerElement
- */
-class GrAdminGroupList extends ListViewMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-admin-group-list'; }
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
-
- /**
- * Offset of currently visible query results.
- */
- _offset: Number,
- _path: {
- type: String,
- readOnly: true,
- value: '/admin/groups',
- },
- _hasNewGroupName: Boolean,
- _createNewCapability: {
- type: Boolean,
- value: false,
- },
- _groups: Array,
-
- /**
- * Because we request one more than the groupsPerPage, _shownGroups
- * may be one less than _groups.
- * */
- _shownGroups: {
- type: Array,
- computed: 'computeShownItems(_groups)',
- },
-
- _groupsPerPage: {
- type: Number,
- value: 25,
- },
-
- _loading: {
- type: Boolean,
- value: true,
- },
- _filter: String,
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getCreateGroupCapability();
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: 'Groups'},
- composed: true, bubbles: true,
- }));
- this._maybeOpenCreateOverlay(this.params);
- }
-
- _paramsChanged(params) {
- this._loading = true;
- this._filter = this.getFilterValue(params);
- this._offset = this.getOffsetValue(params);
-
- return this._getGroups(this._filter, this._groupsPerPage,
- this._offset);
- }
-
- /**
- * Opens the create overlay if the route has a hash 'create'
- *
- * @param {!Object} params
- */
- _maybeOpenCreateOverlay(params) {
- if (params && params.openCreateModal) {
- this.$.createOverlay.open();
- }
- }
-
- /**
- * Generates groups link (/admin/groups/<uuid>)
- *
- * @param {string} id
- */
- _computeGroupUrl(id) {
- return GerritNav.getUrlForGroup(decodeURIComponent(id));
- }
-
- _getCreateGroupCapability() {
- return this.$.restAPI.getAccount().then(account => {
- if (!account) { return; }
- return this.$.restAPI.getAccountCapabilities(['createGroup'])
- .then(capabilities => {
- if (capabilities.createGroup) {
- this._createNewCapability = true;
- }
- });
- });
- }
-
- _getGroups(filter, groupsPerPage, offset) {
- this._groups = [];
- return this.$.restAPI.getGroups(filter, groupsPerPage, offset)
- .then(groups => {
- if (!groups) {
- return;
- }
- this._groups = Object.keys(groups)
- .map(key => {
- const group = groups[key];
- group.name = key;
- return group;
- });
- this._loading = false;
- });
- }
-
- _refreshGroupsList() {
- this.$.restAPI.invalidateGroupsCache();
- return this._getGroups(this._filter, this._groupsPerPage,
- this._offset);
- }
-
- _handleCreateGroup() {
- this.$.createNewModal.handleCreateGroup().then(() => {
- this._refreshGroupsList();
- });
- }
-
- _handleCloseCreate() {
- this.$.createOverlay.close();
- }
-
- _handleCreateClicked() {
- this.$.createOverlay.open();
- }
-
- _visibleToAll(item) {
- return item.options.visible_to_all === true ? 'Y' : 'N';
- }
-}
-
-customElements.define(GrAdminGroupList.is, GrAdminGroupList);
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
new file mode 100644
index 0000000..9d40e28
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.ts
@@ -0,0 +1,192 @@
+/**
+ * @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-dialog/gr-dialog';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-group-dialog/gr-create-group-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-admin-group-list_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe, computed} from '@polymer/decorators';
+import {AppElementAdminParams} from '../../gr-app-types';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-admin-group-list': GrAdminGroupList;
+ }
+}
+
+export interface GrAdminGroupList {
+ $: {
+ createOverlay: GrOverlay;
+ createNewModal: GrCreateGroupDialog;
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-admin-group-list')
+export class GrAdminGroupList extends ListViewMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ params?: AppElementAdminParams;
+
+ /**
+ * Offset of currently visible query results.
+ */
+ @property({type: Number})
+ _offset?: number;
+
+ @property({type: String})
+ readonly _path = '/admin/groups';
+
+ @property({type: Boolean})
+ _hasNewGroupName?: boolean;
+
+ @property({type: Boolean})
+ _createNewCapability = false;
+
+ @property({type: Array})
+ _groups: GroupInfo[] = [];
+
+ /**
+ * Because we request one more than the groupsPerPage, _shownGroups
+ * may be one less than _groups.
+ * */
+ @computed('_groups')
+ get _shownGroups() {
+ return this.computeShownItems(this._groups);
+ }
+
+ @property({type: Number})
+ _groupsPerPage = 25;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _filter = '';
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getCreateGroupCapability();
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: 'Groups'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._maybeOpenCreateOverlay(this.params);
+ }
+
+ @observe('params')
+ _paramsChanged(params: AppElementAdminParams) {
+ this._loading = true;
+ this._filter = this.getFilterValue(params);
+ this._offset = this.getOffsetValue(params);
+
+ return this._getGroups(this._filter, this._groupsPerPage, this._offset);
+ }
+
+ /**
+ * Opens the create overlay if the route has a hash 'create'
+ */
+ _maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+ if (params?.openCreateModal) {
+ this.$.createOverlay.open();
+ }
+ }
+
+ /**
+ * Generates groups link (/admin/groups/<uuid>)
+ */
+ _computeGroupUrl(id: string) {
+ return GerritNav.getUrlForGroup(decodeURIComponent(id) as GroupId);
+ }
+
+ _getCreateGroupCapability() {
+ return this.$.restAPI.getAccount().then(account => {
+ if (!account) {
+ return;
+ }
+ return this.$.restAPI
+ .getAccountCapabilities(['createGroup'])
+ .then(capabilities => {
+ if (capabilities?.createGroup) {
+ this._createNewCapability = true;
+ }
+ });
+ });
+ }
+
+ _getGroups(filter: string, groupsPerPage: number, offset?: number) {
+ this._groups = [];
+ return this.$.restAPI
+ .getGroups(filter, groupsPerPage, offset)
+ .then(groups => {
+ if (!groups) {
+ return;
+ }
+ this._groups = Object.keys(groups).map(key => {
+ const group = groups[key];
+ group.name = key as GroupName;
+ return group;
+ });
+ this._loading = false;
+ });
+ }
+
+ _refreshGroupsList() {
+ this.$.restAPI.invalidateGroupsCache();
+ return this._getGroups(this._filter, this._groupsPerPage, this._offset);
+ }
+
+ _handleCreateGroup() {
+ this.$.createNewModal.handleCreateGroup().then(() => {
+ this._refreshGroupsList();
+ });
+ }
+
+ _handleCloseCreate() {
+ this.$.createOverlay.close();
+ }
+
+ _handleCreateClicked() {
+ this.$.createOverlay.open();
+ }
+
+ _visibleToAll(item: GroupInfo) {
+ return item.options?.visible_to_all === true ? 'Y' : 'N';
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
index e22dc66..93de8b4 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_html.ts
@@ -73,7 +73,6 @@
<div class="main" slot="main">
<gr-create-group-dialog
has-new-group-name="{{_hasNewGroupName}}"
- params="[[params]]"
id="createNewModal"
></gr-create-group-dialog>
</div>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
index 546b8a8..93d41c3 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.js
@@ -18,6 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-admin-group-list.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import 'lodash/lodash.js';
const basicFixture = fixtureFromElement('gr-admin-group-list');
@@ -154,7 +155,7 @@
element._loading = false;
element._groups = _.times(25, groupGenerator);
- flushAsynchronousOperations();
+ flush();
assert.equal(element.computeLoadingClass(element._loading), '');
assert.equal(getComputedStyle(element.$.loading).display, 'none');
});
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
deleted file mode 100644
index e555583..0000000
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ /dev/null
@@ -1,313 +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-menu-page-styles.js';
-import '../../../styles/gr-page-nav-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-page-nav/gr-page-nav.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-admin-group-list/gr-admin-group-list.js';
-import '../gr-group/gr-group.js';
-import '../gr-group-audit-log/gr-group-audit-log.js';
-import '../gr-group-members/gr-group-members.js';
-import '../gr-plugin-list/gr-plugin-list.js';
-import '../gr-repo/gr-repo.js';
-import '../gr-repo-access/gr-repo-access.js';
-import '../gr-repo-commands/gr-repo-commands.js';
-import '../gr-repo-dashboards/gr-repo-dashboards.js';
-import '../gr-repo-detail-list/gr-repo-detail-list.js';
-import '../gr-repo-list/gr-repo-list.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-admin-view_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getAdminLinks} from '../../../utils/admin-nav-util.js';
-
-const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
-
-/**
- * @extends PolymerElement
- */
-class GrAdminView extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-admin-view'; }
-
- static get properties() {
- return {
- /** @type {?} */
- params: Object,
- path: String,
- adminView: String,
-
- _breadcrumbParentName: String,
- _repoName: String,
- _groupId: {
- type: Number,
- observer: '_computeGroupName',
- },
- _groupIsInternal: Boolean,
- _groupName: String,
- _groupOwner: {
- type: Boolean,
- value: false,
- },
- _subsectionLinks: Array,
- _filteredLinks: Array,
- _showDownload: {
- type: Boolean,
- value: false,
- },
- _isAdmin: {
- type: Boolean,
- value: false,
- },
- _showGroup: Boolean,
- _showGroupAuditLog: Boolean,
- _showGroupList: Boolean,
- _showGroupMembers: Boolean,
- _showRepoAccess: Boolean,
- _showRepoCommands: Boolean,
- _showRepoDashboards: Boolean,
- _showRepoDetailList: Boolean,
- _showRepoMain: Boolean,
- _showRepoList: Boolean,
- _showPluginList: Boolean,
- };
- }
-
- static get observers() {
- return [
- '_paramsChanged(params)',
- ];
- }
-
- /** @override */
- attached() {
- super.attached();
- this.reload();
- }
-
- reload() {
- const promises = [
- this.$.restAPI.getAccount(),
- pluginLoader.awaitPluginsLoaded(),
- ];
- return Promise.all(promises).then(result => {
- this._account = result[0];
- let options;
- if (this._repoName) {
- options = {repoName: this._repoName};
- } else if (this._groupId) {
- options = {
- groupId: this._groupId,
- groupName: this._groupName,
- groupIsInternal: this._groupIsInternal,
- isAdmin: this._isAdmin,
- groupOwner: this._groupOwner,
- };
- }
-
- return getAdminLinks(this._account,
- this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
- this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI),
- options)
- .then(res => {
- this._filteredLinks = res.links;
- this._breadcrumbParentName = res.expandedSection ?
- res.expandedSection.name : '';
-
- if (!res.expandedSection) {
- this._subsectionLinks = [];
- return;
- }
- this._subsectionLinks = [res.expandedSection]
- .concat(res.expandedSection.children).map(section => {
- return {
- text: !section.detailType ? 'Home' : section.name,
- value: section.view + (section.detailType || ''),
- view: section.view,
- url: section.url,
- detailType: section.detailType,
- parent: this._groupId || this._repoName || '',
- };
- });
- });
- });
- }
-
- _computeSelectValue(params) {
- if (!params || !params.view) { return; }
- return params.view + (params.detail || '');
- }
-
- _selectedIsCurrentPage(selected) {
- return (selected.parent === (this._repoName || this._groupId) &&
- selected.view === this.params.view &&
- selected.detailType === this.params.detail);
- }
-
- _handleSubsectionChange(e) {
- const selected = this._subsectionLinks
- .find(section => section.value === e.detail.value);
-
- // This is when it gets set initially.
- if (this._selectedIsCurrentPage(selected)) {
- return;
- }
- GerritNav.navigateToRelativeUrl(selected.url);
- }
-
- _paramsChanged(params) {
- const isGroupView = params.view === GerritNav.View.GROUP;
- const isRepoView = params.view === GerritNav.View.REPO;
- const isAdminView = params.view === GerritNav.View.ADMIN;
-
- this.set('_showGroup', isGroupView && !params.detail);
- this.set('_showGroupAuditLog', isGroupView &&
- params.detail === GerritNav.GroupDetailView.LOG);
- this.set('_showGroupMembers', isGroupView &&
- params.detail === GerritNav.GroupDetailView.MEMBERS);
-
- this.set('_showGroupList', isAdminView &&
- params.adminView === 'gr-admin-group-list');
-
- this.set('_showRepoAccess', isRepoView &&
- params.detail === GerritNav.RepoDetailView.ACCESS);
- this.set('_showRepoCommands', isRepoView &&
- params.detail === GerritNav.RepoDetailView.COMMANDS);
- this.set('_showRepoDetailList', isRepoView &&
- (params.detail === GerritNav.RepoDetailView.BRANCHES ||
- params.detail === GerritNav.RepoDetailView.TAGS));
- this.set('_showRepoDashboards', isRepoView &&
- params.detail === GerritNav.RepoDetailView.DASHBOARDS);
- this.set('_showRepoMain', isRepoView && !params.detail);
-
- this.set('_showRepoList', isAdminView &&
- params.adminView === 'gr-repo-list');
-
- this.set('_showPluginList', isAdminView &&
- params.adminView === 'gr-plugin-list');
-
- let needsReload = false;
- if (params.repo !== this._repoName) {
- this._repoName = params.repo || '';
- // Reloads the admin menu.
- needsReload = true;
- }
- if (params.groupId !== this._groupId) {
- this._groupId = params.groupId || '';
- // Reloads the admin menu.
- needsReload = true;
- }
- if (this._breadcrumbParentName && !params.groupId && !params.repo) {
- needsReload = true;
- }
- if (!needsReload) { return; }
- this.reload();
- }
-
- // TODO (beckysiegel): Update these functions after router abstraction is
- // updated. They are currently copied from gr-dropdown (and should be
- // updated there as well once complete).
- _computeURLHelper(host, path) {
- return '//' + host + getBaseUrl() + path;
- }
-
- _computeRelativeURL(path) {
- const host = window.location.host;
- return this._computeURLHelper(host, path);
- }
-
- _computeLinkURL(link) {
- if (!link || typeof link.url === 'undefined') { return ''; }
- if (link.target || !link.noBaseUrl) {
- return link.url;
- }
- return this._computeRelativeURL(link.url);
- }
-
- /**
- * @param {string} itemView
- * @param {Object} params
- * @param {string=} opt_detailType
- */
- _computeSelectedClass(itemView, params, opt_detailType) {
- if (!params) return '';
- // Group params are structured differently from admin params. Compute
- // selected differently for groups.
- // TODO(wyatta): Simplify this when all routes work like group params.
- if (params.view === GerritNav.View.GROUP &&
- itemView === GerritNav.View.GROUP) {
- if (!params.detail && !opt_detailType) { return 'selected'; }
- if (params.detail === opt_detailType) { return 'selected'; }
- return '';
- }
-
- if (params.view === GerritNav.View.REPO &&
- itemView === GerritNav.View.REPO) {
- if (!params.detail && !opt_detailType) { return 'selected'; }
- if (params.detail === opt_detailType) { return 'selected'; }
- return '';
- }
-
- if (params.detailType && params.detailType !== opt_detailType) {
- return '';
- }
- return itemView === params.adminView ? 'selected' : '';
- }
-
- _computeGroupName(groupId) {
- if (!groupId) { return ''; }
-
- const promises = [];
- this.$.restAPI.getGroupConfig(groupId).then(group => {
- if (!group || !group.name) { return; }
-
- this._groupName = group.name;
- this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
- this.reload();
-
- promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
- this._isAdmin = isAdmin;
- }));
-
- promises.push(this.$.restAPI.getIsGroupOwner(group.name).then(
- isOwner => {
- this._groupOwner = isOwner;
- }));
-
- return Promise.all(promises).then(() => {
- this.reload();
- });
- });
- }
-
- _updateGroupName(e) {
- this._groupName = e.detail.name;
- this.reload();
- }
-}
-
-customElements.define(GrAdminView.is, GrAdminView);
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
new file mode 100644
index 0000000..7fb713f
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -0,0 +1,463 @@
+/**
+ * @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-menu-page-styles';
+import '../../../styles/gr-page-nav-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-page-nav/gr-page-nav';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-admin-group-list/gr-admin-group-list';
+import '../gr-group/gr-group';
+import '../gr-group-audit-log/gr-group-audit-log';
+import '../gr-group-members/gr-group-members';
+import '../gr-plugin-list/gr-plugin-list';
+import '../gr-repo/gr-repo';
+import '../gr-repo-access/gr-repo-access';
+import '../gr-repo-commands/gr-repo-commands';
+import '../gr-repo-dashboards/gr-repo-dashboards';
+import '../gr-repo-detail-list/gr-repo-detail-list';
+import '../gr-repo-list/gr-repo-list';
+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-admin-view_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {
+ GerritNav,
+ GerritView,
+ GroupDetailView,
+ RepoDetailView,
+} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+ AdminNavLinksOption,
+ getAdminLinks,
+ NavLink,
+ SubsectionInterface,
+} from '../../../utils/admin-nav-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ AppElementAdminParams,
+ AppElementGroupParams,
+ AppElementRepoParams,
+} from '../../gr-app-types';
+import {
+ AccountDetailInfo,
+ GroupId,
+ GroupName,
+ RepoName,
+} from '../../../types/common';
+import {GroupNameChangedDetail} from '../gr-group/gr-group';
+import {ValueChangeDetail} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+
+export interface GrAdminView {
+ $: {
+ restAPI: RestApiService & Element;
+ jsAPI: GrJsApiInterface;
+ };
+}
+
+interface AdminSubsectionLink {
+ text: string;
+ value: string;
+ view: GerritView;
+ url: string;
+ detailType?: GroupDetailView | RepoDetailView;
+ parent?: GroupId | RepoName;
+}
+
+// The type is matched to the _showAdminView function from the gr-app-element
+type AdminViewParams =
+ | AppElementAdminParams
+ | AppElementGroupParams
+ | AppElementRepoParams;
+
+function getAdminViewParamsDetail(
+ params: AdminViewParams
+): GroupDetailView | RepoDetailView | undefined {
+ if (params.view !== GerritView.ADMIN) {
+ return params.detail;
+ }
+ return undefined;
+}
+
+@customElement('gr-admin-view')
+export class GrAdminView extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ private _account?: AccountDetailInfo;
+
+ @property({type: Object})
+ params?: AdminViewParams;
+
+ @property({type: String})
+ path?: string;
+
+ @property({type: String})
+ adminView?: string;
+
+ @property({type: String})
+ _breadcrumbParentName?: string;
+
+ @property({type: String})
+ _repoName?: RepoName;
+
+ @property({type: String, observer: '_computeGroupName'})
+ _groupId?: GroupId;
+
+ @property({type: Boolean})
+ _groupIsInternal?: boolean;
+
+ @property({type: String})
+ _groupName?: GroupName;
+
+ @property({type: Boolean})
+ _groupOwner = false;
+
+ @property({type: Array})
+ _subsectionLinks?: AdminSubsectionLink[];
+
+ @property({type: Array})
+ _filteredLinks?: NavLink[];
+
+ @property({type: Boolean})
+ _showDownload = false;
+
+ @property({type: Boolean})
+ _isAdmin = false;
+
+ @property({type: Boolean})
+ _showGroup?: boolean;
+
+ @property({type: Boolean})
+ _showGroupAuditLog?: boolean;
+
+ @property({type: Boolean})
+ _showGroupList?: boolean;
+
+ @property({type: Boolean})
+ _showGroupMembers?: boolean;
+
+ @property({type: Boolean})
+ _showRepoAccess?: boolean;
+
+ @property({type: Boolean})
+ _showRepoCommands?: boolean;
+
+ @property({type: Boolean})
+ _showRepoDashboards?: boolean;
+
+ @property({type: Boolean})
+ _showRepoDetailList?: boolean;
+
+ @property({type: Boolean})
+ _showRepoMain?: boolean;
+
+ @property({type: Boolean})
+ _showRepoList?: boolean;
+
+ @property({type: Boolean})
+ _showPluginList?: boolean;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.reload();
+ }
+
+ reload() {
+ const promises: [Promise<AccountDetailInfo | undefined>, Promise<void>] = [
+ this.$.restAPI.getAccount(),
+ getPluginLoader().awaitPluginsLoaded(),
+ ];
+ return Promise.all(promises).then(result => {
+ this._account = result[0];
+ let options: AdminNavLinksOption | undefined = undefined;
+ if (this._repoName) {
+ options = {repoName: this._repoName};
+ } else if (this._groupId) {
+ options = {
+ groupId: this._groupId,
+ groupName: this._groupName,
+ groupIsInternal: this._groupIsInternal,
+ isAdmin: this._isAdmin,
+ groupOwner: this._groupOwner,
+ };
+ }
+
+ return getAdminLinks(
+ this._account,
+ () =>
+ this.$.restAPI.getAccountCapabilities().then(capabilities => {
+ if (!capabilities) {
+ throw new Error('getAccountCapabilities returns undefined');
+ }
+ return capabilities;
+ }),
+ () => this.$.jsAPI.getAdminMenuLinks(),
+ options
+ ).then(res => {
+ this._filteredLinks = res.links;
+ this._breadcrumbParentName = res.expandedSection
+ ? res.expandedSection.name
+ : '';
+
+ if (!res.expandedSection) {
+ this._subsectionLinks = [];
+ return;
+ }
+ this._subsectionLinks = [res.expandedSection]
+ .concat(res.expandedSection.children ?? [])
+ .map(section => {
+ return {
+ text: !section.detailType ? 'Home' : section.name,
+ value: section.view + (section.detailType ?? ''),
+ view: section.view,
+ url: section.url,
+ detailType: section.detailType,
+ parent: this._groupId ?? this._repoName,
+ };
+ });
+ });
+ });
+ }
+
+ _computeSelectValue(params: AdminViewParams) {
+ if (!params || !params.view) return;
+ return `${params.view}${getAdminViewParamsDetail(params) ?? ''}`;
+ }
+
+ _selectedIsCurrentPage(selected: AdminSubsectionLink) {
+ if (!this.params) return false;
+
+ return (
+ selected.parent === (this._repoName ?? this._groupId) &&
+ selected.view === this.params.view &&
+ selected.detailType === getAdminViewParamsDetail(this.params)
+ );
+ }
+
+ _handleSubsectionChange(e: CustomEvent<ValueChangeDetail>) {
+ if (!this._subsectionLinks) return;
+
+ // The GrDropdownList items are _subsectionLinks, so find(...) always return
+ // an item _subsectionLinks and never returns undefined
+ const selected = this._subsectionLinks.find(
+ section => section.value === e.detail.value
+ )!;
+
+ // This is when it gets set initially.
+ if (this._selectedIsCurrentPage(selected)) return;
+ GerritNav.navigateToRelativeUrl(selected.url);
+ }
+
+ @observe('params')
+ _paramsChanged(params: AdminViewParams) {
+ this.set('_showGroup', params.view === GerritView.GROUP && !params.detail);
+ this.set(
+ '_showGroupAuditLog',
+ params.view === GerritView.GROUP && params.detail === GroupDetailView.LOG
+ );
+ this.set(
+ '_showGroupMembers',
+ params.view === GerritView.GROUP &&
+ params.detail === GroupDetailView.MEMBERS
+ );
+
+ this.set(
+ '_showGroupList',
+ params.view === GerritView.ADMIN &&
+ params.adminView === 'gr-admin-group-list'
+ );
+
+ this.set(
+ '_showRepoAccess',
+ params.view === GerritView.REPO && params.detail === RepoDetailView.ACCESS
+ );
+ this.set(
+ '_showRepoCommands',
+ params.view === GerritView.REPO &&
+ params.detail === RepoDetailView.COMMANDS
+ );
+ this.set(
+ '_showRepoDetailList',
+ params.view === GerritView.REPO &&
+ (params.detail === RepoDetailView.BRANCHES ||
+ params.detail === RepoDetailView.TAGS)
+ );
+ this.set(
+ '_showRepoDashboards',
+ params.view === GerritView.REPO &&
+ params.detail === RepoDetailView.DASHBOARDS
+ );
+ this.set(
+ '_showRepoMain',
+ params.view === GerritView.REPO && !params.detail
+ );
+
+ this.set(
+ '_showRepoList',
+ params.view === GerritView.ADMIN && params.adminView === 'gr-repo-list'
+ );
+
+ this.set(
+ '_showPluginList',
+ params.view === GerritView.ADMIN && params.adminView === 'gr-plugin-list'
+ );
+
+ let needsReload = false;
+ const newRepoName =
+ params.view === GerritView.REPO ? params.repo : undefined;
+ if (newRepoName !== this._repoName) {
+ this._repoName = newRepoName;
+ // Reloads the admin menu.
+ needsReload = true;
+ }
+ const newGroupId =
+ params.view === GerritView.GROUP ? params.groupId : undefined;
+ if (newGroupId !== this._groupId) {
+ this._groupId = newGroupId;
+ // Reloads the admin menu.
+ needsReload = true;
+ }
+ if (
+ this._breadcrumbParentName &&
+ (params.view !== GerritView.GROUP || !params.groupId) &&
+ (params.view !== GerritView.REPO || !params.repo)
+ ) {
+ needsReload = true;
+ }
+ if (!needsReload) {
+ return;
+ }
+ this.reload();
+ }
+
+ // TODO (beckysiegel): Update these functions after router abstraction is
+ // updated. They are currently copied from gr-dropdown (and should be
+ // updated there as well once complete).
+ _computeURLHelper(host: string, path: string) {
+ return '//' + host + getBaseUrl() + path;
+ }
+
+ _computeRelativeURL(path: string) {
+ const host = window.location.host;
+ return this._computeURLHelper(host, path);
+ }
+
+ _computeLinkURL(link: NavLink | SubsectionInterface) {
+ if (!link || typeof link.url === 'undefined') return '';
+
+ if ((link as NavLink).target || !(link as NavLink).noBaseUrl) {
+ return link.url;
+ }
+ return this._computeRelativeURL(link.url);
+ }
+
+ _computeSelectedClass(
+ itemView?: GerritView,
+ params?: AdminViewParams,
+ detailType?: GroupDetailView | RepoDetailView
+ ) {
+ if (!params) return '';
+ // Group params are structured differently from admin params. Compute
+ // selected differently for groups.
+ // TODO(wyatta): Simplify this when all routes work like group params.
+ if (params.view === GerritView.GROUP && itemView === GerritView.GROUP) {
+ if (!params.detail && !detailType) {
+ return 'selected';
+ }
+ if (params.detail === detailType) {
+ return 'selected';
+ }
+ return '';
+ }
+
+ if (params.view === GerritView.REPO && itemView === GerritView.REPO) {
+ if (!params.detail && !detailType) {
+ return 'selected';
+ }
+ if (params.detail === detailType) {
+ return 'selected';
+ }
+ return '';
+ }
+ // TODO(TS): The following condtion seems always false, because params
+ // never has detailType property. Remove it.
+ if (
+ ((params as unknown) as AdminSubsectionLink).detailType &&
+ ((params as unknown) as AdminSubsectionLink).detailType !== detailType
+ ) {
+ return '';
+ }
+ return params.view === GerritView.ADMIN && itemView === params.adminView
+ ? 'selected'
+ : '';
+ }
+
+ _computeGroupName(groupId?: GroupId) {
+ if (!groupId) return;
+
+ const promises: Array<Promise<void>> = [];
+ this.$.restAPI.getGroupConfig(groupId).then(group => {
+ if (!group || !group.name) {
+ return;
+ }
+
+ this._groupName = group.name;
+ this._groupIsInternal = !!group.id.match(INTERNAL_GROUP_REGEX);
+ this.reload();
+
+ promises.push(
+ this.$.restAPI.getIsAdmin().then(isAdmin => {
+ this._isAdmin = !!isAdmin;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getIsGroupOwner(group.name).then(isOwner => {
+ this._groupOwner = isOwner;
+ })
+ );
+
+ return Promise.all(promises).then(() => {
+ this.reload();
+ });
+ });
+ }
+
+ _updateGroupName(e: CustomEvent<GroupNameChangedDetail>) {
+ this._groupName = e.detail.name;
+ this.reload();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-admin-view': GrAdminView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
index f8b5abd..44fd4d6 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.js
@@ -18,8 +18,8 @@
import '../../../test/common-test-setup-karma.js';
import './gr-admin-view.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {stubBaseUrl} from '../../../test/test-utils.js';
const basicFixture = fixtureFromElement('gr-admin-view');
@@ -35,7 +35,7 @@
},
});
const pluginsLoaded = Promise.resolve();
- sinon.stub(pluginLoader, 'awaitPluginsLoaded').returns(pluginsLoaded);
+ sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
pluginsLoaded.then(() => flush(done));
});
@@ -73,8 +73,8 @@
adminView: 'gr-repo-list',
};
- flushAsynchronousOperations();
- assert.equal(dom(element.root).querySelectorAll(
+ flush();
+ assert.equal(element.root.querySelectorAll(
'.selected').length, 1);
assert.ok(element.shadowRoot
.querySelector('gr-repo-list'));
@@ -149,7 +149,7 @@
return element.reload().then(() => {
assert.equal(element._filteredLinks.length, 3);
assert.deepEqual(element._filteredLinks[1], {
- capability: null,
+ capability: undefined,
url: '/internal/link/url',
name: 'internal link text',
noBaseUrl: true,
@@ -158,7 +158,7 @@
target: null,
});
assert.deepEqual(element._filteredLinks[2], {
- capability: null,
+ capability: undefined,
url: 'http://external/link/url',
name: 'external link text',
noBaseUrl: false,
@@ -183,7 +183,7 @@
viewPlugins: true,
}));
element.reload().then(() => {
- flushAsynchronousOperations();
+ flush();
assert.equal(dom(element.root)
.querySelectorAll('.sectionTitle').length, 3);
assert.equal(element.shadowRoot
@@ -214,7 +214,7 @@
viewPlugins: true,
}));
element.reload().then(() => {
- flushAsynchronousOperations();
+ flush();
assert.equal(element._filteredLinks.length, 3);
// Repos
@@ -244,10 +244,10 @@
'getAccount')
.callsFake(() => Promise.resolve({_id: 1}));
sinon.stub(element, 'reload');
- element.params = {repo: 'Test Repo', adminView: 'gr-repo'};
+ element.params = {repo: 'Test Repo', view: GerritView.REPO};
assert.equal(element.reload.callCount, 1);
element.params = {repo: 'Test Repo 2',
- adminView: 'gr-repo'};
+ view: GerritView.REPO};
assert.equal(element.reload.callCount, 2);
});
@@ -266,7 +266,7 @@
'getAccount')
.callsFake(() => Promise.resolve({_id: 1}));
sinon.stub(element, 'reload');
- element.params = {groupId: '1', adminView: 'gr-group'};
+ element.params = {groupId: '1', view: GerritView.GROUP};
assert.equal(element.reload.callCount, 1);
});
@@ -280,7 +280,7 @@
});
element.params = {group: 1, view: GerritNav.View.GROUP};
element._groupName = 'oldName';
- flushAsynchronousOperations();
+ flush();
element.shadowRoot
.querySelector('gr-group').dispatchEvent(
new CustomEvent('name-changed', {
@@ -302,11 +302,11 @@
detailType: undefined,
},
];
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('.mainHeader'));
element._subsectionLinks = undefined;
- flushAsynchronousOperations();
+ flush();
assert.equal(
getComputedStyle(element.shadowRoot
.querySelector('.mainHeader')).display,
@@ -332,7 +332,7 @@
element.$.restAPI,
'getAccount')
.callsFake(() => Promise.resolve({_id: 1}));
- flushAsynchronousOperations();
+ flush();
const expectedFilteredLinks = [
{
name: 'Repositories',
@@ -512,7 +512,7 @@
adminView: 'gr-repo-list',
openCreateModal: false,
};
- flushAsynchronousOperations();
+ flush();
const selected = element.shadowRoot
.querySelector('gr-page-nav .selected');
assert.isOk(selected);
@@ -526,7 +526,7 @@
};
element._repoName = 'foo';
return element.reload().then(() => {
- flushAsynchronousOperations();
+ flush();
const selected = element.shadowRoot
.querySelector('gr-page-nav .selected');
assert.isOk(selected);
@@ -542,7 +542,7 @@
};
element._repoName = 'foo';
return element.reload().then(() => {
- flushAsynchronousOperations();
+ flush();
const selected = element.shadowRoot
.querySelector('gr-page-nav .selected');
assert.isOk(selected);
@@ -558,7 +558,7 @@
};
element._repoName = 'foo';
return element.reload().then(() => {
- flushAsynchronousOperations();
+ flush();
const selected = element.shadowRoot
.querySelector('gr-page-nav .selected');
assert.isOk(selected);
@@ -592,7 +592,7 @@
adminView: 'gr-admin-group-list',
openCreateModal: false,
};
- flushAsynchronousOperations();
+ flush();
const selected = element.shadowRoot
.querySelector('gr-page-nav .selected');
assert.isOk(selected);
@@ -606,7 +606,7 @@
};
element._groupName = 'foo';
return element.reload().then(() => {
- flushAsynchronousOperations();
+ flush();
const subsectionItems = dom(element.root)
.querySelectorAll('.subsectionItem');
assert.equal(subsectionItems.length, 2);
@@ -631,7 +631,7 @@
};
element._groupName = 'foo';
return element.reload().then(() => {
- flushAsynchronousOperations();
+ flush();
const subsectionItems = dom(element.root)
.querySelectorAll('.subsectionItem');
assert.equal(subsectionItems.length, 0);
@@ -651,7 +651,7 @@
};
element._groupName = 'foo';
return element.reload().then(() => {
- flushAsynchronousOperations();
+ flush();
const selected = element.shadowRoot
.querySelector('gr-page-nav .selected');
assert.isOk(selected);
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
deleted file mode 100644
index 288af4c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
+++ /dev/null
@@ -1,85 +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 '../../shared/gr-dialog/gr-dialog.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-delete-item-dialog_html.js';
-
-const DETAIL_TYPES = {
- BRANCHES: 'branches',
- ID: 'id',
- TAGS: 'tags',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrConfirmDeleteItemDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-confirm-delete-item-dialog'; }
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- static get properties() {
- return {
- item: String,
- itemType: String,
- };
- }
-
- _handleConfirmTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm', {
- composed: true, bubbles: false,
- }));
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {
- composed: true, bubbles: false,
- }));
- }
-
- _computeItemName(detailType) {
- if (detailType === DETAIL_TYPES.BRANCHES) {
- return 'Branch';
- } else if (detailType === DETAIL_TYPES.TAGS) {
- return 'Tag';
- } else if (detailType === DETAIL_TYPES.ID) {
- return 'ID';
- }
- }
-}
-
-customElements.define(GrConfirmDeleteItemDialog.is,
- GrConfirmDeleteItemDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
new file mode 100644
index 0000000..79a3e95
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -0,0 +1,98 @@
+/**
+ * @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 '../../shared/gr-dialog/gr-dialog';
+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-delete-item-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+
+// TODO(TS): add description for this
+export enum DetailType {
+ BRANCHES = 'branches',
+ ID = 'id',
+ TAGS = 'tags',
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-confirm-delete-item-dialog': GrConfirmDeleteItemDialog;
+ }
+}
+
+@customElement('gr-confirm-delete-item-dialog')
+export class GrConfirmDeleteItemDialog 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: String})
+ item?: string;
+
+ @property({type: String})
+ itemType?: DetailType;
+
+ _handleConfirmTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('confirm', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleCancelTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _computeItemName(detailType: DetailType) {
+ if (detailType === DetailType.BRANCHES) {
+ return 'Branch';
+ } else if (detailType === DetailType.TAGS) {
+ return 'Tag';
+ } else if (detailType === DetailType.ID) {
+ return 'ID';
+ }
+ // TODO(TS): should never happen, this is to pass:
+ // not all code returns value
+ return '';
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
deleted file mode 100644
index c0b71c0..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
+++ /dev/null
@@ -1,157 +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-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.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-create-change-dialog_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const SUGGESTIONS_LIMIT = 15;
-const REF_PREFIX = 'refs/heads/';
-
-/**
- * @extends PolymerElement
- */
-class GrCreateChangeDialog extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-create-change-dialog'; }
-
- static get properties() {
- return {
- repoName: String,
- branch: String,
- /** @type {?} */
- _repoConfig: Object,
- subject: String,
- topic: String,
- _query: {
- type: Function,
- value() {
- return this._getRepoBranchesSuggestions.bind(this);
- },
- },
- baseChange: String,
- baseCommit: String,
- privateByDefault: String,
- canCreate: {
- type: Boolean,
- notify: true,
- value: false,
- },
- _privateChangesEnabled: Boolean,
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- if (!this.repoName) { return Promise.resolve(); }
-
- const promises = [];
-
- promises.push(this.$.restAPI.getProjectConfig(this.repoName)
- .then(config => {
- this.privateByDefault = config.private_by_default;
- }));
-
- promises.push(this.$.restAPI.getConfig().then(config => {
- if (!config) { return; }
-
- this._privateConfig = config && config.change &&
- config.change.disable_private_changes;
- }));
-
- return Promise.all(promises);
- }
-
- static get observers() {
- return [
- '_allowCreate(branch, subject)',
- ];
- }
-
- _computeBranchClass(baseChange) {
- return baseChange ? 'hide' : '';
- }
-
- _allowCreate(branch, subject) {
- this.canCreate = !!branch && !!subject;
- }
-
- handleCreateChange() {
- const isPrivate = this.$.privateChangeCheckBox.checked;
- const isWip = true;
- return this.$.restAPI.createChange(this.repoName, this.branch,
- this.subject, this.topic, isPrivate, isWip, this.baseChange,
- this.baseCommit || null)
- .then(changeCreated => {
- if (!changeCreated) { return; }
- GerritNav.navigateToChange(changeCreated);
- });
- }
-
- _getRepoBranchesSuggestions(input) {
- if (input.startsWith(REF_PREFIX)) {
- input = input.substring(REF_PREFIX.length);
- }
- return this.$.restAPI.getRepoBranches(
- input, this.repoName, SUGGESTIONS_LIMIT).then(response => {
- const branches = [];
- let branch;
- for (const key in response) {
- if (!response.hasOwnProperty(key)) { continue; }
- if (response[key].ref.startsWith('refs/heads/')) {
- branch = response[key].ref.substring('refs/heads/'.length);
- } else {
- branch = response[key].ref;
- }
- branches.push({
- name: branch,
- });
- }
- return branches;
- });
- }
-
- _formatBooleanString(config) {
- if (config && config.configured_value === 'TRUE') {
- return true;
- } else if (config && config.configured_value === 'FALSE') {
- return false;
- } else if (config && config.configured_value === 'INHERIT') {
- return !!(config && config.inherited_value);
- } else {
- return false;
- }
- }
-
- _computePrivateSectionClass(config) {
- return config ? 'hide' : '';
- }
-}
-
-customElements.define(GrCreateChangeDialog.is, GrCreateChangeDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
new file mode 100644
index 0000000..2a6c7a8
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
@@ -0,0 +1,225 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+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-create-change-dialog_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+ RepoName,
+ BranchName,
+ ChangeId,
+ ConfigInfo,
+ InheritedBooleanInfo,
+} from '../../../types/common';
+import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+
+const SUGGESTIONS_LIMIT = 15;
+const REF_PREFIX = 'refs/heads/';
+
+export interface GrCreateChangeDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ privateChangeCheckBox: HTMLInputElement;
+ branchInput: GrAutocomplete;
+ tagNameInput: HTMLInputElement;
+ messageInput: IronAutogrowTextareaElement;
+ };
+}
+@customElement('gr-create-change-dialog')
+export class GrCreateChangeDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ repoName?: RepoName;
+
+ @property({type: String})
+ branch?: BranchName;
+
+ @property({type: Object})
+ _repoConfig?: ConfigInfo;
+
+ @property({type: String})
+ subject?: string;
+
+ @property({type: String})
+ topic?: string;
+
+ @property({type: Object})
+ _query?: (input: string) => Promise<{name: string}[]>;
+
+ @property({type: String})
+ baseChange?: ChangeId;
+
+ @property({type: String})
+ baseCommit?: string;
+
+ @property({type: Object})
+ privateByDefault?: InheritedBooleanInfo;
+
+ @property({type: Boolean, notify: true})
+ canCreate = false;
+
+ @property({type: Boolean})
+ _privateChangesEnabled?: boolean;
+
+ constructor() {
+ super();
+ this._query = (input: string) => this._getRepoBranchesSuggestions(input);
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ if (!this.repoName) {
+ return Promise.resolve();
+ }
+
+ const promises = [];
+
+ promises.push(
+ this.$.restAPI.getProjectConfig(this.repoName).then(config => {
+ if (!config) return;
+ this.privateByDefault = config.private_by_default;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getConfig().then(config => {
+ if (!config) {
+ return;
+ }
+
+ this._privateChangesEnabled =
+ config && config.change && !config.change.disable_private_changes;
+ })
+ );
+
+ return Promise.all(promises);
+ }
+
+ _computeBranchClass(baseChange: boolean) {
+ return baseChange ? 'hide' : '';
+ }
+
+ @observe('branch', 'subject')
+ _allowCreate(branch: BranchName, subject: string) {
+ this.canCreate = !!branch && !!subject;
+ }
+
+ handleCreateChange(): Promise<void> {
+ if (!this.repoName || !this.branch || !this.subject) {
+ return Promise.resolve();
+ }
+ const isPrivate = this.$.privateChangeCheckBox.checked;
+ const isWip = true;
+ return this.$.restAPI
+ .createChange(
+ this.repoName,
+ this.branch,
+ this.subject,
+ this.topic,
+ isPrivate,
+ isWip,
+ this.baseChange,
+ this.baseCommit || undefined
+ )
+ .then(changeCreated => {
+ if (!changeCreated) {
+ return;
+ }
+ GerritNav.navigateToChange(changeCreated);
+ });
+ }
+
+ _getRepoBranchesSuggestions(input: string) {
+ if (!this.repoName) {
+ return Promise.reject(new Error('missing repo name'));
+ }
+ if (input.startsWith(REF_PREFIX)) {
+ input = input.substring(REF_PREFIX.length);
+ }
+ return this.$.restAPI
+ .getRepoBranches(input, this.repoName, SUGGESTIONS_LIMIT)
+ .then(response => {
+ if (!response) return [];
+ const branches = [];
+ let branch;
+ for (const key in response) {
+ if (!hasOwnProperty(response, key)) {
+ continue;
+ }
+ if (response[key].ref.startsWith('refs/heads/')) {
+ branch = response[key].ref.substring('refs/heads/'.length);
+ } else {
+ branch = response[key].ref;
+ }
+ branches.push({
+ name: branch,
+ });
+ }
+ return branches;
+ });
+ }
+
+ _formatBooleanString(config: InheritedBooleanInfo) {
+ if (
+ config &&
+ config.configured_value === InheritedBooleanInfoConfiguredValue.TRUE
+ ) {
+ return true;
+ } else if (
+ config &&
+ config.configured_value === InheritedBooleanInfoConfiguredValue.FALSE
+ ) {
+ return false;
+ } else if (
+ config &&
+ config.configured_value === InheritedBooleanInfoConfiguredValue.INHERITED
+ ) {
+ return !!(config && config.inherited_value);
+ } else {
+ return false;
+ }
+ }
+
+ _computePrivateSectionClass(config: boolean) {
+ return config ? 'hide' : '';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-create-change-dialog': GrCreateChangeDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.js
deleted file mode 100644
index 07eee42..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.js
+++ /dev/null
@@ -1,148 +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 '../../../test/common-test-setup-karma.js';
-import './gr-create-change-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-create-change-dialog');
-
-suite('gr-create-change-dialog tests', () => {
- let element;
-
- setup(() => {
- stub('gr-rest-api-interface', {
- getLoggedIn() { return Promise.resolve(true); },
- getRepoBranches(input) {
- if (input.startsWith('test')) {
- return Promise.resolve([
- {
- ref: 'refs/heads/test-branch',
- revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
- can_delete: true,
- },
- ]);
- } else {
- return Promise.resolve({});
- }
- },
- });
- element = basicFixture.instantiate();
- element.repoName = 'test-repo';
- element._repoConfig = {
- private_by_default: {
- configured_value: 'FALSE',
- inherited_value: false,
- },
- };
- });
-
- test('new change created with default', done => {
- const configInputObj = {
- branch: 'test-branch',
- subject: 'first change created with polygerrit ui',
- topic: 'test-topic',
- is_private: false,
- work_in_progress: true,
- };
-
- const saveStub = sinon.stub(element.$.restAPI,
- 'createChange').callsFake(() => Promise.resolve({}));
-
- element.branch = 'test-branch';
- element.topic = 'test-topic';
- element.subject = 'first change created with polygerrit ui';
- assert.isFalse(element.$.privateChangeCheckBox.checked);
-
- element.$.branchInput.bindValue = configInputObj.branch;
- element.$.tagNameInput.bindValue = configInputObj.topic;
- element.$.messageInput.bindValue = configInputObj.subject;
-
- element.handleCreateChange().then(() => {
- // Private change
- assert.isFalse(saveStub.lastCall.args[4]);
- // WIP Change
- assert.isTrue(saveStub.lastCall.args[5]);
- assert.isTrue(saveStub.called);
- done();
- });
- });
-
- test('new change created with private', done => {
- element.privateByDefault = {
- configured_value: 'TRUE',
- inherited_value: false,
- };
- sinon.stub(element, '_formatBooleanString')
- .callsFake(() => Promise.resolve(true));
- flushAsynchronousOperations();
-
- const configInputObj = {
- branch: 'test-branch',
- subject: 'first change created with polygerrit ui',
- topic: 'test-topic',
- is_private: true,
- work_in_progress: true,
- };
-
- const saveStub = sinon.stub(element.$.restAPI,
- 'createChange').callsFake(() => Promise.resolve({}));
-
- element.branch = 'test-branch';
- element.topic = 'test-topic';
- element.subject = 'first change created with polygerrit ui';
- assert.isTrue(element.$.privateChangeCheckBox.checked);
-
- element.$.branchInput.bindValue = configInputObj.branch;
- element.$.tagNameInput.bindValue = configInputObj.topic;
- element.$.messageInput.bindValue = configInputObj.subject;
-
- element.handleCreateChange().then(() => {
- // Private change
- assert.isTrue(saveStub.lastCall.args[4]);
- // WIP Change
- assert.isTrue(saveStub.lastCall.args[5]);
- assert.isTrue(saveStub.called);
- done();
- });
- });
-
- test('_getRepoBranchesSuggestions empty', done => {
- element._getRepoBranchesSuggestions('nonexistent').then(branches => {
- assert.equal(branches.length, 0);
- done();
- });
- });
-
- test('_getRepoBranchesSuggestions non-empty', done => {
- element._getRepoBranchesSuggestions('test-branch').then(branches => {
- assert.equal(branches.length, 1);
- assert.equal(branches[0].name, 'test-branch');
- done();
- });
- });
-
- test('_computeBranchClass', () => {
- assert.equal(element._computeBranchClass(true), 'hide');
- assert.equal(element._computeBranchClass(false), '');
- });
-
- test('_computePrivateSectionClass', () => {
- assert.equal(element._computePrivateSectionClass(true), 'hide');
- assert.equal(element._computePrivateSectionClass(false), '');
- });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
new file mode 100644
index 0000000..e529730
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.ts
@@ -0,0 +1,149 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-create-change-dialog.js';
+import {GrCreateChangeDialog} from './gr-create-change-dialog';
+import {BranchName, GitRef, RepoName} from '../../../types/common';
+import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
+import {createChange, createConfig} from '../../../test/test-data-generators';
+
+const basicFixture = fixtureFromElement('gr-create-change-dialog');
+
+suite('gr-create-change-dialog tests', () => {
+ let element: GrCreateChangeDialog;
+
+ setup(() => {
+ stub('gr-rest-api-interface', {
+ getLoggedIn() {
+ return Promise.resolve(true);
+ },
+ getRepoBranches(input) {
+ if (input.startsWith('test')) {
+ return Promise.resolve([
+ {
+ ref: 'refs/heads/test-branch' as GitRef,
+ revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+ can_delete: true,
+ },
+ ]);
+ } else {
+ return Promise.resolve([]);
+ }
+ },
+ });
+ element = basicFixture.instantiate();
+ element.repoName = 'test-repo' as RepoName;
+ element._repoConfig = {
+ ...createConfig(),
+ private_by_default: {
+ value: false,
+ configured_value: InheritedBooleanInfoConfiguredValue.FALSE,
+ inherited_value: false,
+ },
+ };
+ });
+
+ test('new change created with default', async () => {
+ const configInputObj = {
+ branch: 'test-branch',
+ subject: 'first change created with polygerrit ui',
+ topic: 'test-topic',
+ is_private: false,
+ work_in_progress: true,
+ };
+
+ const saveStub = sinon
+ .stub(element.$.restAPI, 'createChange')
+ .callsFake(() => Promise.resolve(createChange()));
+
+ element.branch = 'test-branch' as BranchName;
+ element.topic = 'test-topic';
+ element.subject = 'first change created with polygerrit ui';
+ assert.isFalse(element.$.privateChangeCheckBox.checked);
+
+ element.$.messageInput.bindValue = configInputObj.subject;
+
+ await element.handleCreateChange();
+ // Private change
+ assert.isFalse(saveStub.lastCall.args[4]);
+ // WIP Change
+ assert.isTrue(saveStub.lastCall.args[5]);
+ assert.isTrue(saveStub.called);
+ });
+
+ test('new change created with private', async () => {
+ element.privateByDefault = {
+ configured_value: InheritedBooleanInfoConfiguredValue.TRUE,
+ inherited_value: false,
+ value: true,
+ };
+ sinon.stub(element, '_formatBooleanString').callsFake(() => true);
+ flush();
+
+ const configInputObj = {
+ branch: 'test-branch',
+ subject: 'first change created with polygerrit ui',
+ topic: 'test-topic',
+ is_private: true,
+ work_in_progress: true,
+ };
+
+ const saveStub = sinon
+ .stub(element.$.restAPI, 'createChange')
+ .callsFake(() => Promise.resolve(createChange()));
+
+ element.branch = 'test-branch' as BranchName;
+ element.topic = 'test-topic';
+ element.subject = 'first change created with polygerrit ui';
+ assert.isTrue(element.$.privateChangeCheckBox.checked);
+
+ element.$.messageInput.bindValue = configInputObj.subject;
+
+ await element.handleCreateChange();
+ // Private change
+ assert.isTrue(saveStub.lastCall.args[4]);
+ // WIP Change
+ assert.isTrue(saveStub.lastCall.args[5]);
+ assert.isTrue(saveStub.called);
+ });
+
+ test('_getRepoBranchesSuggestions empty', done => {
+ element._getRepoBranchesSuggestions('nonexistent').then(branches => {
+ assert.equal(branches.length, 0);
+ done();
+ });
+ });
+
+ test('_getRepoBranchesSuggestions non-empty', done => {
+ element._getRepoBranchesSuggestions('test-branch').then(branches => {
+ assert.equal(branches.length, 1);
+ assert.equal(branches[0].name, 'test-branch');
+ done();
+ });
+ });
+
+ test('_computeBranchClass', () => {
+ assert.equal(element._computeBranchClass(true), 'hide');
+ assert.equal(element._computeBranchClass(false), '');
+ });
+
+ test('_computePrivateSectionClass', () => {
+ assert.equal(element._computePrivateSectionClass(true), 'hide');
+ assert.equal(element._computePrivateSectionClass(false), '');
+ });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
deleted file mode 100644
index 85a76f1..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
+++ /dev/null
@@ -1,81 +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-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-create-group-dialog_html.js';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
-import page from 'page/page.mjs';
-
-/**
- * @extends PolymerElement
- */
-class GrCreateGroupDialog extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-create-group-dialog'; }
-
- static get properties() {
- return {
- params: Object,
- hasNewGroupName: {
- type: Boolean,
- notify: true,
- value: false,
- },
- _name: Object,
- _groupCreated: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_updateGroupName(_name)',
- ];
- }
-
- _computeGroupUrl(groupId) {
- return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
- }
-
- _updateGroupName(name) {
- this.hasNewGroupName = !!name;
- }
-
- handleCreateGroup() {
- return this.$.restAPI.createGroup({name: this._name})
- .then(groupRegistered => {
- if (groupRegistered.status !== 201) { return; }
- this._groupCreated = true;
- return this.$.restAPI.getGroupConfig(this._name)
- .then(group => {
- page.show(this._computeGroupUrl(group.group_id));
- });
- });
- }
-}
-
-customElements.define(GrCreateGroupDialog.is, GrCreateGroupDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
new file mode 100644
index 0000000..1d8b7db
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.ts
@@ -0,0 +1,82 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-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-create-group-dialog_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {page} from '../../../utils/page-wrapper-utils';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GroupName} from '../../../types/common';
+
+export interface GrCreateGroupDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-create-group-dialog')
+export class GrCreateGroupDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean, notify: true})
+ hasNewGroupName = false;
+
+ @property({type: String})
+ _name: GroupName | '' = '';
+
+ @property({type: Boolean})
+ _groupCreated = false;
+
+ _computeGroupUrl(groupId: string) {
+ return getBaseUrl() + '/admin/groups/' + encodeURL(groupId, true);
+ }
+
+ @observe('_name')
+ _updateGroupName(name: string) {
+ this.hasNewGroupName = !!name;
+ }
+
+ handleCreateGroup() {
+ const name = this._name as GroupName;
+ return this.$.restAPI.createGroup({name}).then(groupRegistered => {
+ if (groupRegistered.status !== 201) {
+ return;
+ }
+ this._groupCreated = true;
+ return this.$.restAPI.getGroupConfig(name).then(group => {
+ // TODO(TS): should group always defined ?
+ page.show(this._computeGroupUrl(group!.group_id!));
+ });
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-create-group-dialog': GrCreateGroupDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
index d9bc500..d32ff30 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-create-group-dialog.js';
-import page from 'page/page.mjs';
+import {page} from '../../../utils/page-wrapper-utils.js';
const basicFixture = fixtureFromElement('gr-create-group-dialog');
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
deleted file mode 100644
index 3b9176c..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
+++ /dev/null
@@ -1,108 +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-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.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-create-pointer-dialog_html.js';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
-import page from 'page/page.mjs';
-
-const DETAIL_TYPES = {
- branches: 'branches',
- tags: 'tags',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrCreatePointerDialog extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-create-pointer-dialog'; }
-
- static get properties() {
- return {
- detailType: String,
- repoName: String,
- hasNewItemName: {
- type: Boolean,
- notify: true,
- value: false,
- },
- itemDetail: String,
- _itemName: String,
- _itemRevision: String,
- _itemAnnotation: String,
- };
- }
-
- static get observers() {
- return [
- '_updateItemName(_itemName)',
- ];
- }
-
- _updateItemName(name) {
- this.hasNewItemName = !!name;
- }
-
- _computeItemUrl(project) {
- if (this.itemDetail === DETAIL_TYPES.branches) {
- return getBaseUrl() + '/admin/repos/' +
- encodeURL(this.repoName, true) + ',branches';
- } else if (this.itemDetail === DETAIL_TYPES.tags) {
- return getBaseUrl() + '/admin/repos/' +
- encodeURL(this.repoName, true) + ',tags';
- }
- }
-
- handleCreateItem() {
- const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
- if (this.itemDetail === DETAIL_TYPES.branches) {
- return this.$.restAPI.createRepoBranch(this.repoName,
- this._itemName, {revision: USE_HEAD})
- .then(itemRegistered => {
- if (itemRegistered.status === 201) {
- page.show(this._computeItemUrl(this.itemDetail));
- }
- });
- } else if (this.itemDetail === DETAIL_TYPES.tags) {
- return this.$.restAPI.createRepoTag(this.repoName,
- this._itemName,
- {revision: USE_HEAD, message: this._itemAnnotation || null})
- .then(itemRegistered => {
- if (itemRegistered.status === 201) {
- page.show(this._computeItemUrl(this.itemDetail));
- }
- });
- }
- }
-
- _computeHideItemClass(type) {
- return type === DETAIL_TYPES.branches ? 'hideItem' : '';
- }
-}
-
-customElements.define(GrCreatePointerDialog.is, GrCreatePointerDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
new file mode 100644
index 0000000..e0a5042
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.ts
@@ -0,0 +1,119 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+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-create-pointer-dialog_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {page} from '../../../utils/page-wrapper-utils';
+import {customElement, property, observe} from '@polymer/decorators';
+import {BranchName, RepoName} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+enum DetailType {
+ branches = 'branches',
+ tags = 'tags',
+}
+
+export interface GrCreatePointerDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-create-pointer-dialog')
+export class GrCreatePointerDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ detailType?: string;
+
+ @property({type: String})
+ repoName?: RepoName;
+
+ @property({type: Boolean, notify: true})
+ hasNewItemName = false;
+
+ @property({type: String})
+ itemDetail?: DetailType;
+
+ @property({type: String})
+ _itemName?: BranchName;
+
+ @property({type: String})
+ _itemRevision?: string;
+
+ @property({type: String})
+ _itemAnnotation?: string;
+
+ @observe('_itemName')
+ _updateItemName(name?: string) {
+ this.hasNewItemName = !!name;
+ }
+
+ handleCreateItem() {
+ if (!this.repoName) {
+ throw new Error('repoName name is not set');
+ }
+ if (!this._itemName) {
+ throw new Error('itemName name is not set');
+ }
+ const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
+ const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
+ if (this.itemDetail === DetailType.branches) {
+ return this.$.restAPI
+ .createRepoBranch(this.repoName, this._itemName, {revision: USE_HEAD})
+ .then(itemRegistered => {
+ if (itemRegistered.status === 201) {
+ page.show(`${url},branches`);
+ }
+ });
+ } else if (this.itemDetail === DetailType.tags) {
+ return this.$.restAPI
+ .createRepoTag(this.repoName, this._itemName, {
+ revision: USE_HEAD,
+ message: this._itemAnnotation || undefined,
+ })
+ .then(itemRegistered => {
+ if (itemRegistered.status === 201) {
+ page.show(`${url},tags`);
+ }
+ });
+ }
+ throw new Error(`Invalid itemDetail: ${this.itemDetail}`);
+ }
+
+ _computeHideItemClass(type: DetailType) {
+ return type === DetailType.branches ? 'hideItem' : '';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-create-pointer-dialog': GrCreatePointerDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
index 22f19a6..79a18d5 100644
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-create-pointer-dialog.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
@@ -25,7 +24,7 @@
let element;
const ironInput = function(element) {
- return dom(element).querySelector('iron-input');
+ return element.querySelector('iron-input');
};
setup(() => {
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
deleted file mode 100644
index 9855523..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.js
+++ /dev/null
@@ -1,150 +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-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.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-create-repo-dialog_html.js';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
-import page from 'page/page.mjs';
-
-/**
- * @extends PolymerElement
- */
-class GrCreateRepoDialog extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-create-repo-dialog'; }
-
- static get properties() {
- return {
- params: Object,
- hasNewRepoName: {
- type: Boolean,
- notify: true,
- value: false,
- },
-
- /** @type {?} */
- _repoConfig: {
- type: Object,
- value: () => {
- // Set default values for dropdowns.
- return {
- create_empty_commit: true,
- permissions_only: false,
- };
- },
- },
- _repoCreated: {
- type: Boolean,
- value: false,
- },
- _repoOwner: String,
- _repoOwnerId: {
- type: String,
- observer: '_repoOwnerIdUpdate',
- },
-
- _query: {
- type: Function,
- value() {
- return this._getRepoSuggestions.bind(this);
- },
- },
- _queryGroups: {
- type: Function,
- value() {
- return this._getGroupSuggestions.bind(this);
- },
- },
- };
- }
-
- static get observers() {
- return [
- '_updateRepoName(_repoConfig.name)',
- ];
- }
-
- _computeRepoUrl(repoName) {
- return getBaseUrl() + '/admin/repos/' +
- encodeURL(repoName, true);
- }
-
- _updateRepoName(name) {
- this.hasNewRepoName = !!name;
- }
-
- _repoOwnerIdUpdate(id) {
- if (id) {
- this.set('_repoConfig.owners', [id]);
- } else {
- this.set('_repoConfig.owners', undefined);
- }
- }
-
- handleCreateRepo() {
- return this.$.restAPI.createRepo(this._repoConfig)
- .then(repoRegistered => {
- if (repoRegistered.status === 201) {
- this._repoCreated = true;
- page.show(this._computeRepoUrl(this._repoConfig.name));
- }
- });
- }
-
- _getRepoSuggestions(input) {
- return this.$.restAPI.getSuggestedProjects(input)
- .then(response => {
- const repos = [];
- for (const key in response) {
- if (!response.hasOwnProperty(key)) { continue; }
- repos.push({
- name: key,
- value: response[key],
- });
- }
- return repos;
- });
- }
-
- _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;
- });
- }
-}
-
-customElements.define(GrCreateRepoDialog.is, GrCreateRepoDialog);
diff --git a/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
new file mode 100644
index 0000000..6f0ac19
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-repo-dialog/gr-create-repo-dialog.ts
@@ -0,0 +1,145 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+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-create-repo-dialog_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {page} from '../../../utils/page-wrapper-utils';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ProjectInput, RepoName} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {AutocompleteQuery} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-create-repo-dialog': GrCreateRepoDialog;
+ }
+}
+
+export interface GrCreateRepoDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-create-repo-dialog')
+export class GrCreateRepoDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean, notify: true})
+ hasNewRepoName = false;
+
+ @property({type: Object})
+ _repoConfig: ProjectInput & {name: RepoName} = {
+ create_empty_commit: true,
+ permissions_only: false,
+ name: '' as RepoName,
+ };
+
+ @property({type: Boolean})
+ _repoCreated = false;
+
+ @property({type: String})
+ _repoOwner?: string;
+
+ @property({type: String})
+ _repoOwnerId?: string;
+
+ @property({type: Object})
+ _query: AutocompleteQuery;
+
+ @property({type: Object})
+ _queryGroups: AutocompleteQuery;
+
+ constructor() {
+ super();
+ this._query = (input: string) => this._getRepoSuggestions(input);
+ this._queryGroups = (input: string) => this._getGroupSuggestions(input);
+ }
+
+ _computeRepoUrl(repoName: string) {
+ return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
+ }
+
+ @observe('_repoConfig.name')
+ _updateRepoName(name: string) {
+ this.hasNewRepoName = !!name;
+ }
+
+ @observe('_repoOwnerId')
+ _repoOwnerIdUpdate(id?: string) {
+ if (id) {
+ this.set('_repoConfig.owners', [id]);
+ } else {
+ this.set('_repoConfig.owners', undefined);
+ }
+ }
+
+ handleCreateRepo() {
+ return this.$.restAPI.createRepo(this._repoConfig).then(repoRegistered => {
+ if (repoRegistered.status === 201) {
+ this._repoCreated = true;
+ page.show(this._computeRepoUrl(this._repoConfig.name));
+ }
+ });
+ }
+
+ _getRepoSuggestions(input: string) {
+ return this.$.restAPI.getSuggestedProjects(input).then(response => {
+ const repos = [];
+ for (const key in response) {
+ if (!hasOwnProperty(response, key)) {
+ continue;
+ }
+ repos.push({
+ name: key,
+ value: response[key],
+ });
+ }
+ return repos;
+ });
+ }
+
+ _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;
+ });
+ }
+}
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 ced9c69..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 this._getAccountSuggestions.bind(this);
- },
- },
- _queryIncludedGroup: {
- type: Function,
- value() {
- return this._getGroupSuggestions.bind(this);
- },
- },
- _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.saveGroupMembers(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.deleteGroupMembers(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..ae10c03
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.ts
@@ -0,0 +1,397 @@
+/**
+ * @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,
+ GroupName,
+} 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?: GroupName;
+
+ @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;
+
+ 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 3e3e572..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');
@@ -162,7 +162,7 @@
const memberName = 'test-admin';
- const saveStub = sinon.stub(element.$.restAPI, 'saveGroupMembers')
+ const saveStub = sinon.stub(element.$.restAPI, 'saveGroupMember')
.callsFake(() => Promise.resolve({}));
const button = element.$.saveGroupMember;
@@ -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/admin/gr-group/gr-group.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
deleted file mode 100644
index 49cd8ea..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
+++ /dev/null
@@ -1,274 +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/shared-styles.js';
-import '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.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_html.js';
-
-const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
-
-const OPTIONS = {
- submitFalse: {
- value: false,
- label: 'False',
- },
- submitTrue: {
- value: true,
- label: 'True',
- },
-};
-
-/**
- * @extends PolymerElement
- */
-class GrGroup extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-group'; }
- /**
- * Fired when the group name changes.
- *
- * @event name-changed
- */
-
- static get properties() {
- return {
- groupId: Number,
- _rename: {
- type: Boolean,
- value: false,
- },
- _groupIsInternal: Boolean,
- _description: {
- type: Boolean,
- value: false,
- },
- _owner: {
- type: Boolean,
- value: false,
- },
- _options: {
- type: Boolean,
- value: false,
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- /** @type {?} */
- _groupConfig: Object,
- _groupConfigOwner: String,
- _groupName: Object,
- _groupOwner: {
- type: Boolean,
- value: false,
- },
- _submitTypes: {
- type: Array,
- value() {
- return Object.values(OPTIONS);
- },
- },
- _query: {
- type: Function,
- value() {
- return this._getGroupSuggestions.bind(this);
- },
- },
- _isAdmin: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_handleConfigName(_groupConfig.name)',
- '_handleConfigOwner(_groupConfig.owner, _groupConfigOwner)',
- '_handleConfigDescription(_groupConfig.description)',
- '_handleConfigOptions(_groupConfig.options.visible_to_all)',
- ];
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadGroup();
- }
-
- _loadGroup() {
- 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;
- this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
-
- promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
- this._isAdmin = !!isAdmin;
- }));
-
- promises.push(this.$.restAPI.getIsGroupOwner(config.name)
- .then(isOwner => {
- this._groupOwner = !!isOwner;
- }));
-
- // If visible to all is undefined, set to false. If it is defined
- // as false, setting to false is fine. If any optional values
- // are added with a default of true, then this would need to be an
- // undefined check and not a truthy/falsy check.
- if (!config.options.visible_to_all) {
- config.options.visible_to_all = false;
- }
- this._groupConfig = config;
-
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: config.name},
- composed: true, bubbles: true,
- }));
-
- return Promise.all(promises).then(() => {
- this._loading = false;
- });
- });
- }
-
- _computeLoadingClass(loading) {
- return loading ? 'loading' : '';
- }
-
- _isLoading() {
- return this._loading || this._loading === undefined;
- }
-
- _handleSaveName() {
- return this.$.restAPI.saveGroupName(this.groupId, this._groupConfig.name)
- .then(config => {
- if (config.status === 200) {
- this._groupName = this._groupConfig.name;
- this.dispatchEvent(new CustomEvent('name-changed', {
- detail: {name: this._groupConfig.name,
- external: this._groupIsExtenral},
- composed: true, bubbles: true,
- }));
- this._rename = false;
- }
- });
- }
-
- _handleSaveOwner() {
- let owner = this._groupConfig.owner;
- if (this._groupConfigOwner) {
- owner = decodeURIComponent(this._groupConfigOwner);
- }
- return this.$.restAPI.saveGroupOwner(this.groupId,
- owner).then(config => {
- this._owner = false;
- });
- }
-
- _handleSaveDescription() {
- return this.$.restAPI.saveGroupDescription(this.groupId,
- this._groupConfig.description).then(config => {
- this._description = false;
- });
- }
-
- _handleSaveOptions() {
- const visible = this._groupConfig.options.visible_to_all;
-
- const options = {visible_to_all: visible};
-
- return this.$.restAPI.saveGroupOptions(this.groupId,
- options).then(config => {
- this._options = false;
- });
- }
-
- _handleConfigName() {
- if (this._isLoading()) { return; }
- this._rename = true;
- }
-
- _handleConfigOwner() {
- if (this._isLoading()) { return; }
- this._owner = true;
- }
-
- _handleConfigDescription() {
- if (this._isLoading()) { return; }
- this._description = true;
- }
-
- _handleConfigOptions() {
- if (this._isLoading()) { return; }
- this._options = true;
- }
-
- _computeHeaderClass(configChanged) {
- return configChanged ? 'edited' : '';
- }
-
- _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;
- });
- }
-
- _computeGroupDisabled(owner, admin, groupIsInternal) {
- return !(groupIsInternal && (admin || owner));
- }
-
- _getGroupUUID(id) {
- if (!id) return;
-
- return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
- }
-}
-
-customElements.define(GrGroup.is, GrGroup);
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
new file mode 100644
index 0000000..511bf5c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -0,0 +1,334 @@
+/**
+ * @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/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+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_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+ AutocompleteSuggestion,
+ AutocompleteQuery,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
+import {
+ ErrorCallback,
+ RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
+
+const OPTIONS = {
+ submitFalse: {
+ value: false,
+ label: 'False',
+ },
+ submitTrue: {
+ value: true,
+ label: 'True',
+ },
+};
+
+export interface GrGroup {
+ $: {
+ restAPI: RestApiService & Element;
+ loading: HTMLDivElement;
+ };
+}
+
+export interface GroupNameChangedDetail {
+ name: GroupName;
+ external: boolean;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-group': GrGroup;
+ }
+}
+
+@customElement('gr-group')
+export class GrGroup extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the group name changes.
+ *
+ * @event name-changed
+ */
+
+ @property({type: String})
+ groupId?: GroupId;
+
+ @property({type: Boolean})
+ _rename = false;
+
+ @property({type: Boolean})
+ _groupIsInternal = false;
+
+ @property({type: Boolean})
+ _description = false;
+
+ @property({type: Boolean})
+ _owner = false;
+
+ @property({type: Boolean})
+ _options = false;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Object})
+ _groupConfig?: GroupInfo;
+
+ @property({type: String})
+ _groupConfigOwner?: string;
+
+ @property({type: Object})
+ _groupName?: string;
+
+ @property({type: Boolean})
+ _groupOwner = false;
+
+ @property({type: Array})
+ _submitTypes = Object.values(OPTIONS);
+
+ @property({type: Object})
+ _query: AutocompleteQuery;
+
+ @property({type: Boolean})
+ _isAdmin = false;
+
+ constructor() {
+ super();
+ this._query = (input: string) => this._getGroupSuggestions(input);
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadGroup();
+ }
+
+ _loadGroup() {
+ if (!this.groupId) {
+ return;
+ }
+
+ const promises: Promise<unknown>[] = [];
+
+ 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;
+ this._groupIsInternal = !!config.id.match(INTERNAL_GROUP_REGEX);
+
+ promises.push(
+ this.$.restAPI.getIsAdmin().then(isAdmin => {
+ this._isAdmin = !!isAdmin;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getIsGroupOwner(config.name).then(isOwner => {
+ this._groupOwner = !!isOwner;
+ })
+ );
+
+ // If visible to all is undefined, set to false. If it is defined
+ // as false, setting to false is fine. If any optional values
+ // are added with a default of true, then this would need to be an
+ // undefined check and not a truthy/falsy check.
+ if (config.options && !config.options.visible_to_all) {
+ config.options.visible_to_all = false;
+ }
+ this._groupConfig = config;
+
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: config.name},
+ composed: true,
+ bubbles: true,
+ })
+ );
+
+ return Promise.all(promises).then(() => {
+ this._loading = false;
+ });
+ });
+ }
+
+ _computeLoadingClass(loading: boolean) {
+ return loading ? 'loading' : '';
+ }
+
+ _isLoading() {
+ return this._loading || this._loading === undefined;
+ }
+
+ _handleSaveName() {
+ const groupConfig = this._groupConfig;
+ if (!this.groupId || !groupConfig || !groupConfig.name) {
+ return Promise.reject(new Error('invalid groupId or config name'));
+ }
+ const groupName = groupConfig.name;
+ return this.$.restAPI
+ .saveGroupName(this.groupId, groupName)
+ .then(config => {
+ if (config.status === 200) {
+ this._groupName = groupName;
+ const detail: GroupNameChangedDetail = {
+ name: groupName,
+ external: !this._groupIsInternal,
+ };
+ this.dispatchEvent(
+ new CustomEvent('name-changed', {
+ detail,
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._rename = false;
+ }
+ });
+ }
+
+ _handleSaveOwner() {
+ if (!this.groupId || !this._groupConfig) return;
+ let owner = this._groupConfig.owner;
+ if (this._groupConfigOwner) {
+ owner = decodeURIComponent(this._groupConfigOwner);
+ }
+ if (!owner) return;
+ return this.$.restAPI.saveGroupOwner(this.groupId, owner).then(() => {
+ this._owner = false;
+ });
+ }
+
+ _handleSaveDescription() {
+ if (!this.groupId || !this._groupConfig || !this._groupConfig.description)
+ return;
+ return this.$.restAPI
+ .saveGroupDescription(this.groupId, this._groupConfig.description)
+ .then(() => {
+ this._description = false;
+ });
+ }
+
+ _handleSaveOptions() {
+ if (!this.groupId || !this._groupConfig || !this._groupConfig.options)
+ return;
+ const visible = this._groupConfig.options.visible_to_all;
+
+ const options = {visible_to_all: visible};
+
+ return this.$.restAPI.saveGroupOptions(this.groupId, options).then(() => {
+ this._options = false;
+ });
+ }
+
+ @observe('_groupConfig.name')
+ _handleConfigName() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._rename = true;
+ }
+
+ @observe('_groupConfig.owner', '_groupConfigOwner')
+ _handleConfigOwner() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._owner = true;
+ }
+
+ @observe('_groupConfig.description')
+ _handleConfigDescription() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._description = true;
+ }
+
+ @observe('_groupConfig.options.visible_to_all')
+ _handleConfigOptions() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._options = true;
+ }
+
+ _computeHeaderClass(configChanged: boolean) {
+ return configChanged ? 'edited' : '';
+ }
+
+ _getGroupSuggestions(input: string) {
+ return this.$.restAPI.getSuggestedGroups(input).then(response => {
+ const groups: AutocompleteSuggestion[] = [];
+ for (const key in response) {
+ if (!hasOwnProperty(response, key)) {
+ continue;
+ }
+ groups.push({
+ name: key,
+ value: decodeURIComponent(response[key].id),
+ });
+ }
+ return groups;
+ });
+ }
+
+ _computeGroupDisabled(
+ owner: boolean,
+ admin: boolean,
+ groupIsInternal: boolean
+ ) {
+ return !(groupIsInternal && (admin || owner));
+ }
+
+ _getGroupUUID(id: GroupId) {
+ if (!id) return;
+
+ return id.match(INTERNAL_GROUP_REGEX) ? id : decodeURIComponent(id);
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
index c6054b2..34f6b6a 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.js
@@ -68,7 +68,7 @@
});
test('default values with external group', done => {
- const groupExternal = Object.assign({}, group);
+ const groupExternal = {...group};
groupExternal.id = 'external-group-id';
groupStub.restore();
groupStub = sinon.stub(
@@ -188,7 +188,7 @@
element._groupConfig = {
name: 'test-group',
};
-
+ element.groupId = 'gg';
sinon.stub(element.$.restAPI, 'saveGroupName')
.returns(Promise.resolve({status: 200}));
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
deleted file mode 100644
index 9675c92..0000000
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
+++ /dev/null
@@ -1,323 +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/paper-toggle-button/paper-toggle-button.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-menu-page-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-rule-editor/gr-rule-editor.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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-permission_html.js';
-import {toSortedPermissionsArray} from '../../../utils/access-util.js';
-
-const MAX_AUTOCOMPLETE_RESULTS = 20;
-
-const RANGE_NAMES = [
- 'QUERY LIMIT',
- 'BATCH CHANGES LIMIT',
-];
-
-/**
- * Fired when the permission has been modified or removed.
- *
- * @event access-modified
- */
-/**
- * Fired when a permission that was previously added was removed.
- *
- * @event added-permission-removed
- * @extends PolymerElement
- */
-class GrPermission extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-permission'; }
-
- static get properties() {
- return {
- labels: Object,
- name: String,
- /** @type {?} */
- permission: {
- type: Object,
- observer: '_sortPermission',
- notify: true,
- },
- groups: Object,
- section: String,
- editing: {
- type: Boolean,
- value: false,
- observer: '_handleEditingChanged',
- },
- _label: {
- type: Object,
- computed: '_computeLabel(permission, labels)',
- },
- _groupFilter: String,
- _query: {
- type: Function,
- value() {
- return this._getGroupSuggestions.bind(this);
- },
- },
- _rules: Array,
- _groupsWithRules: Object,
- _deleted: {
- type: Boolean,
- value: false,
- },
- _originalExclusiveValue: Boolean,
- };
- }
-
- static get observers() {
- return [
- '_handleRulesChanged(_rules.splices)',
- ];
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('access-saved',
- () => this._handleAccessSaved());
- }
-
- /** @override */
- ready() {
- super.ready();
- this._setupValues();
- }
-
- _setupValues() {
- if (!this.permission) { return; }
- this._originalExclusiveValue = !!this.permission.value.exclusive;
- flush();
- }
-
- _handleAccessSaved() {
- // Set a new 'original' value to keep track of after the value has been
- // saved.
- this._setupValues();
- }
-
- _permissionIsOwnerOrGlobal(permissionId, section) {
- return permissionId === 'owner' || section === 'GLOBAL_CAPABILITIES';
- }
-
- _handleEditingChanged(editing, editingOld) {
- // Ignore when editing gets set initially.
- if (!editingOld) { return; }
- // Restore original values if no longer editing.
- if (!editing) {
- this._deleted = false;
- delete this.permission.value.deleted;
- this._groupFilter = '';
- this._rules = this._rules.filter(rule => !rule.value.added);
- for (const key of Object.keys(this.permission.value.rules)) {
- if (this.permission.value.rules[key].added) {
- delete this.permission.value.rules[key];
- }
- }
-
- // Restore exclusive bit to original.
- this.set(['permission', 'value', 'exclusive'],
- this._originalExclusiveValue);
- }
- }
-
- _handleAddedRuleRemoved(e) {
- const index = e.model.index;
- this._rules = this._rules.slice(0, index)
- .concat(this._rules.slice(index + 1, this._rules.length));
- }
-
- _handleValueChange() {
- this.permission.value.modified = true;
- // Allows overall access page to know a change has been made.
- this.dispatchEvent(
- new CustomEvent('access-modified', {bubbles: true, composed: true}));
- }
-
- _handleRemovePermission() {
- if (this.permission.value.added) {
- this.dispatchEvent(new CustomEvent(
- 'added-permission-removed', {bubbles: true, composed: true}));
- }
- this._deleted = true;
- this.permission.value.deleted = true;
- this.dispatchEvent(
- new CustomEvent('access-modified', {bubbles: true, composed: true}));
- }
-
- _handleRulesChanged(changeRecord) {
- // Update the groups to exclude in the autocomplete.
- this._groupsWithRules = this._computeGroupsWithRules(this._rules);
- }
-
- _sortPermission(permission) {
- this._rules = toSortedPermissionsArray(permission.value.rules);
- }
-
- _computeSectionClass(editing, deleted) {
- const classList = [];
- if (editing) {
- classList.push('editing');
- }
- if (deleted) {
- classList.push('deleted');
- }
- return classList.join(' ');
- }
-
- _handleUndoRemove() {
- this._deleted = false;
- delete this.permission.value.deleted;
- }
-
- _computeLabel(permission, labels) {
- if (!labels || !permission ||
- !permission.value || !permission.value.label) { return; }
-
- const labelName = permission.value.label;
-
- // It is possible to have a label name that is not included in the
- // 'labels' object. In this case, treat it like anything else.
- if (!labels[labelName]) { return; }
- return {
- name: labelName,
- values: this._computeLabelValues(labels[labelName].values),
- };
- }
-
- _computeLabelValues(values) {
- const valuesArr = [];
- const keys = Object.keys(values)
- .sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
-
- for (const key of keys) {
- let text = values[key];
- if (!text) { text = ''; }
- // The value from the server being used to choose which item is
- // selected is in integer form, so this must be converted.
- valuesArr.push({value: parseInt(key, 10), text});
- }
- return valuesArr;
- }
-
- /**
- * @param {!Array} rules
- * @return {!Object} Object with groups with rues as keys, and true as
- * value.
- */
- _computeGroupsWithRules(rules) {
- const groups = {};
- for (const rule of rules) {
- groups[rule.id] = true;
- }
- return groups;
- }
-
- _computeGroupName(groups, groupId) {
- return groups && groups[groupId] && groups[groupId].name ?
- groups[groupId].name : groupId;
- }
-
- _getGroupSuggestions() {
- return this.$.restAPI.getSuggestedGroups(
- this._groupFilter,
- MAX_AUTOCOMPLETE_RESULTS)
- .then(response => {
- const groups = [];
- for (const key in response) {
- if (!response.hasOwnProperty(key)) { continue; }
- groups.push({
- name: key,
- value: response[key],
- });
- }
- // Does not return groups in which we already have rules for.
- return groups
- .filter(group => !this._groupsWithRules[group.value.id]);
- });
- }
-
- /**
- * Handles adding a skeleton item to the dom-repeat.
- * gr-rule-editor handles setting the default values.
- */
- _handleAddRuleItem(e) {
- // The group id is encoded, but have to decode in order for the access
- // API to work as expected.
- const groupId = decodeURIComponent(e.detail.value.id)
- .replace(/\+/g, ' ');
- // We cannot use "this.set(...)" here, because groupId may contain dots,
- // and dots in property path names are totally unsupported by Polymer.
- // Apparently Polymer picks up this change anyway, otherwise we should
- // have looked at using MutableData:
- // https://polymer-library.polymer-project.org/2.0/docs/devguide/data-system#mutable-data
- this.permission.value.rules[groupId] = {};
-
- // Purposely don't recompute sorted array so that the newly added rule
- // is the last item of the array.
- this.push('_rules', {
- id: groupId,
- });
-
- // Add the new group name to the groups object so the name renders
- // correctly.
- if (this.groups && !this.groups[groupId]) {
- this.groups[groupId] = {name: this.$.groupAutocomplete.text};
- }
-
- // Wait for new rule to get value populated via gr-rule-editor, and then
- // add to permission values as well, so that the change gets propagated
- // back to the section. Since the rule is inside a dom-repeat, a flush
- // is needed.
- flush();
- const value = this._rules[this._rules.length - 1].value;
- value.added = true;
- // See comment above for why we cannot use "this.set(...)" here.
- this.permission.value.rules[groupId] = value;
- this.dispatchEvent(
- new CustomEvent('access-modified', {bubbles: true, composed: true}));
- }
-
- _computeHasRange(name) {
- if (!name) { return false; }
-
- return RANGE_NAMES.includes(name.toUpperCase());
- }
-
- /**
- * Work around a issue on iOS when clicking turns into double tap
- */
- _onTapExclusiveToggle(e) {
- e.preventDefault();
- }
-}
-
-customElements.define(GrPermission.is, GrPermission);
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
new file mode 100644
index 0000000..c998cd8
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -0,0 +1,435 @@
+/**
+ * @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/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-menu-page-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-rule-editor/gr-rule-editor';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-permission_html';
+import {
+ toSortedPermissionsArray,
+ PermissionArrayItem,
+ PermissionArray,
+} from '../../../utils/access-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {
+ LabelNameToLabelTypeInfoMap,
+ LabelTypeInfoValues,
+ GroupInfo,
+ ProjectAccessGroups,
+ GroupId,
+ GitRef,
+} from '../../../types/common';
+import {
+ AutocompleteQuery,
+ GrAutocomplete,
+ AutocompleteSuggestion,
+ AutocompleteCommitEvent,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+ EditablePermissionInfo,
+ EditablePermissionRuleInfo,
+ EditableProjectAccessGroups,
+} from '../gr-repo-access/gr-repo-access-interfaces';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+
+const MAX_AUTOCOMPLETE_RESULTS = 20;
+
+const RANGE_NAMES = ['QUERY LIMIT', 'BATCH CHANGES LIMIT'];
+
+type GroupsWithRulesMap = {[ruleId: string]: boolean};
+
+export interface GrPermission {
+ $: {
+ restAPI: RestApiService & Element;
+ groupAutocomplete: GrAutocomplete;
+ };
+}
+
+interface ComputedLabelValue {
+ value: number;
+ text: string;
+}
+
+interface ComputedLabel {
+ name: string;
+ values: ComputedLabelValue[];
+}
+
+interface GroupSuggestion {
+ name: string;
+ value: GroupInfo;
+}
+
+/**
+ * Fired when the permission has been modified or removed.
+ *
+ * @event access-modified
+ */
+/**
+ * Fired when a permission that was previously added was removed.
+ *
+ * @event added-permission-removed
+ */
+@customElement('gr-permission')
+export class GrPermission extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ labels?: LabelNameToLabelTypeInfoMap;
+
+ @property({type: String})
+ name?: string;
+
+ @property({type: Object, observer: '_sortPermission', notify: true})
+ permission?: PermissionArrayItem<EditablePermissionInfo>;
+
+ @property({type: Object})
+ groups?: EditableProjectAccessGroups;
+
+ @property({type: String})
+ section?: GitRef;
+
+ @property({type: Boolean, observer: '_handleEditingChanged'})
+ editing = false;
+
+ @property({type: Object, computed: '_computeLabel(permission, labels)'})
+ _label?: ComputedLabel;
+
+ @property({type: String})
+ _groupFilter?: string;
+
+ @property({type: Object})
+ _query: AutocompleteQuery;
+
+ @property({type: Array})
+ _rules?: PermissionArray<EditablePermissionRuleInfo>;
+
+ @property({type: Object})
+ _groupsWithRules?: GroupsWithRulesMap;
+
+ @property({type: Boolean})
+ _deleted = false;
+
+ @property({type: Boolean})
+ _originalExclusiveValue?: boolean;
+
+ constructor() {
+ super();
+ this._query = () => this._getGroupSuggestions();
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('access-saved', () => this._handleAccessSaved());
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._setupValues();
+ }
+
+ _setupValues() {
+ if (!this.permission) {
+ return;
+ }
+ this._originalExclusiveValue = !!this.permission.value.exclusive;
+ flush();
+ }
+
+ _handleAccessSaved() {
+ // Set a new 'original' value to keep track of after the value has been
+ // saved.
+ this._setupValues();
+ }
+
+ _permissionIsOwnerOrGlobal(permissionId: string, section: string) {
+ return permissionId === 'owner' || section === 'GLOBAL_CAPABILITIES';
+ }
+
+ _handleEditingChanged(editing: boolean, editingOld: boolean) {
+ // Ignore when editing gets set initially.
+ if (!editingOld) {
+ return;
+ }
+ if (!this.permission || !this._rules) {
+ return;
+ }
+
+ // Restore original values if no longer editing.
+ if (!editing) {
+ this._deleted = false;
+ delete this.permission.value.deleted;
+ this._groupFilter = '';
+ this._rules = this._rules.filter(rule => !rule.value.added);
+ for (const key of Object.keys(this.permission.value.rules)) {
+ if (this.permission.value.rules[key].added) {
+ delete this.permission.value.rules[key];
+ }
+ }
+
+ // Restore exclusive bit to original.
+ this.set(
+ ['permission', 'value', 'exclusive'],
+ this._originalExclusiveValue
+ );
+ }
+ }
+
+ _handleAddedRuleRemoved(e: PolymerDomRepeatEvent) {
+ if (!this._rules) {
+ return;
+ }
+ const index = e.model.index;
+ this._rules = this._rules
+ .slice(0, index)
+ .concat(this._rules.slice(index + 1, this._rules.length));
+ }
+
+ _handleValueChange() {
+ if (!this.permission) {
+ return;
+ }
+ this.permission.value.modified = true;
+ // Allows overall access page to know a change has been made.
+ this.dispatchEvent(
+ new CustomEvent('access-modified', {bubbles: true, composed: true})
+ );
+ }
+
+ _handleRemovePermission() {
+ if (!this.permission) {
+ return;
+ }
+ if (this.permission.value.added) {
+ this.dispatchEvent(
+ new CustomEvent('added-permission-removed', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ this._deleted = true;
+ this.permission.value.deleted = true;
+ this.dispatchEvent(
+ new CustomEvent('access-modified', {bubbles: true, composed: true})
+ );
+ }
+
+ @observe('_rules.splices')
+ _handleRulesChanged() {
+ if (!this._rules) {
+ return;
+ }
+ // Update the groups to exclude in the autocomplete.
+ this._groupsWithRules = this._computeGroupsWithRules(this._rules);
+ }
+
+ _sortPermission(permission: PermissionArrayItem<EditablePermissionInfo>) {
+ this._rules = toSortedPermissionsArray(permission.value.rules);
+ }
+
+ _computeSectionClass(editing: boolean, deleted: boolean) {
+ const classList = [];
+ if (editing) {
+ classList.push('editing');
+ }
+ if (deleted) {
+ classList.push('deleted');
+ }
+ return classList.join(' ');
+ }
+
+ _handleUndoRemove() {
+ if (!this.permission) {
+ return;
+ }
+ this._deleted = false;
+ delete this.permission.value.deleted;
+ }
+
+ _computeLabel(
+ permission?: PermissionArrayItem<EditablePermissionInfo>,
+ labels?: LabelNameToLabelTypeInfoMap
+ ): ComputedLabel | undefined {
+ if (
+ !labels ||
+ !permission ||
+ !permission.value ||
+ !permission.value.label
+ ) {
+ return;
+ }
+
+ const labelName = permission.value.label;
+
+ // It is possible to have a label name that is not included in the
+ // 'labels' object. In this case, treat it like anything else.
+ if (!labels[labelName]) {
+ return;
+ }
+ return {
+ name: labelName,
+ values: this._computeLabelValues(labels[labelName].values),
+ };
+ }
+
+ _computeLabelValues(values: LabelTypeInfoValues): ComputedLabelValue[] {
+ const valuesArr: ComputedLabelValue[] = [];
+ const keys = Object.keys(values).sort((a, b) => Number(a) - Number(b));
+
+ for (const key of keys) {
+ let text = values[key];
+ if (!text) {
+ text = '';
+ }
+ // The value from the server being used to choose which item is
+ // selected is in integer form, so this must be converted.
+ valuesArr.push({value: Number(key), text});
+ }
+ return valuesArr;
+ }
+
+ _computeGroupsWithRules(
+ rules: PermissionArray<EditablePermissionRuleInfo>
+ ): GroupsWithRulesMap {
+ const groups: GroupsWithRulesMap = {};
+ for (const rule of rules) {
+ groups[rule.id] = true;
+ }
+ return groups;
+ }
+
+ _computeGroupName(groups: ProjectAccessGroups, groupId: GroupId) {
+ return groups && groups[groupId] && groups[groupId].name
+ ? groups[groupId].name
+ : groupId;
+ }
+
+ _getGroupSuggestions(): Promise<AutocompleteSuggestion[]> {
+ return this.$.restAPI
+ .getSuggestedGroups(this._groupFilter || '', MAX_AUTOCOMPLETE_RESULTS)
+ .then(response => {
+ const groups: GroupSuggestion[] = [];
+ for (const key in response) {
+ if (!hasOwnProperty(response, key)) {
+ continue;
+ }
+ groups.push({
+ name: key,
+ value: response[key],
+ });
+ }
+ // Does not return groups in which we already have rules for.
+ return groups
+ .filter(
+ group =>
+ this._groupsWithRules && !this._groupsWithRules[group.value.id]
+ )
+ .map((group: GroupSuggestion) => {
+ const autocompleteSuggestion: AutocompleteSuggestion = {
+ name: group.name,
+ value: group.value.id,
+ };
+ return autocompleteSuggestion;
+ });
+ });
+ }
+
+ /**
+ * Handles adding a skeleton item to the dom-repeat.
+ * gr-rule-editor handles setting the default values.
+ */
+ _handleAddRuleItem(e: AutocompleteCommitEvent) {
+ if (!this.permission || !this._rules) {
+ return;
+ }
+
+ // The group id is encoded, but have to decode in order for the access
+ // API to work as expected.
+ const groupId = decodeURIComponent(e.detail.value).replace(/\+/g, ' ');
+ // We cannot use "this.set(...)" here, because groupId may contain dots,
+ // and dots in property path names are totally unsupported by Polymer.
+ // Apparently Polymer picks up this change anyway, otherwise we should
+ // have looked at using MutableData:
+ // https://polymer-library.polymer-project.org/2.0/docs/devguide/data-system#mutable-data
+ // Actual value assigned below, after the flush
+ this.permission.value.rules[groupId] = {} as EditablePermissionRuleInfo;
+
+ // Purposely don't recompute sorted array so that the newly added rule
+ // is the last item of the array.
+ this.push('_rules', {
+ id: groupId,
+ });
+
+ // Add the new group name to the groups object so the name renders
+ // correctly.
+ if (this.groups && !this.groups[groupId]) {
+ this.groups[groupId] = {name: this.$.groupAutocomplete.text};
+ }
+
+ // Wait for new rule to get value populated via gr-rule-editor, and then
+ // add to permission values as well, so that the change gets propagated
+ // back to the section. Since the rule is inside a dom-repeat, a flush
+ // is needed.
+ flush();
+ const value = this._rules[this._rules.length - 1].value;
+ value.added = true;
+ // See comment above for why we cannot use "this.set(...)" here.
+ this.permission.value.rules[groupId] = value;
+ this.dispatchEvent(
+ new CustomEvent('access-modified', {bubbles: true, composed: true})
+ );
+ }
+
+ _computeHasRange(name: string) {
+ if (!name) {
+ return false;
+ }
+
+ return RANGE_NAMES.includes(name.toUpperCase());
+ }
+
+ /**
+ * Work around a issue on iOS when clicking turns into double tap
+ */
+ _onTapExclusiveToggle(e: Event) {
+ e.preventDefault();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-permission': GrPermission;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
index 835c90a..32430ec 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.js
@@ -193,10 +193,10 @@
assert.deepEqual(groups, [
{
name: 'Administrators',
- value: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f'},
+ value: '4c97682e6ce61b7247f3381b6f1789356666de7f',
}, {
name: 'Anonymous Users',
- value: {id: 'global%3AAnonymous-Users'},
+ value: 'global%3AAnonymous-Users',
},
]);
done();
@@ -211,7 +211,7 @@
element._getGroupSuggestions().then(groups => {
assert.deepEqual(groups, [{
name: 'Anonymous Users',
- value: {id: 'global%3AAnonymous-Users'},
+ value: 'global%3AAnonymous-Users',
}]);
done();
});
@@ -283,7 +283,7 @@
},
};
element._setupValues();
- flushAsynchronousOperations();
+ flush();
});
test('adding a rule', () => {
@@ -293,16 +293,14 @@
element.$.groupAutocomplete.text = 'ldap/tests te.st';
const e = {
detail: {
- value: {
- id: 'ldap:CN=test+te.st',
- },
+ value: 'ldap:CN=test+te.st',
},
};
element.editing = true;
assert.equal(element._rules.length, 2);
assert.equal(Object.keys(element._groupsWithRules).length, 2);
element._handleAddRuleItem(e);
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(element.groups, {'ldap:CN=test te.st': {
name: 'ldap/tests te.st'}});
assert.equal(element._rules.length, 3);
@@ -326,7 +324,7 @@
new CustomEvent('added-rule-removed', {
composed: true, bubbles: true,
}));
- flushAsynchronousOperations();
+ flush();
assert.equal(element._rules.length, 1);
});
@@ -369,7 +367,7 @@
assert.isNotOk(element.permission.value.modified);
MockInteractions.tap(element.shadowRoot
.querySelector('#exclusiveToggle'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.permission.value.exclusive);
assert.isTrue(element.permission.value.modified);
assert.isFalse(element._originalExclusiveValue);
@@ -392,7 +390,7 @@
.querySelector('#exclusiveToggle')).display,
'flex');
element.set(['permission', 'id'], 'owner');
- flushAsynchronousOperations();
+ flush();
assert.equal(getComputedStyle(element.shadowRoot
.querySelector('#exclusiveToggle')).display,
'none');
@@ -403,7 +401,7 @@
.querySelector('#exclusiveToggle')).display,
'flex');
element.section = 'GLOBAL_CAPABILITIES';
- flushAsynchronousOperations();
+ flush();
assert.equal(getComputedStyle(element.shadowRoot
.querySelector('#exclusiveToggle')).display,
'none');
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
deleted file mode 100644
index d9d37f6..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.js
+++ /dev/null
@@ -1,106 +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-input/iron-input.js';
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-plugin-config-array-editor_html.js';
-
-/** @extends PolymerElement */
-class GrPluginConfigArrayEditor extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-plugin-config-array-editor'; }
- /**
- * Fired when the plugin config option changes.
- *
- * @event plugin-config-option-changed
- */
-
- static get properties() {
- return {
- /** @type {?} */
- pluginOption: Object,
- /** @type {boolean} */
- disabled: {
- type: Boolean,
- computed: '_computeDisabled(pluginOption.*)',
- },
- /** @type {?} */
- _newValue: {
- type: String,
- value: '',
- },
- };
- }
-
- _computeDisabled(record) {
- return !(record && record.base && record.base.info &&
- record.base.info.editable);
- }
-
- _handleAddTap(e) {
- e.preventDefault();
- this._handleAdd();
- }
-
- _handleInputKeydown(e) {
- // Enter.
- if (e.keyCode === 13) {
- e.preventDefault();
- this._handleAdd();
- }
- }
-
- _handleAdd() {
- if (!this._newValue.length) { return; }
- this._dispatchChanged(
- this.pluginOption.info.values.concat([this._newValue]));
- this._newValue = '';
- }
-
- _handleDelete(e) {
- const value = dom(e).localTarget.dataset.item;
- this._dispatchChanged(
- this.pluginOption.info.values.filter(str => str !== value));
- }
-
- _dispatchChanged(values) {
- const {_key, info} = this.pluginOption;
- const detail = {
- _key,
- info: Object.assign(info, {values}, {}),
- notifyPath: `${_key}.values`,
- };
- this.dispatchEvent(
- new CustomEvent('plugin-config-option-changed', {detail}));
- }
-
- _computeShowInputRow(disabled) {
- return disabled ? 'hide' : '';
- }
-}
-
-customElements.define(GrPluginConfigArrayEditor.is,
- GrPluginConfigArrayEditor);
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
new file mode 100644
index 0000000..1ab32ea
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -0,0 +1,123 @@
+/**
+ * @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-input/iron-input';
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-plugin-config-array-editor_html';
+import {property, customElement} from '@polymer/decorators';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {
+ PluginConfigOptionsChangedEventDetail,
+ ArrayPluginOption,
+} from '../gr-repo-plugin-config/gr-repo-plugin-config-types';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-plugin-config-array-editor': GrPluginConfigArrayEditor;
+ }
+}
+
+@customElement('gr-plugin-config-array-editor')
+class GrPluginConfigArrayEditor extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the plugin config option changes.
+ *
+ * @event plugin-config-option-changed
+ */
+
+ @property({type: String})
+ _newValue = '';
+
+ // This property is never null, since this component in only about operations
+ // on pluginOption.
+ @property({type: Object})
+ pluginOption!: ArrayPluginOption;
+
+ @property({type: Boolean, computed: '_computeDisabled(pluginOption.*)'})
+ disabled?: boolean;
+
+ _computeDisabled(
+ record: PolymerDeepPropertyChange<ArrayPluginOption, ArrayPluginOption>
+ ) {
+ return !(
+ record &&
+ record.base &&
+ record.base.info &&
+ record.base.info.editable
+ );
+ }
+
+ _handleAddTap(e: MouseEvent) {
+ e.preventDefault();
+ this._handleAdd();
+ }
+
+ _handleInputKeydown(e: KeyboardEvent) {
+ // Enter.
+ if (e.keyCode === 13) {
+ e.preventDefault();
+ this._handleAdd();
+ }
+ }
+
+ _handleAdd() {
+ if (!this._newValue.length) {
+ return;
+ }
+ this._dispatchChanged(
+ this.pluginOption.info.values.concat([this._newValue])
+ );
+ this._newValue = '';
+ }
+
+ _handleDelete(e: MouseEvent) {
+ const value = ((dom(e) as EventApi).localTarget as HTMLElement).dataset[
+ 'item'
+ ];
+ this._dispatchChanged(
+ this.pluginOption.info.values.filter(str => str !== value)
+ );
+ }
+
+ _dispatchChanged(values: string[]) {
+ const {_key, info} = this.pluginOption;
+ const detail: PluginConfigOptionsChangedEventDetail = {
+ _key,
+ info: {...info, values},
+ notifyPath: `${_key}.values`,
+ };
+ this.dispatchEvent(
+ new CustomEvent('plugin-config-option-changed', {detail})
+ );
+ }
+
+ _computeShowInputRow(disabled: boolean) {
+ return disabled ? 'hide' : '';
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
index 9e9eb1c..dfc191f 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-plugin-config-array-editor.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-plugin-config-array-editor');
@@ -26,7 +25,7 @@
let dispatchStub;
- const getAll = str => dom(element.root).querySelectorAll(str);
+ const getAll = str => element.root.querySelectorAll(str);
setup(() => {
element = basicFixture.instantiate();
@@ -61,12 +60,12 @@
test('with enter', () => {
element._newValue = '';
MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
- flushAsynchronousOperations();
+ flush();
assert.isFalse(dispatchStub.called);
element._newValue = 'test';
MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
- flushAsynchronousOperations();
+ flush();
assert.isTrue(dispatchStub.called);
assert.equal(dispatchStub.lastCall.args[0], 'test');
@@ -76,12 +75,12 @@
test('with add btn', () => {
element._newValue = '';
MockInteractions.tap(element.$.addButton);
- flushAsynchronousOperations();
+ flush();
assert.isFalse(dispatchStub.called);
element._newValue = 'test';
MockInteractions.tap(element.$.addButton);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(dispatchStub.called);
assert.equal(dispatchStub.lastCall.args[0], 'test');
@@ -92,22 +91,22 @@
test('deleting', () => {
dispatchStub = sinon.stub(element, '_dispatchChanged');
element.pluginOption = {info: {values: ['test', 'test2']}};
- flushAsynchronousOperations();
+ flush();
const rows = getAll('.existingItems .row');
assert.equal(rows.length, 2);
const button = rows[0].querySelector('gr-button');
MockInteractions.tap(button);
- flushAsynchronousOperations();
+ flush();
assert.isFalse(dispatchStub.called);
element.pluginOption.info.editable = true;
element.notifyPath('pluginOption.info.editable');
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(button);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(dispatchStub.called);
assert.deepEqual(dispatchStub.lastCall.args[0], ['test2']);
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
deleted file mode 100644
index 3a54ad4..0000000
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
+++ /dev/null
@@ -1,133 +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-list-view/gr-list-view.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-plugin-list_html.js';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-
-/**
- * @appliesMixin ListViewMixin
- * @extends PolymerElement
- */
-class GrPluginList extends ListViewMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-plugin-list'; }
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
- /**
- * Offset of currently visible query results.
- */
- _offset: {
- type: Number,
- value: 0,
- },
- _path: {
- type: String,
- readOnly: true,
- value: '/admin/plugins',
- },
- _plugins: Array,
- /**
- * Because we request one more than the pluginsPerPage, _shownPlugins
- * maybe one less than _plugins.
- * */
- _shownPlugins: {
- type: Array,
- computed: 'computeShownItems(_plugins)',
- },
- _pluginsPerPage: {
- type: Number,
- value: 25,
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- _filter: {
- type: String,
- value: '',
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: 'Plugins'},
- composed: true, bubbles: true,
- }));
- }
-
- _paramsChanged(params) {
- this._loading = true;
- this._filter = this.getFilterValue(params);
- this._offset = this.getOffsetValue(params);
-
- return this._getPlugins(this._filter, this._pluginsPerPage,
- this._offset);
- }
-
- _getPlugins(filter, pluginsPerPage, offset) {
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
- return this.$.restAPI.getPlugins(filter, pluginsPerPage, offset, errFn)
- .then(plugins => {
- if (!plugins) {
- this._plugins = [];
- return;
- }
- this._plugins = Object.keys(plugins)
- .map(key => {
- const plugin = plugins[key];
- plugin.name = key;
- return plugin;
- });
- this._loading = false;
- });
- }
-
- _status(item) {
- return item.disabled === true ? 'Disabled' : 'Enabled';
- }
-
- _computePluginUrl(id) {
- return this.getUrl('/', id);
- }
-}
-
-customElements.define(GrPluginList.is, GrPluginList);
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
new file mode 100644
index 0000000..5039972
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.ts
@@ -0,0 +1,141 @@
+/**
+ * @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-list-view/gr-list-view';
+import '../../shared/gr-rest-api-interface/gr-rest-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-plugin-list_html';
+import {
+ ListViewMixin,
+ ListViewParams,
+} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {PluginInfo} from '../../../types/common';
+
+interface PluginInfoWithName extends PluginInfo {
+ name: string;
+}
+export interface GrPluginList {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-plugin-list')
+export class GrPluginList extends ListViewMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * URL params passed from the router.
+ */
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: ListViewParams;
+
+ /**
+ * Offset of currently visible query results.
+ */
+ @property({type: Number})
+ _offset = 0;
+
+ @property({type: String})
+ readonly _path = '/admin/plugins';
+
+ @property({type: Array})
+ _plugins?: PluginInfoWithName[];
+
+ /**
+ * Because we request one more than the pluginsPerPage, _shownPlugins
+ * maybe one less than _plugins.
+ **/
+ @property({type: Array, computed: 'computeShownItems(_plugins)'})
+ _shownPlugins?: PluginInfoWithName[];
+
+ @property({type: Number})
+ _pluginsPerPage = 25;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _filter = '';
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: 'Plugins'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _paramsChanged(params: ListViewParams) {
+ this._loading = true;
+ this._filter = this.getFilterValue(params);
+ this._offset = this.getOffsetValue(params);
+
+ return this._getPlugins(this._filter, this._pluginsPerPage, this._offset);
+ }
+
+ _getPlugins(filter: string, pluginsPerPage: number, offset?: number) {
+ const errFn: ErrorCallback = response => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+ return this.$.restAPI
+ .getPlugins(filter, pluginsPerPage, offset, errFn)
+ .then(plugins => {
+ if (!plugins) {
+ this._plugins = [];
+ return;
+ }
+ this._plugins = Object.keys(plugins).map(key => {
+ return {...plugins[key], name: key};
+ });
+ this._loading = false;
+ });
+ }
+
+ _status(item: PluginInfo) {
+ return item.disabled === true ? 'Disabled' : 'Enabled';
+ }
+
+ _computePluginUrl(id: string) {
+ return this.getUrl('/', id);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-plugin-list': GrPluginList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
index d60483e..7303748 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-plugin-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import 'lodash/lodash.js';
const basicFixture = fixtureFromElement('gr-plugin-list');
@@ -77,7 +77,7 @@
test('with and without urls', done => {
flush(() => {
- const names = dom(element.root).querySelectorAll('.name');
+ const names = element.root.querySelectorAll('.name');
assert.isOk(names[1].querySelector('a'));
assert.equal(names[1].querySelector('a').innerText, 'test1');
assert.isNotOk(names[2].querySelector('a'));
@@ -159,7 +159,7 @@
element._loading = false;
element._plugins = _.times(25, pluginGenerator);
- flushAsynchronousOperations();
+ flush();
assert.equal(element.computeLoadingClass(element._loading), '');
assert.equal(getComputedStyle(element.$.loading).display, 'none');
});
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
new file mode 100644
index 0000000..40a1e0a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * 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.
+ */
+
+/**
+ * @fileOverview This file contains interfaces shared between gr-repo-access
+ * and nested elements (gr-access-section, gr-permission)
+ */
+
+import {
+ AccessSectionInfo,
+ GroupInfo,
+ PermissionInfo,
+ PermissionRuleInfo,
+} from '../../../types/common';
+import {PermissionArrayItem} from '../../../utils/access-util';
+
+export type PrimitiveValue = string | boolean | number | undefined;
+
+export interface PropertyTreeNode {
+ [propName: string]: PropertyTreeNode | PrimitiveValue;
+ deleted?: boolean;
+ modified?: boolean;
+ added?: boolean;
+ updatedId?: string;
+}
+
+/**
+ * EditableLocalAccessSectionInfo is exactly the same as LocalAccessSectionInfo,
+ * but with additional properties: each nested object additionally implements
+ * interface PropertyTreeNode
+ */
+
+export type EditableLocalAccessSectionInfo = {
+ [ref: string]: EditableAccessSectionInfo;
+};
+
+export interface EditableAccessSectionInfo
+ extends AccessSectionInfo,
+ PropertyTreeNode {
+ permissions: EditableAccessPermissionsMap;
+}
+
+export type EditableAccessPermissionsMap = {
+ [permissionName: string]: EditablePermissionInfo;
+};
+
+export interface EditablePermissionInfo
+ extends PermissionInfo,
+ PropertyTreeNode {
+ rules: EditablePermissionInfoRules;
+}
+
+export type EditablePermissionInfoRules = {
+ [groupUUID: string]: EditablePermissionRuleInfo;
+};
+
+export interface EditablePermissionRuleInfo
+ extends PermissionRuleInfo,
+ PropertyTreeNode {}
+
+export type PermissionAccessSection = PermissionArrayItem<
+ EditableAccessSectionInfo
+>;
+
+export interface NewlyAddedGroupInfo {
+ name: string;
+}
+export type EditableProjectAccessGroups = {
+ [uuid: string]: GroupInfo | NewlyAddedGroupInfo;
+};
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
deleted file mode 100644
index 8bdc737..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.js
+++ /dev/null
@@ -1,518 +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-menu-page-styles.js';
-import '../../../styles/gr-subpage-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-access-section/gr-access-section.js';
-import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-repo-access_html.js';
-import {
- encodeURL,
- getBaseUrl,
- singleDecodeURL,
-} from '../../../utils/url-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {toSortedPermissionsArray} from '../../../utils/access-util.js';
-
-const Defs = {};
-
-const NOTHING_TO_SAVE = 'No changes to save.';
-
-const MAX_AUTOCOMPLETE_RESULTS = 50;
-
-/**
- * Fired when save is a no-op
- *
- * @event show-alert
- */
-
-/**
- * @typedef {{
- * value: !Object,
- * }}
- */
-Defs.rule;
-
-/**
- * @typedef {{
- * rules: !Object<string, Defs.rule>
- * }}
- */
-Defs.permission;
-
-/**
- * Can be an empty object or consist of permissions.
- *
- * @typedef {{
- * permissions: !Object<string, Defs.permission>
- * }}
- */
-Defs.permissions;
-
-/**
- * Can be an empty object or consist of permissions.
- *
- * @typedef {!Object<string, Defs.permissions>}
- */
-Defs.sections;
-
-/**
- * @typedef {{
- * remove: !Defs.sections,
- * add: !Defs.sections,
- * }}
- */
-Defs.projectAccessInput;
-
-/**
- * @extends PolymerElement
- */
-class GrRepoAccess extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo-access'; }
-
- static get properties() {
- return {
- repo: {
- type: String,
- observer: '_repoChanged',
- },
- // The current path
- path: String,
-
- _canUpload: {
- type: Boolean,
- value: false,
- },
- _inheritFromFilter: String,
- _query: {
- type: Function,
- value() {
- return this._getInheritFromSuggestions.bind(this);
- },
- },
- _ownerOf: Array,
- _capabilities: Object,
- _groups: Object,
- /** @type {?} */
- _inheritsFrom: Object,
- _labels: Object,
- _local: Object,
- _editing: {
- type: Boolean,
- value: false,
- observer: '_handleEditingChanged',
- },
- _modified: {
- type: Boolean,
- value: false,
- },
- _sections: Array,
- _weblinks: Array,
- _loading: {
- type: Boolean,
- value: true,
- },
- };
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('access-modified',
- () =>
- this._handleAccessModified());
- }
-
- _handleAccessModified() {
- this._modified = true;
- }
-
- /**
- * @param {string} repo
- * @return {!Promise}
- */
- _repoChanged(repo) {
- this._loading = true;
-
- if (!repo) { return Promise.resolve(); }
-
- return this._reload(repo);
- }
-
- _reload(repo) {
- const promises = [];
-
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
-
- this._editing = false;
-
- // Always reset sections when a project changes.
- this._sections = [];
- promises.push(this.$.restAPI.getRepoAccessRights(repo, errFn)
- .then(res => {
- if (!res) { return Promise.resolve(); }
-
- // Keep a copy of the original inherit from values separate from
- // the ones data bound to gr-autocomplete, so the original value
- // can be restored if the user cancels.
- this._inheritsFrom = res.inherits_from ? Object.assign({},
- res.inherits_from) : null;
- this._originalInheritsFrom = res.inherits_from ? Object.assign({},
- res.inherits_from) : null;
- // Initialize the filter value so when the user clicks edit, the
- // current value appears. If there is no parent repo, it is
- // initialized as an empty string.
- this._inheritFromFilter = res.inherits_from ?
- this._inheritsFrom.name : '';
- this._local = res.local;
- this._groups = res.groups;
- this._weblinks = res.config_web_links || [];
- this._canUpload = res.can_upload;
- this._ownerOf = res.owner_of || [];
- return toSortedPermissionsArray(this._local);
- }));
-
- promises.push(this.$.restAPI.getCapabilities(errFn)
- .then(res => {
- if (!res) { return Promise.resolve(); }
-
- return res;
- }));
-
- promises.push(this.$.restAPI.getRepo(repo, errFn)
- .then(res => {
- if (!res) { return Promise.resolve(); }
-
- return res.labels;
- }));
-
- return Promise.all(promises).then(([sections, capabilities, labels]) => {
- this._capabilities = capabilities;
- this._labels = labels;
- this._sections = sections;
- this._loading = false;
- });
- }
-
- _handleUpdateInheritFrom(e) {
- if (!this._inheritsFrom) {
- this._inheritsFrom = {};
- }
- this._inheritsFrom.id = e.detail.value;
- this._inheritsFrom.name = this._inheritFromFilter;
- this._handleAccessModified();
- }
-
- _getInheritFromSuggestions() {
- return this.$.restAPI.getRepos(
- this._inheritFromFilter,
- MAX_AUTOCOMPLETE_RESULTS)
- .then(response => {
- const projects = [];
- for (const key in response) {
- if (!response.hasOwnProperty(key)) { continue; }
- projects.push({
- name: response[key].name,
- value: response[key].id,
- });
- }
- return projects;
- });
- }
-
- _computeLoadingClass(loading) {
- return loading ? 'loading' : '';
- }
-
- _handleEdit() {
- this._editing = !this._editing;
- }
-
- _editOrCancel(editing) {
- return editing ? 'Cancel' : 'Edit';
- }
-
- _computeWebLinkClass(weblinks) {
- return weblinks && weblinks.length ? 'show' : '';
- }
-
- _computeShowInherit(inheritsFrom) {
- return inheritsFrom ? 'show' : '';
- }
-
- _handleAddedSectionRemoved(e) {
- const index = e.model.index;
- this._sections = this._sections.slice(0, index)
- .concat(this._sections.slice(index + 1, this._sections.length));
- }
-
- _handleEditingChanged(editing, editingOld) {
- // Ignore when editing gets set initially.
- if (!editingOld || editing) { return; }
- // Remove any unsaved but added refs.
- if (this._sections) {
- this._sections = this._sections.filter(p => !p.value.added);
- }
- // Restore inheritFrom.
- if (this._inheritsFrom) {
- this._inheritsFrom = Object.assign({}, this._originalInheritsFrom);
- this._inheritFromFilter = this._inheritsFrom.name;
- }
- for (const key of Object.keys(this._local)) {
- if (this._local[key].added) {
- delete this._local[key];
- }
- }
- }
-
- /**
- * @param {!Defs.projectAccessInput} addRemoveObj
- * @param {!Array} path
- * @param {string} type add or remove
- * @param {!Object=} opt_value value to add if the type is 'add'
- * @return {!Defs.projectAccessInput}
- */
- _updateAddRemoveObj(addRemoveObj, path, type, opt_value) {
- let curPos = addRemoveObj[type];
- for (const item of path) {
- if (!curPos[item]) {
- if (item === path[path.length - 1] && type === 'remove') {
- if (path[path.length - 2] === 'permissions') {
- curPos[item] = {rules: {}};
- } else if (path.length === 1) {
- curPos[item] = {permissions: {}};
- } else {
- curPos[item] = {};
- }
- } else if (item === path[path.length - 1] && type === 'add') {
- curPos[item] = opt_value;
- } else {
- curPos[item] = {};
- }
- }
- curPos = curPos[item];
- }
- return addRemoveObj;
- }
-
- /**
- * Used to recursively remove any objects with a 'deleted' bit.
- */
- _recursivelyRemoveDeleted(obj) {
- for (const k in obj) {
- if (!obj.hasOwnProperty(k)) { continue; }
-
- if (typeof obj[k] == 'object') {
- if (obj[k].deleted) {
- delete obj[k];
- return;
- }
- this._recursivelyRemoveDeleted(obj[k]);
- }
- }
- }
-
- _recursivelyUpdateAddRemoveObj(obj, addRemoveObj, path = []) {
- for (const k in obj) {
- if (!obj.hasOwnProperty(k)) { continue; }
- if (typeof obj[k] == 'object') {
- const updatedId = obj[k].updatedId;
- const ref = updatedId ? updatedId : k;
- if (obj[k].deleted) {
- this._updateAddRemoveObj(addRemoveObj,
- path.concat(k), 'remove');
- continue;
- } else if (obj[k].modified) {
- this._updateAddRemoveObj(addRemoveObj,
- path.concat(k), 'remove');
- this._updateAddRemoveObj(addRemoveObj, path.concat(ref), 'add',
- obj[k]);
- /* Special case for ref changes because they need to be added and
- removed in a different way. The new ref needs to include all
- changes but also the initial state. To do this, instead of
- continuing with the same recursion, just remove anything that is
- deleted in the current state. */
- if (updatedId && updatedId !== k) {
- this._recursivelyRemoveDeleted(addRemoveObj.add[updatedId]);
- }
- continue;
- } else if (obj[k].added) {
- this._updateAddRemoveObj(addRemoveObj,
- path.concat(ref), 'add', obj[k]);
- /**
- * As add / delete both can happen in the new section,
- * so here to make sure it will remove the deleted ones.
- *
- * @see Issue 11339
- */
- this._recursivelyRemoveDeleted(addRemoveObj.add[k]);
- continue;
- }
- this._recursivelyUpdateAddRemoveObj(obj[k], addRemoveObj,
- path.concat(k));
- }
- }
- }
-
- /**
- * Returns an object formatted for saving or submitting access changes for
- * review
- *
- * @return {!Defs.projectAccessInput}
- */
- _computeAddAndRemove() {
- const addRemoveObj = {
- add: {},
- remove: {},
- };
-
- const originalInheritsFromId = this._originalInheritsFrom ?
- singleDecodeURL(this._originalInheritsFrom.id) : null;
- const inheritsFromId = this._inheritsFrom ?
- singleDecodeURL(this._inheritsFrom.id) : null;
-
- const inheritFromChanged =
- // Inherit from changed
- (originalInheritsFromId &&
- originalInheritsFromId !== inheritsFromId) ||
- // Inherit from added (did not have one initially);
- (!originalInheritsFromId && inheritsFromId);
-
- this._recursivelyUpdateAddRemoveObj(this._local, addRemoveObj);
-
- if (inheritFromChanged) {
- addRemoveObj.parent = inheritsFromId;
- }
- return addRemoveObj;
- }
-
- _handleCreateSection() {
- let newRef = 'refs/for/*';
- // Avoid using an already used key for the placeholder, since it
- // immediately gets added to an object.
- while (this._local[newRef]) {
- newRef = `${newRef}*`;
- }
- const section = {permissions: {}, added: true};
- this.push('_sections', {id: newRef, value: section});
- this.set(['_local', newRef], section);
- flush();
- dom(this.root).querySelector('gr-access-section:last-of-type')
- .editReference();
- }
-
- _getObjforSave() {
- const addRemoveObj = this._computeAddAndRemove();
- // If there are no changes, don't actually save.
- if (!Object.keys(addRemoveObj.add).length &&
- !Object.keys(addRemoveObj.remove).length &&
- !addRemoveObj.parent) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: NOTHING_TO_SAVE},
- bubbles: true,
- composed: true,
- }));
- return;
- }
- const obj = {
- add: addRemoveObj.add,
- remove: addRemoveObj.remove,
- };
- if (addRemoveObj.parent) {
- obj.parent = addRemoveObj.parent;
- }
- return obj;
- }
-
- _handleSave(e) {
- const obj = this._getObjforSave();
- if (!obj) { return; }
- const button = e && e.target;
- if (button) {
- button.loading = true;
- }
- return this.$.restAPI.setRepoAccessRights(this.repo, obj)
- .then(() => {
- this._reload(this.repo);
- })
- .finally(() => {
- this._modified = false;
- if (button) {
- button.loading = false;
- }
- });
- }
-
- _handleSaveForReview(e) {
- const obj = this._getObjforSave();
- if (!obj) { return; }
- const button = e && e.target;
- if (button) {
- button.loading = true;
- }
- return this.$.restAPI
- .setRepoAccessRightsForReview(this.repo, obj)
- .then(change => {
- GerritNav.navigateToChange(change);
- })
- .finally(() => {
- this._modified = false;
- if (button) {
- button.loading = false;
- }
- });
- }
-
- _computeSaveReviewBtnClass(canUpload) {
- return !canUpload ? 'invisible' : '';
- }
-
- _computeSaveBtnClass(ownerOf) {
- return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
- }
-
- _computeMainClass(ownerOf, canUpload, editing) {
- const classList = [];
- if (ownerOf && ownerOf.length > 0 || canUpload) {
- classList.push('admin');
- }
- if (editing) {
- classList.push('editing');
- }
- return classList.join(' ');
- }
-
- _computeParentHref(repoName) {
- return getBaseUrl() +
- `/admin/repos/${encodeURL(repoName, true)},access`;
- }
-}
-
-customElements.define(GrRepoAccess.is, GrRepoAccess);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
new file mode 100644
index 0000000..b96ec2c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access.ts
@@ -0,0 +1,621 @@
+/**
+ * @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-menu-page-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-access-section/gr-access-section';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-repo-access_html';
+import {encodeURL, getBaseUrl, singleDecodeURL} from '../../../utils/url-util';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {toSortedPermissionsArray} from '../../../utils/access-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+ RepoName,
+ ProjectInfo,
+ CapabilityInfoMap,
+ LabelNameToLabelTypeInfoMap,
+ ProjectAccessInput,
+ GitRef,
+ UrlEncodedRepoName,
+ ProjectAccessGroups,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrAccessSection} from '../gr-access-section/gr-access-section';
+import {
+ AutocompleteQuery,
+ AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {
+ EditableLocalAccessSectionInfo,
+ PermissionAccessSection,
+ PropertyTreeNode,
+ PrimitiveValue,
+} from './gr-repo-access-interfaces';
+
+const NOTHING_TO_SAVE = 'No changes to save.';
+
+const MAX_AUTOCOMPLETE_RESULTS = 50;
+
+export interface GrRepoAccess {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+/**
+ * Fired when save is a no-op
+ *
+ * @event show-alert
+ */
+@customElement('gr-repo-access')
+export class GrRepoAccess extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, observer: '_repoChanged'})
+ repo?: RepoName;
+
+ @property({type: String})
+ path?: string;
+
+ @property({type: Boolean})
+ _canUpload?: boolean = false; // restAPI can return undefined
+
+ @property({type: String})
+ _inheritFromFilter?: RepoName;
+
+ @property({type: Object})
+ _query: AutocompleteQuery;
+
+ @property({type: Array})
+ _ownerOf?: GitRef[];
+
+ @property({type: Object})
+ _capabilities?: CapabilityInfoMap;
+
+ @property({type: Object})
+ _groups?: ProjectAccessGroups;
+
+ @property({type: Object})
+ _inheritsFrom?: ProjectInfo | null | {};
+
+ @property({type: Object})
+ _labels?: LabelNameToLabelTypeInfoMap;
+
+ @property({type: Object})
+ _local?: EditableLocalAccessSectionInfo;
+
+ @property({type: Boolean, observer: '_handleEditingChanged'})
+ _editing = false;
+
+ @property({type: Boolean})
+ _modified = false;
+
+ @property({type: Array})
+ _sections?: PermissionAccessSection[];
+
+ @property({type: Array})
+ _weblinks?: string[];
+
+ @property({type: Boolean})
+ _loading = true;
+
+ private _originalInheritsFrom?: ProjectInfo | null;
+
+ constructor() {
+ super();
+ this._query = () => this._getInheritFromSuggestions();
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('access-modified', () =>
+ this._handleAccessModified()
+ );
+ }
+
+ _handleAccessModified() {
+ this._modified = true;
+ }
+
+ _repoChanged(repo: RepoName) {
+ this._loading = true;
+
+ if (!repo) {
+ return Promise.resolve();
+ }
+
+ return this._reload(repo);
+ }
+
+ _reload(repo: RepoName) {
+ const errFn = (response?: Response | null) => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+
+ this._editing = false;
+
+ // Always reset sections when a project changes.
+ this._sections = [];
+ const sectionsPromises = this.$.restAPI
+ .getRepoAccessRights(repo, errFn)
+ .then(res => {
+ if (!res) {
+ return Promise.resolve(undefined);
+ }
+
+ // Keep a copy of the original inherit from values separate from
+ // the ones data bound to gr-autocomplete, so the original value
+ // can be restored if the user cancels.
+ this._inheritsFrom = res.inherits_from
+ ? {
+ ...res.inherits_from,
+ }
+ : null;
+ this._originalInheritsFrom = res.inherits_from
+ ? {
+ ...res.inherits_from,
+ }
+ : null;
+ // Initialize the filter value so when the user clicks edit, the
+ // current value appears. If there is no parent repo, it is
+ // initialized as an empty string.
+ this._inheritFromFilter = res.inherits_from
+ ? res.inherits_from.name
+ : ('' as RepoName);
+ // 'as EditableLocalAccessSectionInfo' is required because res.local
+ // type doesn't have index signature
+ this._local = res.local as EditableLocalAccessSectionInfo;
+ this._groups = res.groups;
+ this._weblinks = res.config_web_links || [];
+ this._canUpload = res.can_upload;
+ this._ownerOf = res.owner_of || [];
+ return toSortedPermissionsArray(this._local);
+ });
+
+ const capabilitiesPromises = this.$.restAPI
+ .getCapabilities(errFn)
+ .then(res => {
+ if (!res) {
+ return Promise.resolve(undefined);
+ }
+
+ return res;
+ });
+
+ const labelsPromises = this.$.restAPI.getRepo(repo, errFn).then(res => {
+ if (!res) {
+ return Promise.resolve(undefined);
+ }
+
+ return res.labels;
+ });
+
+ return Promise.all([
+ sectionsPromises,
+ capabilitiesPromises,
+ labelsPromises,
+ ]).then(([sections, capabilities, labels]) => {
+ this._capabilities = capabilities;
+ this._labels = labels;
+ this._sections = sections;
+ this._loading = false;
+ });
+ }
+
+ _handleUpdateInheritFrom(e: CustomEvent<{value: string}>) {
+ const parentProject: ProjectInfo = {
+ id: e.detail.value as UrlEncodedRepoName,
+ name: this._inheritFromFilter,
+ };
+ if (!this._inheritsFrom) {
+ this._inheritsFrom = parentProject;
+ } else {
+ // TODO(TS): replace with
+ // this._inheritsFrom = {...this._inheritsFrom, ...parentProject};
+ const projectInfo = this._inheritsFrom as ProjectInfo;
+ projectInfo.id = parentProject.id;
+ projectInfo.name = parentProject.name;
+ }
+ this._handleAccessModified();
+ }
+
+ _getInheritFromSuggestions(): Promise<AutocompleteSuggestion[]> {
+ return this.$.restAPI
+ .getRepos(this._inheritFromFilter, MAX_AUTOCOMPLETE_RESULTS)
+ .then(response => {
+ const projects: AutocompleteSuggestion[] = [];
+ if (!response) {
+ return projects;
+ }
+ for (const item of response) {
+ projects.push({
+ name: item.name,
+ value: item.id,
+ });
+ }
+ return projects;
+ });
+ }
+
+ _computeLoadingClass(loading: boolean) {
+ return loading ? 'loading' : '';
+ }
+
+ _handleEdit() {
+ this._editing = !this._editing;
+ }
+
+ _editOrCancel(editing: boolean) {
+ return editing ? 'Cancel' : 'Edit';
+ }
+
+ _computeWebLinkClass(weblinks?: string[]) {
+ return weblinks && weblinks.length ? 'show' : '';
+ }
+
+ _computeShowInherit(inheritsFrom?: RepoName) {
+ return inheritsFrom ? 'show' : '';
+ }
+
+ // TODO(TS): Unclear what is model here, provide a better explanation
+ _handleAddedSectionRemoved(e: CustomEvent & {model: {index: string}}) {
+ if (!this._sections) {
+ return;
+ }
+ const index = Number(e.model.index);
+ if (isNaN(index)) {
+ return;
+ }
+ this._sections = this._sections
+ .slice(0, index)
+ .concat(this._sections.slice(index + 1, this._sections.length));
+ }
+
+ _handleEditingChanged(editing: boolean, editingOld: boolean) {
+ // Ignore when editing gets set initially.
+ if (!editingOld || editing) {
+ return;
+ }
+ // Remove any unsaved but added refs.
+ if (this._sections) {
+ this._sections = this._sections.filter(p => !p.value.added);
+ }
+ // Restore inheritFrom.
+ if (this._inheritsFrom) {
+ this._inheritsFrom = {...this._originalInheritsFrom};
+ this._inheritFromFilter =
+ 'name' in this._inheritsFrom ? this._inheritsFrom.name : undefined;
+ }
+ if (!this._local) {
+ return;
+ }
+ for (const key of Object.keys(this._local)) {
+ if (this._local[key].added) {
+ delete this._local[key];
+ }
+ }
+ }
+
+ _updateRemoveObj(addRemoveObj: {remove: PropertyTreeNode}, path: string[]) {
+ let curPos: PropertyTreeNode = addRemoveObj.remove;
+ for (const item of path) {
+ if (!curPos[item]) {
+ if (item === path[path.length - 1]) {
+ if (path[path.length - 2] === 'permissions') {
+ curPos[item] = {rules: {}};
+ } else if (path.length === 1) {
+ curPos[item] = {permissions: {}};
+ } else {
+ curPos[item] = {};
+ }
+ } else {
+ curPos[item] = {};
+ }
+ }
+ // The last item can be a PrimitiveValue, but we don't use it
+ // All intermediate items are PropertyTreeNode
+ // TODO(TS): rewrite this loop and process the last item explicitly
+ curPos = curPos[item] as PropertyTreeNode;
+ }
+ return addRemoveObj;
+ }
+
+ _updateAddObj(
+ addRemoveObj: {add: PropertyTreeNode},
+ path: string[],
+ value: PropertyTreeNode | PrimitiveValue
+ ) {
+ let curPos: PropertyTreeNode = addRemoveObj.add;
+ for (const item of path) {
+ if (!curPos[item]) {
+ if (item === path[path.length - 1]) {
+ curPos[item] = value;
+ } else {
+ curPos[item] = {};
+ }
+ }
+ // The last item can be a PrimitiveValue, but we don't use it
+ // All intermediate items are PropertyTreeNode
+ // TODO(TS): rewrite this loop and process the last item explicitly
+ curPos = curPos[item] as PropertyTreeNode;
+ }
+ return addRemoveObj;
+ }
+
+ /**
+ * Used to recursively remove any objects with a 'deleted' bit.
+ */
+ _recursivelyRemoveDeleted(obj: PropertyTreeNode) {
+ for (const k in obj) {
+ if (!hasOwnProperty(obj, k)) {
+ continue;
+ }
+ const node = obj[k];
+ if (typeof node === 'object') {
+ if (node.deleted) {
+ delete obj[k];
+ return;
+ }
+ this._recursivelyRemoveDeleted(node);
+ }
+ }
+ }
+
+ _recursivelyUpdateAddRemoveObj(
+ obj: PropertyTreeNode,
+ addRemoveObj: {
+ add: PropertyTreeNode;
+ remove: PropertyTreeNode;
+ },
+ path: string[] = []
+ ) {
+ for (const k in obj) {
+ if (!hasOwnProperty(obj, k)) {
+ continue;
+ }
+ const node = obj[k];
+ if (typeof node === 'object') {
+ const updatedId = node.updatedId;
+ const ref = updatedId ? updatedId : k;
+ if (node.deleted) {
+ this._updateRemoveObj(addRemoveObj, path.concat(k));
+ continue;
+ } else if (node.modified) {
+ this._updateRemoveObj(addRemoveObj, path.concat(k));
+ this._updateAddObj(addRemoveObj, path.concat(ref), node);
+ /* Special case for ref changes because they need to be added and
+ removed in a different way. The new ref needs to include all
+ changes but also the initial state. To do this, instead of
+ continuing with the same recursion, just remove anything that is
+ deleted in the current state. */
+ if (updatedId && updatedId !== k) {
+ this._recursivelyRemoveDeleted(
+ addRemoveObj.add[updatedId] as PropertyTreeNode
+ );
+ }
+ continue;
+ } else if (node.added) {
+ this._updateAddObj(addRemoveObj, path.concat(ref), node);
+ /**
+ * As add / delete both can happen in the new section,
+ * so here to make sure it will remove the deleted ones.
+ *
+ * @see Issue 11339
+ */
+ this._recursivelyRemoveDeleted(
+ addRemoveObj.add[k] as PropertyTreeNode
+ );
+ continue;
+ }
+ this._recursivelyUpdateAddRemoveObj(node, addRemoveObj, path.concat(k));
+ }
+ }
+ }
+
+ /**
+ * Returns an object formatted for saving or submitting access changes for
+ * review
+ */
+ _computeAddAndRemove() {
+ const addRemoveObj: {
+ add: PropertyTreeNode;
+ remove: PropertyTreeNode;
+ parent?: string | null;
+ } = {
+ add: {},
+ remove: {},
+ };
+
+ const originalInheritsFromId = this._originalInheritsFrom
+ ? singleDecodeURL(this._originalInheritsFrom.id)
+ : null;
+ // TODO(TS): this._inheritsFrom as ProjectInfo might be a mistake.
+ // _inheritsFrom can be {}
+ const inheritsFromId = this._inheritsFrom
+ ? singleDecodeURL((this._inheritsFrom as ProjectInfo).id)
+ : null;
+
+ const inheritFromChanged =
+ // Inherit from changed
+ (originalInheritsFromId && originalInheritsFromId !== inheritsFromId) ||
+ // Inherit from added (did not have one initially);
+ (!originalInheritsFromId && inheritsFromId);
+
+ if (!this._local) {
+ return addRemoveObj;
+ }
+
+ this._recursivelyUpdateAddRemoveObj(
+ (this._local as unknown) as PropertyTreeNode,
+ addRemoveObj
+ );
+
+ if (inheritFromChanged) {
+ addRemoveObj.parent = inheritsFromId;
+ }
+ return addRemoveObj;
+ }
+
+ _handleCreateSection() {
+ if (!this._local) {
+ return;
+ }
+ let newRef = 'refs/for/*';
+ // Avoid using an already used key for the placeholder, since it
+ // immediately gets added to an object.
+ while (this._local[newRef]) {
+ newRef = `${newRef}*`;
+ }
+ const section = {permissions: {}, added: true};
+ this.push('_sections', {id: newRef, value: section});
+ this.set(['_local', newRef], section);
+ flush();
+ // Template already instantiated at this point
+ (this.root!.querySelector(
+ 'gr-access-section:last-of-type'
+ ) as GrAccessSection).editReference();
+ }
+
+ _getObjforSave(): ProjectAccessInput | undefined {
+ const addRemoveObj = this._computeAddAndRemove();
+ // If there are no changes, don't actually save.
+ if (
+ !Object.keys(addRemoveObj.add).length &&
+ !Object.keys(addRemoveObj.remove).length &&
+ !addRemoveObj.parent
+ ) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: NOTHING_TO_SAVE},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ return;
+ }
+ const obj: ProjectAccessInput = ({
+ add: addRemoveObj.add,
+ remove: addRemoveObj.remove,
+ } as unknown) as ProjectAccessInput;
+ if (addRemoveObj.parent) {
+ obj.parent = addRemoveObj.parent;
+ }
+ return obj;
+ }
+
+ _handleSave(e: Event) {
+ const obj = this._getObjforSave();
+ if (!obj) {
+ return;
+ }
+ const button = e && (e.target as GrButton);
+ if (button) {
+ button.loading = true;
+ }
+ const repo = this.repo;
+ if (!repo) {
+ return Promise.resolve();
+ }
+ return this.$.restAPI
+ .setRepoAccessRights(repo, obj)
+ .then(() => {
+ this._reload(repo);
+ })
+ .finally(() => {
+ this._modified = false;
+ if (button) {
+ button.loading = false;
+ }
+ });
+ }
+
+ _handleSaveForReview(e: Event) {
+ const obj = this._getObjforSave();
+ if (!obj) {
+ return;
+ }
+ const button = e && (e.target as GrButton);
+ if (button) {
+ button.loading = true;
+ }
+ if (!this.repo) {
+ return;
+ }
+ return this.$.restAPI
+ .setRepoAccessRightsForReview(this.repo, obj)
+ .then(change => {
+ GerritNav.navigateToChange(change);
+ })
+ .finally(() => {
+ this._modified = false;
+ if (button) {
+ button.loading = false;
+ }
+ });
+ }
+
+ _computeSaveReviewBtnClass(canUpload?: boolean) {
+ return !canUpload ? 'invisible' : '';
+ }
+
+ _computeSaveBtnClass(ownerOf?: GitRef[]) {
+ return ownerOf && ownerOf.length === 0 ? 'invisible' : '';
+ }
+
+ _computeMainClass(
+ ownerOf: GitRef[] | undefined,
+ canUpload: boolean,
+ editing: boolean
+ ) {
+ const classList = [];
+ if ((ownerOf && ownerOf.length > 0) || canUpload) {
+ classList.push('admin');
+ }
+ if (editing) {
+ classList.push('editing');
+ }
+ return classList.join(' ');
+ }
+
+ _computeParentHref(repoName: RepoName) {
+ return getBaseUrl() + `/admin/repos/${encodeURL(repoName, true)},access`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo-access': GrRepoAccess;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
index c60a1fe..d3204e1 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access_test.js
@@ -209,7 +209,7 @@
name: 'another-repo',
};
// When there is a parent project, the link should be displayed.
- flushAsynchronousOperations();
+ flush();
assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
'none');
@@ -266,13 +266,13 @@
element._inheritsFrom = {
id: 'test-project',
};
- flushAsynchronousOperations();
+ flush();
assert.equal(getComputedStyle(element.shadowRoot
.querySelector('#editInheritFromInput'))
.display, 'none');
MockInteractions.tap(element.$.editBtn);
- flushAsynchronousOperations();
+ flush();
// Edit button changes to Cancel button, and Save button is visible but
// disabled.
@@ -312,7 +312,7 @@
element._groups = JSON.parse(JSON.stringify(accessRes.groups));
element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
element._labels = JSON.parse(JSON.stringify(repoRes.labels));
- flushAsynchronousOperations();
+ flush();
});
test('removing an added section', () => {
@@ -323,7 +323,7 @@
new CustomEvent('added-section-removed', {
composed: true, bubbles: true,
}));
- flushAsynchronousOperations();
+ flush();
assert.equal(element._sections.length, 0);
});
@@ -361,7 +361,7 @@
element._inheritsFrom = {
id: 'test-project',
};
- flushAsynchronousOperations();
+ flush();
element.shadowRoot.querySelector('#editInheritFromInput').dispatchEvent(
new CustomEvent('commit', {
detail: {},
@@ -586,9 +586,9 @@
.querySelector('gr-access-section').shadowRoot
.querySelector('gr-permission')
._handleAddRuleItem(
- {detail: {value: {id: 'Maintainers'}}});
+ {detail: {value: 'Maintainers'}});
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Remove the added rule.
@@ -656,7 +656,7 @@
};
element.shadowRoot
.querySelector('gr-access-section')._handleAddPermission();
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Add a new rule to the new permission.
@@ -686,7 +686,7 @@
.querySelector('gr-access-section').root).querySelectorAll(
'gr-permission')[2];
newPermission._handleAddRuleItem(
- {detail: {value: {id: 'Maintainers'}}});
+ {detail: {value: 'Maintainers'}});
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Modify a section reference.
@@ -777,7 +777,7 @@
const newSection = dom(element.root)
.querySelectorAll('gr-access-section')[1];
newSection._handleAddPermission();
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Add rule to the new permission.
@@ -806,9 +806,9 @@
newSection.shadowRoot
.querySelector('gr-permission')._handleAddRuleItem(
- {detail: {value: {id: 'Maintainers'}}});
+ {detail: {value: 'Maintainers'}});
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(element._computeAddAndRemove(), expectedInput);
// Modify a the reference from the default value.
@@ -921,7 +921,7 @@
.querySelector('gr-access-section').root).querySelectorAll(
'gr-permission')[1];
readPermission._handleAddRuleItem(
- {detail: {value: {id: 'Maintainers'}}});
+ {detail: {value: 'Maintainers'}});
expectedInput = {
add: {
@@ -994,10 +994,10 @@
let newSection = dom(element.root)
.querySelectorAll('gr-access-section')[1];
newSection._handleAddPermission();
- flushAsynchronousOperations();
+ flush();
newSection.shadowRoot
.querySelector('gr-permission')._handleAddRuleItem(
- {detail: {value: {id: 'Maintainers'}}});
+ {detail: {value: 'Maintainers'}});
// Modify a the reference from the default value.
element._local['refs/for/*'].updatedId = 'refs/for/new';
@@ -1068,10 +1068,10 @@
newSection = dom(element.root)
.querySelectorAll('gr-access-section')[2];
newSection._handleAddPermission();
- flushAsynchronousOperations();
+ flush();
newSection.shadowRoot
.querySelector('gr-permission')._handleAddRuleItem(
- {detail: {value: {id: 'Maintainers'}}});
+ {detail: {value: 'Maintainers'}});
// Modify a the reference from the default value.
element._local['refs/for/**'].updatedId = 'refs/for/new2';
expectedInput = {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
deleted file mode 100644
index 4ab1b98..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.js
+++ /dev/null
@@ -1,158 +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/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-change-dialog/gr-create-change-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-repo-commands_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const GC_MESSAGE = 'Garbage collection completed successfully.';
-
-const CONFIG_BRANCH = 'refs/meta/config';
-const CONFIG_PATH = 'project.config';
-const EDIT_CONFIG_SUBJECT = 'Edit Repo Config';
-const INITIAL_PATCHSET = 1;
-const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
-const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
-
-/**
- * @extends PolymerElement
- */
-class GrRepoCommands extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo-commands'; }
-
- static get properties() {
- return {
- params: Object,
- repo: String,
- _loading: {
- type: Boolean,
- value: true,
- },
- /** @type {?} */
- _repoConfig: Object,
- _canCreate: Boolean,
- // states
- _creatingChange: Boolean,
- _editingConfig: Boolean,
- _runningGC: Boolean,
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadRepo();
-
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: 'Repo Commands'},
- composed: true, bubbles: true,
- }));
- }
-
- _loadRepo() {
- if (!this.repo) { return Promise.resolve(); }
-
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
-
- return this.$.restAPI.getProjectConfig(this.repo, errFn)
- .then(config => {
- if (!config) { return Promise.resolve(); }
-
- this._repoConfig = config;
- this._loading = false;
- });
- }
-
- _computeLoadingClass(loading) {
- return loading ? 'loading' : '';
- }
-
- _isLoading() {
- return this._loading || this._loading === undefined;
- }
-
- _handleRunningGC() {
- this._runningGC = true;
- return this.$.restAPI.runRepoGC(this.repo).then(response => {
- if (response.status === 200) {
- this.dispatchEvent(new CustomEvent(
- 'show-alert',
- {detail: {message: GC_MESSAGE}, bubbles: true, composed: true}));
- }
- })
- .finally(() => {
- this._runningGC = false;
- });
- }
-
- _createNewChange() {
- this.$.createChangeOverlay.open();
- }
-
- _handleCreateChange() {
- this._creatingChange = true;
- this.$.createNewChangeModal.handleCreateChange()
- .finally(() => {
- this._creatingChange = false;
- });
- this._handleCloseCreateChange();
- }
-
- _handleCloseCreateChange() {
- this.$.createChangeOverlay.close();
- }
-
- _handleEditRepoConfig() {
- this._editingConfig = true;
- return this.$.restAPI.createChange(this.repo, CONFIG_BRANCH,
- EDIT_CONFIG_SUBJECT, undefined, false, true).then(change => {
- const message = change ?
- CREATE_CHANGE_SUCCEEDED_MESSAGE :
- CREATE_CHANGE_FAILED_MESSAGE;
- this.dispatchEvent(new CustomEvent('show-alert',
- {detail: {message}, bubbles: true, composed: true}));
- if (!change) { return; }
-
- GerritNav.navigateToRelativeUrl(GerritNav.getEditUrlForDiff(
- change, CONFIG_PATH, INITIAL_PATCHSET));
- })
- .finally(() => {
- this._editingConfig = false;
- });
- }
-}
-
-customElements.define(GrRepoCommands.is, GrRepoCommands);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
new file mode 100644
index 0000000..a74f4bb
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands.ts
@@ -0,0 +1,218 @@
+/**
+ * @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/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-change-dialog/gr-create-change-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-repo-commands_html';
+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 {
+ BranchName,
+ ConfigInfo,
+ PatchSetNum,
+ RepoName,
+} from '../../../types/common';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrCreateChangeDialog} from '../gr-create-change-dialog/gr-create-change-dialog';
+
+const GC_MESSAGE = 'Garbage collection completed successfully.';
+const CONFIG_BRANCH = 'refs/meta/config' as BranchName;
+const CONFIG_PATH = 'project.config';
+const EDIT_CONFIG_SUBJECT = 'Edit Repo Config';
+const INITIAL_PATCHSET = 1 as PatchSetNum;
+const CREATE_CHANGE_FAILED_MESSAGE = 'Failed to create change.';
+const CREATE_CHANGE_SUCCEEDED_MESSAGE = 'Navigating to change';
+
+export interface GrRepoCommands {
+ $: {
+ restAPI: RestApiService & Element;
+ createChangeOverlay: GrOverlay;
+ createNewChangeModal: GrCreateChangeDialog;
+ };
+}
+
+@customElement('gr-repo-commands')
+export class GrRepoCommands extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ // This is a required property. Without `repo` being set the component is not
+ // useful. Thus using !.
+ @property({type: String})
+ repo!: RepoName;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Object})
+ _repoConfig?: ConfigInfo;
+
+ @property({type: Boolean})
+ _canCreate = false;
+
+ @property({type: Boolean})
+ _creatingChange = false;
+
+ @property({type: Boolean})
+ _editingConfig = false;
+
+ @property({type: Boolean})
+ _runningGC = false;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadRepo();
+
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: 'Repo Commands'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _loadRepo() {
+ const errFn: ErrorCallback = response => {
+ // Do not process the error, if the component is not attached to the DOM
+ // anymore, which at least in tests can happen.
+ if (!this.isConnected) return;
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+
+ this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+ if (!config) return;
+ // Do not process the response, if the component is not attached to the
+ // DOM anymore, which at least in tests can happen.
+ if (!this.isConnected) return;
+ this._repoConfig = config;
+ this._loading = false;
+ });
+ }
+
+ _computeLoadingClass(loading: boolean) {
+ return loading ? 'loading' : '';
+ }
+
+ _isLoading() {
+ return this._loading;
+ }
+
+ _handleRunningGC() {
+ this._runningGC = true;
+ return this.$.restAPI
+ .runRepoGC(this.repo)
+ .then(response => {
+ if (response?.status === 200) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: GC_MESSAGE},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ })
+ .finally(() => {
+ this._runningGC = false;
+ });
+ }
+
+ _createNewChange() {
+ this.$.createChangeOverlay.open();
+ }
+
+ _handleCreateChange() {
+ this._creatingChange = true;
+ this.$.createNewChangeModal.handleCreateChange().finally(() => {
+ this._creatingChange = false;
+ });
+ this._handleCloseCreateChange();
+ }
+
+ _handleCloseCreateChange() {
+ this.$.createChangeOverlay.close();
+ }
+
+ /**
+ * Returns a Promise for testing.
+ */
+ _handleEditRepoConfig() {
+ this._editingConfig = true;
+ return this.$.restAPI
+ .createChange(
+ this.repo,
+ CONFIG_BRANCH,
+ EDIT_CONFIG_SUBJECT,
+ undefined,
+ false,
+ true
+ )
+ .then(change => {
+ const message = change
+ ? CREATE_CHANGE_SUCCEEDED_MESSAGE
+ : CREATE_CHANGE_FAILED_MESSAGE;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ if (!change) {
+ return;
+ }
+
+ GerritNav.navigateToRelativeUrl(
+ GerritNav.getEditUrlForDiff(change, CONFIG_PATH, INITIAL_PATCHSET)
+ );
+ })
+ .finally(() => {
+ this._editingConfig = false;
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo-commands': GrRepoCommands;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
index 0bb0c55..efe4012 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-commands/gr-repo-commands_test.js
@@ -28,10 +28,11 @@
setup(() => {
element = basicFixture.instantiate();
- repoStub = sinon.stub(
- element.$.restAPI,
- 'getProjectConfig')
- .callsFake(() => Promise.resolve({}));
+ // Note that this probably does not achieve what it is supposed to, because
+ // getProjectConfig() is called as soon as the element is attached, so
+ // stubbing it here has not effect anymore.
+ repoStub = sinon.stub(element.$.restAPI, 'getProjectConfig')
+ .returns(Promise.resolve({}));
});
suite('create new change dialog', () => {
@@ -72,6 +73,7 @@
sinon.stub(GerritNav, 'navigateToRelativeUrl');
handleSpy = sinon.spy(element, '_handleEditRepoConfig');
alertStub = sinon.stub();
+ element.repo = 'test';
element.addEventListener('show-alert', alertStub);
});
@@ -81,7 +83,7 @@
MockInteractions.tap(element.$.editRepoConfig);
assert.isTrue(element.$.editRepoConfig.loading);
return handleSpy.lastCall.returnValue.then(() => {
- flushAsynchronousOperations();
+ flush();
assert.isTrue(alertStub.called);
assert.equal(alertStub.lastCall.args[0].detail.message,
@@ -98,7 +100,7 @@
MockInteractions.tap(element.$.editRepoConfig);
assert.isTrue(element.$.editRepoConfig.loading);
return handleSpy.lastCall.returnValue.then(() => {
- flushAsynchronousOperations();
+ flush();
assert.isTrue(alertStub.called);
assert.equal(alertStub.lastCall.args[0].detail.message,
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
deleted file mode 100644
index f47ff76..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.js
+++ /dev/null
@@ -1,109 +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 '../../../styles/shared-styles.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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-repo-dashboards_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/**
- * @extends PolymerElement
- */
-class GrRepoDashboards extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo-dashboards'; }
-
- static get properties() {
- return {
- repo: {
- type: String,
- observer: '_repoChanged',
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- _dashboards: Array,
- };
- }
-
- _repoChanged(repo) {
- this._loading = true;
- if (!repo) { return Promise.resolve(); }
-
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
-
- this.$.restAPI.getRepoDashboards(this.repo, errFn).then(res => {
- if (!res) { return Promise.resolve(); }
-
- // Group by ref and sort by id.
- const dashboards = res.concat.apply([], res).sort((a, b) =>
- (a.id < b.id ? -1 : 1));
- const dashboardsByRef = {};
- dashboards.forEach(d => {
- if (!dashboardsByRef[d.ref]) {
- dashboardsByRef[d.ref] = [];
- }
- dashboardsByRef[d.ref].push(d);
- });
-
- const dashboardBuilder = [];
- Object.keys(dashboardsByRef).sort()
- .forEach(ref => {
- dashboardBuilder.push({
- section: ref,
- dashboards: dashboardsByRef[ref],
- });
- });
-
- this._dashboards = dashboardBuilder;
- this._loading = false;
- flush();
- });
- }
-
- _getUrl(project, id) {
- if (!project || !id) { return ''; }
-
- return GerritNav.getUrlForRepoDashboard(project, id);
- }
-
- _computeLoadingClass(loading) {
- return loading ? 'loading' : '';
- }
-
- _computeInheritedFrom(project, definingProject) {
- return project === definingProject ? '' : definingProject;
- }
-
- _computeIsDefault(isDefault) {
- return isDefault ? '✓' : '';
- }
-}
-
-customElements.define(GrRepoDashboards.is, GrRepoDashboards);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
new file mode 100644
index 0000000..d9d8560
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards.ts
@@ -0,0 +1,134 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-repo-dashboards_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
+import {ErrorCallback} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+interface DashboardRef {
+ section: string;
+ dashboards: DashboardInfo[];
+}
+
+export interface GrRepoDashboards {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-repo-dashboards')
+export class GrRepoDashboards extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, observer: '_repoChanged'})
+ repo?: RepoName;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Array})
+ _dashboards?: DashboardRef[];
+
+ _repoChanged(repo?: RepoName) {
+ this._loading = true;
+ if (!repo) {
+ return Promise.resolve();
+ }
+
+ const errFn: ErrorCallback = response => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+
+ return this.$.restAPI
+ .getRepoDashboards(repo, errFn)
+ .then((res?: DashboardInfo[]) => {
+ if (!res) {
+ return;
+ }
+
+ // Group by ref and sort by id.
+ const dashboards = res.concat
+ .apply([], res)
+ .sort((a, b) => (a.id < b.id ? -1 : 1));
+ const dashboardsByRef: Record<string, DashboardInfo[]> = {};
+ dashboards.forEach(d => {
+ if (!dashboardsByRef[d.ref]) {
+ dashboardsByRef[d.ref] = [];
+ }
+ dashboardsByRef[d.ref].push(d);
+ });
+
+ const dashboardBuilder: DashboardRef[] = [];
+ Object.keys(dashboardsByRef)
+ .sort()
+ .forEach(ref => {
+ dashboardBuilder.push({
+ section: ref,
+ dashboards: dashboardsByRef[ref],
+ });
+ });
+
+ this._dashboards = dashboardBuilder;
+ this._loading = false;
+ flush();
+ });
+ }
+
+ _getUrl(project: RepoName, id: DashboardId) {
+ if (!project || !id) {
+ return '';
+ }
+
+ return GerritNav.getUrlForRepoDashboard(project, id);
+ }
+
+ _computeLoadingClass(loading: boolean) {
+ return loading ? 'loading' : '';
+ }
+
+ _computeInheritedFrom(project: RepoName, definingProject: RepoName) {
+ return project === definingProject ? '' : definingProject;
+ }
+
+ _computeIsDefault(isDefault: boolean) {
+ return isDefault ? '✓' : '';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo-dashboards': GrRepoDashboards;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
deleted file mode 100644
index 4989365..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.js
+++ /dev/null
@@ -1,309 +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-input/iron-input.js';
-import '../../../styles/gr-form-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-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-list-view/gr-list-view.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-pointer-dialog/gr-create-pointer-dialog.js';
-import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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-repo-detail-list_html.js';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-import {encodeURL} from '../../../utils/url-util.js';
-
-const DETAIL_TYPES = {
- BRANCHES: 'branches',
- TAGS: 'tags',
-};
-
-const PGP_START = '-----BEGIN PGP SIGNATURE-----';
-
-/**
- * @appliesMixin ListViewMixin
- * @extends PolymerElement
- */
-class GrRepoDetailList extends ListViewMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo-detail-list'; }
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
- /**
- * The kind of detail we are displaying, possibilities are determined by
- * the const DETAIL_TYPES.
- */
- detailType: String,
-
- _editing: {
- type: Boolean,
- value: false,
- },
- _isOwner: {
- type: Boolean,
- value: false,
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- },
- /**
- * Offset of currently visible query results.
- */
- _offset: Number,
- _repo: Object,
- _items: Array,
- /**
- * Because we request one more than the projectsPerPage, _shownProjects
- * maybe one less than _projects.
- */
- _shownItems: {
- type: Array,
- computed: 'computeShownItems(_items)',
- },
- _itemsPerPage: {
- type: Number,
- value: 25,
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- _filter: String,
- _refName: String,
- _hasNewItemName: Boolean,
- _isEditing: Boolean,
- _revisedRef: String,
- };
- }
-
- _determineIfOwner(repo) {
- return this.$.restAPI.getRepoAccess(repo)
- .then(access =>
- this._isOwner = access && !!access[repo].is_owner);
- }
-
- _paramsChanged(params) {
- if (!params || !params.repo) { return; }
-
- this._repo = params.repo;
-
- this._getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- if (loggedIn) {
- this._determineIfOwner(this._repo);
- }
- });
-
- this.detailType = params.detail;
-
- this._filter = this.getFilterValue(params);
- this._offset = this.getOffsetValue(params);
-
- return this._getItems(this._filter, this._repo,
- this._itemsPerPage, this._offset, this.detailType);
- }
-
- _getItems(filter, repo, itemsPerPage, offset, detailType) {
- this._loading = true;
- this._items = [];
- flush();
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
- if (detailType === DETAIL_TYPES.BRANCHES) {
- return this.$.restAPI.getRepoBranches(
- filter, repo, itemsPerPage, offset, errFn).then(items => {
- if (!items) { return; }
- this._items = items;
- this._loading = false;
- });
- } else if (detailType === DETAIL_TYPES.TAGS) {
- return this.$.restAPI.getRepoTags(
- filter, repo, itemsPerPage, offset, errFn).then(items => {
- if (!items) { return; }
- this._items = items;
- this._loading = false;
- });
- }
- }
-
- _getPath(repo) {
- return `/admin/repos/${encodeURL(repo, false)},` +
- `${this.detailType}`;
- }
-
- _computeWeblink(repo) {
- if (!repo.web_links) { return ''; }
- const webLinks = repo.web_links;
- return webLinks.length ? webLinks : null;
- }
-
- _computeMessage(message) {
- if (!message) { return; }
- // Strip PGP info.
- return message.split(PGP_START)[0];
- }
-
- _stripRefs(item, detailType) {
- if (detailType === DETAIL_TYPES.BRANCHES) {
- return item.replace('refs/heads/', '');
- } else if (detailType === DETAIL_TYPES.TAGS) {
- return item.replace('refs/tags/', '');
- }
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _computeEditingClass(isEditing) {
- return isEditing ? 'editing' : '';
- }
-
- _computeCanEditClass(ref, detailType, isOwner) {
- return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
- 'canEdit' : '';
- }
-
- _handleEditRevision(e) {
- this._revisedRef = e.model.get('item.revision');
- this._isEditing = true;
- }
-
- _handleCancelRevision() {
- this._isEditing = false;
- }
-
- _handleSaveRevision(e) {
- this._setRepoHead(this._repo, this._revisedRef, e);
- }
-
- _setRepoHead(repo, ref, e) {
- return this.$.restAPI.setRepoHead(repo, ref).then(res => {
- if (res.status < 400) {
- this._isEditing = false;
- e.model.set('item.revision', ref);
- // This is needed to refresh _items property with fresh data,
- // specifically can_delete from the json response.
- this._getItems(
- this._filter, this._repo, this._itemsPerPage,
- this._offset, this.detailType);
- }
- });
- }
-
- _computeItemName(detailType) {
- if (detailType === DETAIL_TYPES.BRANCHES) {
- return 'Branch';
- } else if (detailType === DETAIL_TYPES.TAGS) {
- return 'Tag';
- }
- }
-
- _handleDeleteItemConfirm() {
- this.$.overlay.close();
- if (this.detailType === DETAIL_TYPES.BRANCHES) {
- return this.$.restAPI.deleteRepoBranches(this._repo, this._refName)
- .then(itemDeleted => {
- if (itemDeleted.status === 204) {
- this._getItems(
- this._filter, this._repo, this._itemsPerPage,
- this._offset, this.detailType);
- }
- });
- } else if (this.detailType === DETAIL_TYPES.TAGS) {
- return this.$.restAPI.deleteRepoTags(this._repo, this._refName)
- .then(itemDeleted => {
- if (itemDeleted.status === 204) {
- this._getItems(
- this._filter, this._repo, this._itemsPerPage,
- this._offset, this.detailType);
- }
- });
- }
- }
-
- _handleConfirmDialogCancel() {
- this.$.overlay.close();
- }
-
- _handleDeleteItem(e) {
- const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
- if (!name) { return; }
- this._refName = name;
- this.$.overlay.open();
- }
-
- _computeHideDeleteClass(owner, canDelete) {
- if (canDelete || owner) {
- return 'show';
- }
-
- return '';
- }
-
- _handleCreateItem() {
- this.$.createNewModal.handleCreateItem();
- this._handleCloseCreate();
- }
-
- _handleCloseCreate() {
- this.$.createOverlay.close();
- }
-
- _handleCreateClicked() {
- this.$.createOverlay.open();
- }
-
- _hideIfBranch(type) {
- if (type === DETAIL_TYPES.BRANCHES) {
- return 'hideItem';
- }
-
- return '';
- }
-
- _computeHideTagger(tagger) {
- return tagger ? '' : 'hide';
- }
-}
-
-customElements.define(GrRepoDetailList.is, GrRepoDetailList);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
new file mode 100644
index 0000000..2fce6e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list.ts
@@ -0,0 +1,397 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-pointer-dialog/gr-create-pointer-dialog';
+import '../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-repo-detail-list_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {encodeURL} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+ ErrorCallback,
+ RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrCreatePointerDialog} from '../gr-create-pointer-dialog/gr-create-pointer-dialog';
+import {
+ RepoName,
+ ProjectInfo,
+ BranchInfo,
+ GitRef,
+ TagInfo,
+ GitPersonInfo,
+} from '../../../types/common';
+import {AppElementRepoParams} from '../../gr-app-types';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+
+const PGP_START = '-----BEGIN PGP SIGNATURE-----';
+
+export interface GrRepoDetailList {
+ $: {
+ restAPI: RestApiService & Element;
+ overlay: GrOverlay;
+ createOverlay: GrOverlay;
+ createNewModal: GrCreatePointerDialog;
+ };
+}
+@customElement('gr-repo-detail-list')
+export class GrRepoDetailList extends ListViewMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: AppElementRepoParams;
+
+ @property({type: String})
+ detailType?: RepoDetailView;
+
+ @property({type: Boolean})
+ _editing = false;
+
+ @property({type: Boolean})
+ _isOwner = false;
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Number})
+ _offset?: number;
+
+ @property({type: String})
+ _repo?: RepoName;
+
+ @property({type: Array})
+ _items?: BranchInfo[] | TagInfo[];
+
+ @property({type: Array, computed: 'computeShownItems(_items)'})
+ _shownItems?: BranchInfo[] | TagInfo[];
+
+ @property({type: Number})
+ _itemsPerPage = 25;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _filter?: string;
+
+ @property({type: String})
+ _refName?: GitRef;
+
+ @property({type: Boolean})
+ _hasNewItemName?: boolean;
+
+ @property({type: Boolean})
+ _isEditing?: boolean;
+
+ @property({type: String})
+ _revisedRef?: GitRef;
+
+ _determineIfOwner(repo: RepoName) {
+ return this.$.restAPI
+ .getRepoAccess(repo)
+ .then(access => (this._isOwner = !!access && !!access[repo].is_owner));
+ }
+
+ _paramsChanged(params?: AppElementRepoParams) {
+ if (!params?.repo) {
+ return Promise.reject(new Error('undefined repo'));
+ }
+
+ // paramsChanged is called before gr-admin-view can set _showRepoDetailList
+ // to false and polymer removes this component, hence check for params
+ if (
+ !(
+ params?.detail === RepoDetailView.BRANCHES ||
+ params?.detail === RepoDetailView.TAGS
+ )
+ ) {
+ return;
+ }
+
+ this._repo = params.repo;
+
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ if (loggedIn && this._repo) {
+ this._determineIfOwner(this._repo);
+ }
+ });
+
+ this.detailType = params.detail;
+
+ this._filter = this.getFilterValue(params);
+ this._offset = this.getOffsetValue(params);
+ if (!this.detailType)
+ return Promise.reject(new Error('undefined detailType'));
+
+ return this._getItems(
+ this._filter,
+ this._repo,
+ this._itemsPerPage,
+ this._offset,
+ this.detailType
+ );
+ }
+
+ // TODO(TS) Move this to object for easier read, understand.
+ _getItems(
+ filter: string | undefined,
+ repo: RepoName | undefined,
+ itemsPerPage: number,
+ offset: number | undefined,
+ detailType: string
+ ) {
+ if (filter === undefined || !repo || offset === undefined) {
+ return Promise.reject(new Error('filter or repo or offset undefined'));
+ }
+ this._loading = true;
+ this._items = [];
+ flush();
+ const errFn: ErrorCallback = response => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+ if (detailType === RepoDetailView.BRANCHES) {
+ return this.$.restAPI
+ .getRepoBranches(filter, repo, itemsPerPage, offset, errFn)
+ .then(items => {
+ if (!items) {
+ return;
+ }
+ this._items = items;
+ this._loading = false;
+ });
+ } else if (detailType === RepoDetailView.TAGS) {
+ return this.$.restAPI
+ .getRepoTags(filter, repo, itemsPerPage, offset, errFn)
+ .then(items => {
+ if (!items) {
+ return;
+ }
+ this._items = items;
+ this._loading = false;
+ });
+ }
+ return Promise.reject(new Error('unknown detail type'));
+ }
+
+ _getPath(repo: RepoName) {
+ return `/admin/repos/${encodeURL(repo, false)},${this.detailType}`;
+ }
+
+ _computeWeblink(repo: ProjectInfo) {
+ if (!repo.web_links) {
+ return '';
+ }
+ const webLinks = repo.web_links;
+ return webLinks.length ? webLinks : null;
+ }
+
+ _computeFirstWebLink(repo: ProjectInfo) {
+ const webLinks = this._computeWeblink(repo);
+ return webLinks ? webLinks[0].url : null;
+ }
+
+ _computeMessage(message?: string) {
+ if (!message) {
+ return;
+ }
+ // Strip PGP info.
+ return message.split(PGP_START)[0];
+ }
+
+ _stripRefs(item: GitRef, detailType?: string) {
+ if (detailType === RepoDetailView.BRANCHES) {
+ return item.replace('refs/heads/', '');
+ } else if (detailType === RepoDetailView.TAGS) {
+ return item.replace('refs/tags/', '');
+ }
+ throw new Error('unknown detailType');
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _computeEditingClass(isEditing: boolean) {
+ return isEditing ? 'editing' : '';
+ }
+
+ _computeCanEditClass(ref: GitRef, detailType: string, isOwner: boolean) {
+ return isOwner && this._stripRefs(ref, detailType) === 'HEAD'
+ ? 'canEdit'
+ : '';
+ }
+
+ _handleEditRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
+ this._revisedRef = (e.model.get('item.revision') as unknown) as GitRef;
+ this._isEditing = true;
+ }
+
+ _handleCancelRevision() {
+ this._isEditing = false;
+ }
+
+ _handleSaveRevision(e: PolymerDomRepeatEvent<GitRef>) {
+ if (this._revisedRef && this._repo)
+ this._setRepoHead(this._repo, this._revisedRef, e);
+ }
+
+ _setRepoHead(repo: RepoName, ref: GitRef, e: PolymerDomRepeatEvent<GitRef>) {
+ return this.$.restAPI.setRepoHead(repo, ref).then(res => {
+ if (res.status < 400) {
+ this._isEditing = false;
+ e.model.set('item.revision', ref);
+ // This is needed to refresh _items property with fresh data,
+ // specifically can_delete from the json response.
+ this._getItems(
+ this._filter,
+ this._repo,
+ this._itemsPerPage,
+ this._offset,
+ this.detailType!
+ );
+ }
+ });
+ }
+
+ _computeItemName(detailType: string) {
+ if (detailType === RepoDetailView.BRANCHES) {
+ return 'Branch';
+ } else if (detailType === RepoDetailView.TAGS) {
+ return 'Tag';
+ }
+ throw new Error('unknown detailType');
+ }
+
+ _handleDeleteItemConfirm() {
+ this.$.overlay.close();
+ if (!this._repo || !this._refName) {
+ return Promise.reject(new Error('undefined repo or refName'));
+ }
+ if (this.detailType === RepoDetailView.BRANCHES) {
+ return this.$.restAPI
+ .deleteRepoBranches(this._repo, this._refName)
+ .then(itemDeleted => {
+ if (itemDeleted.status === 204) {
+ this._getItems(
+ this._filter,
+ this._repo,
+ this._itemsPerPage,
+ this._offset,
+ this.detailType!
+ );
+ }
+ });
+ } else if (this.detailType === RepoDetailView.TAGS) {
+ return this.$.restAPI
+ .deleteRepoTags(this._repo, this._refName)
+ .then(itemDeleted => {
+ if (itemDeleted.status === 204) {
+ this._getItems(
+ this._filter,
+ this._repo,
+ this._itemsPerPage,
+ this._offset,
+ this.detailType!
+ );
+ }
+ });
+ }
+ return Promise.reject(new Error('unknown detail type'));
+ }
+
+ _handleConfirmDialogCancel() {
+ this.$.overlay.close();
+ }
+
+ _handleDeleteItem(e: PolymerDomRepeatEvent<GitRef>) {
+ const name = this._stripRefs(
+ e.model.get('item.ref'),
+ this.detailType
+ ) as GitRef;
+ if (!name) {
+ return;
+ }
+ this._refName = name;
+ this.$.overlay.open();
+ }
+
+ _computeHideDeleteClass(owner: boolean, canDelete: boolean) {
+ if (canDelete || owner) {
+ return 'show';
+ }
+
+ return '';
+ }
+
+ _handleCreateItem() {
+ this.$.createNewModal.handleCreateItem();
+ this._handleCloseCreate();
+ }
+
+ _handleCloseCreate() {
+ this.$.createOverlay.close();
+ }
+
+ _handleCreateClicked() {
+ this.$.createOverlay.open();
+ }
+
+ _hideIfBranch(type: string) {
+ if (type === RepoDetailView.BRANCHES) {
+ return 'hideItem';
+ }
+
+ return '';
+ }
+
+ _computeHideTagger(tagger: GitPersonInfo) {
+ return tagger ? '' : 'hide';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo-detail-list': GrRepoDetailList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
index 8955092..196797f 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_html.ts
@@ -104,7 +104,9 @@
<template is="dom-repeat" items="[[_shownItems]]">
<tr class="table">
<td class$="[[detailType]] name">
- [[_stripRefs(item.ref, detailType)]]
+ <a href$="[[_computeFirstWebLink(item)]]">
+ [[_stripRefs(item.ref, detailType)]]
+ </a>
</td>
<td
class$="[[detailType]] revision [[_computeCanEditClass(item.ref, detailType, _isOwner)]]"
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
index 7190218..7727821 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-detail-list/gr-repo-detail-list_test.js
@@ -17,7 +17,8 @@
import '../../../test/common-test-setup-karma.js';
import './gr-repo-detail-list.js';
-import page from 'page/page.mjs';
+import 'lodash/lodash.js';
+import {page} from '../../../utils/page-wrapper-utils.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-repo-detail-list');
@@ -132,9 +133,9 @@
});
test('Edit HEAD button admin', done => {
- const saveBtn = dom(element.root).querySelector('.saveBtn');
- const cancelBtn = dom(element.root).querySelector('.cancelBtn');
- const editBtn = dom(element.root).querySelector('.editBtn');
+ const saveBtn = element.root.querySelector('.saveBtn');
+ const cancelBtn = element.root.querySelector('.cancelBtn');
+ const editBtn = element.root.querySelector('.editBtn');
const revisionNoEditing = dom(element.root)
.querySelector('.revisionNoEditing');
const revisionWithEditing = dom(element.root)
@@ -169,7 +170,7 @@
}
MockInteractions.tap(editBtn);
- flushAsynchronousOperations();
+ flush();
// The revision and edit button are not visible.
assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
assert.equal(getComputedStyle(editBtn).display, 'none');
@@ -200,7 +201,7 @@
// When cancel is tapped, the edit secion closes.
MockInteractions.tap(cancelBtn);
- flushAsynchronousOperations();
+ flush();
// The revision and edit button are visible.
assert.notEqual(getComputedStyle(revisionWithEditing).display,
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
deleted file mode 100644
index 249a75d..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.js
+++ /dev/null
@@ -1,186 +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-dialog/gr-dialog.js';
-import '../../shared/gr-list-view/gr-list-view.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-repo-dialog/gr-create-repo-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-repo-list_html.js';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/**
- * @appliesMixin ListViewMixin
- * @extends PolymerElement
- */
-class GrRepoList extends ListViewMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo-list'; }
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
-
- /**
- * Offset of currently visible query results.
- */
- _offset: Number,
- _path: {
- type: String,
- readOnly: true,
- value: '/admin/repos',
- },
- _hasNewRepoName: Boolean,
- _createNewCapability: {
- type: Boolean,
- value: false,
- },
- _repos: Array,
-
- /**
- * Because we request one more than the projectsPerPage, _shownProjects
- * maybe one less than _projects.
- * */
- _shownRepos: {
- type: Array,
- computed: 'computeShownItems(_repos)',
- },
-
- _reposPerPage: {
- type: Number,
- value: 25,
- },
-
- _loading: {
- type: Boolean,
- value: true,
- },
- _filter: {
- type: String,
- value: '',
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getCreateRepoCapability();
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: 'Repos'},
- composed: true, bubbles: true,
- }));
- this._maybeOpenCreateOverlay(this.params);
- }
-
- _paramsChanged(params) {
- this._loading = true;
- this._filter = this.getFilterValue(params);
- this._offset = this.getOffsetValue(params);
-
- return this._getRepos(this._filter, this._reposPerPage,
- this._offset);
- }
-
- /**
- * Opens the create overlay if the route has a hash 'create'
- *
- * @param {!Object} params
- */
- _maybeOpenCreateOverlay(params) {
- if (params && params.openCreateModal) {
- this.$.createOverlay.open();
- }
- }
-
- _computeRepoUrl(name) {
- return this.getUrl(this._path + '/', name);
- }
-
- _computeChangesLink(name) {
- return GerritNav.getUrlForProjectChanges(name);
- }
-
- _getCreateRepoCapability() {
- return this.$.restAPI.getAccount().then(account => {
- if (!account) { return; }
- return this.$.restAPI.getAccountCapabilities(['createProject'])
- .then(capabilities => {
- if (capabilities.createProject) {
- this._createNewCapability = true;
- }
- });
- });
- }
-
- _getRepos(filter, reposPerPage, offset) {
- this._repos = [];
- return this.$.restAPI.getRepos(filter, reposPerPage, offset)
- .then(repos => {
- // Late response.
- if (filter !== this._filter || !repos) { return; }
- this._repos = repos;
- this._loading = false;
- });
- }
-
- _refreshReposList() {
- this.$.restAPI.invalidateReposCache();
- return this._getRepos(this._filter, this._reposPerPage,
- this._offset);
- }
-
- _handleCreateRepo() {
- this.$.createNewModal.handleCreateRepo().then(() => {
- this._refreshReposList();
- });
- }
-
- _handleCloseCreate() {
- this.$.createOverlay.close();
- }
-
- _handleCreateClicked() {
- this.$.createOverlay.open();
- }
-
- _readOnly(item) {
- return item.state === 'READ_ONLY' ? 'Y' : '';
- }
-
- _computeWeblink(repo) {
- if (!repo.web_links) { return ''; }
- const webLinks = repo.web_links;
- return webLinks.length ? webLinks : null;
- }
-}
-
-customElements.define(GrRepoList.is, GrRepoList);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
new file mode 100644
index 0000000..ba2d850
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list.ts
@@ -0,0 +1,189 @@
+/**
+ * @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-dialog/gr-dialog';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-repo-dialog/gr-create-repo-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-repo-list_html';
+import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe, computed} from '@polymer/decorators';
+import {AppElementAdminParams} from '../../gr-app-types';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {RepoName, ProjectInfoWithName} from '../../../types/common';
+import {GrCreateRepoDialog} from '../gr-create-repo-dialog/gr-create-repo-dialog';
+import {ProjectState} from '../../../constants/constants';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo-list': GrRepoList;
+ }
+}
+
+export interface GrRepoList {
+ $: {
+ restAPI: RestApiService & Element;
+ createOverlay: GrOverlay;
+ createNewModal: GrCreateRepoDialog;
+ };
+}
+
+@customElement('gr-repo-list')
+export class GrRepoList extends ListViewMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ params?: AppElementAdminParams;
+
+ @property({type: Number})
+ _offset?: number;
+
+ @property({type: String})
+ readonly _path = '/admin/repos';
+
+ @property({type: Boolean})
+ _hasNewRepoName = false;
+
+ @property({type: Boolean})
+ _createNewCapability = false;
+
+ @property({type: Array})
+ _repos: ProjectInfoWithName[] = [];
+
+ @property({type: Number})
+ _reposPerPage = 25;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _filter = '';
+
+ @computed('_repos')
+ get _shownRepos() {
+ return this.computeShownItems(this._repos);
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getCreateRepoCapability();
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: 'Repos'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._maybeOpenCreateOverlay(this.params);
+ }
+
+ @observe('params')
+ _paramsChanged(params: AppElementAdminParams) {
+ this._loading = true;
+ this._filter = this.getFilterValue(params);
+ this._offset = this.getOffsetValue(params);
+
+ return this._getRepos(this._filter, this._reposPerPage, this._offset);
+ }
+
+ /**
+ * Opens the create overlay if the route has a hash 'create'
+ */
+ _maybeOpenCreateOverlay(params?: AppElementAdminParams) {
+ if (params?.openCreateModal) {
+ this.$.createOverlay.open();
+ }
+ }
+
+ _computeRepoUrl(name: string) {
+ return this.getUrl(this._path + '/', name);
+ }
+
+ _computeChangesLink(name: string) {
+ return GerritNav.getUrlForProjectChanges(name as RepoName);
+ }
+
+ _getCreateRepoCapability() {
+ return this.$.restAPI.getAccount().then(account => {
+ if (!account) {
+ return;
+ }
+ return this.$.restAPI
+ .getAccountCapabilities(['createProject'])
+ .then(capabilities => {
+ if (capabilities?.createProject) {
+ this._createNewCapability = true;
+ }
+ });
+ });
+ }
+
+ _getRepos(filter: string, reposPerPage: number, offset?: number) {
+ this._repos = [];
+ return this.$.restAPI.getRepos(filter, reposPerPage, offset).then(repos => {
+ // Late response.
+ if (filter !== this._filter || !repos) {
+ return;
+ }
+ this._repos = repos;
+ this._loading = false;
+ });
+ }
+
+ _refreshReposList() {
+ this.$.restAPI.invalidateReposCache();
+ return this._getRepos(this._filter, this._reposPerPage, this._offset);
+ }
+
+ _handleCreateRepo() {
+ this.$.createNewModal.handleCreateRepo().then(() => {
+ this._refreshReposList();
+ });
+ }
+
+ _handleCloseCreate() {
+ this.$.createOverlay.close();
+ }
+
+ _handleCreateClicked() {
+ this.$.createOverlay.open();
+ }
+
+ _readOnly(repo: ProjectInfoWithName) {
+ return repo.state === ProjectState.READ_ONLY ? 'Y' : '';
+ }
+
+ _computeWeblink(repo: ProjectInfoWithName) {
+ if (!repo.web_links) {
+ return '';
+ }
+ const webLinks = repo.web_links;
+ return webLinks.length ? webLinks : null;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
index f61adce..4889845 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_html.ts
@@ -110,7 +110,6 @@
<div class="main" slot="main">
<gr-create-repo-dialog
has-new-repo-name="{{_hasNewRepoName}}"
- params="[[params]]"
id="createNewModal"
></gr-create-repo-dialog>
</div>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
index b629cf4..e2a29f2 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-list/gr-repo-list_test.js
@@ -17,7 +17,8 @@
import '../../../test/common-test-setup-karma.js';
import './gr-repo-list.js';
-import page from 'page/page.mjs';
+import {page} from '../../../utils/page-wrapper-utils.js';
+import 'lodash/lodash.js';
const basicFixture = fixtureFromElement('gr-repo-list');
@@ -144,7 +145,7 @@
element._loading = false;
element._repos = _.times(25, repoGenerator);
- flushAsynchronousOperations();
+ flush();
assert.equal(element.computeLoadingClass(element._loading), '');
assert.equal(getComputedStyle(element.$.loading).display, 'none');
});
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
new file mode 100644
index 0000000..6a96f55
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * 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.
+ */
+/**
+ * @fileOverview This file contains interfaces shared between
+ * gr-repo-plugin-config.ts and nested editors
+ * (e.g. gr-plugin-config-array-editor.ts)
+ *
+ * This file is required to avoid circular dependencies between files
+ */
+
+import {
+ ConfigArrayParameterInfo,
+ ConfigParameterInfo,
+ ConfigParameterInfoBase,
+} from '../../../types/common';
+
+export interface PluginOption<
+ T extends ConfigParameterInfoBase = ConfigParameterInfo
+> {
+ _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
+ info: T;
+}
+
+export type ArrayPluginOption = PluginOption<ConfigArrayParameterInfo>;
+
+export interface PluginConfigOptionsChangedEventDetail {
+ _key: string;
+ info: ConfigArrayParameterInfo;
+ notifyPath: string;
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
deleted file mode 100644
index 4933f41..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.js
+++ /dev/null
@@ -1,171 +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-input/iron-input.js';
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-subpage-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-repo-plugin-config_html.js';
-
-// Should be kept in sync with
-// gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
-const CONFIG_ENTRY_TYPE = {
- ARRAY: 'ARRAY',
- BOOLEAN: 'BOOLEAN',
- INT: 'INT',
- LIST: 'LIST',
- LONG: 'LONG',
- STRING: 'STRING',
-};
-
-const PLUGIN_CONFIG_CHANGED_EVENT_NAME = 'plugin-config-changed';
-
-/**
- * @extends PolymerElement
- */
-class GrRepoPluginConfig extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo-plugin-config'; }
- /**
- * Fired when the plugin config changes.
- *
- * @event plugin-config-changed
- */
-
- static get properties() {
- return {
- /** @type {?} */
- pluginData: Object,
- /** @type {Array} */
- _pluginConfigOptions: {
- type: Array,
- computed: '_computePluginConfigOptions(pluginData.*)',
- },
- };
- }
-
- _computePluginConfigOptions(dataRecord) {
- if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
- return [];
- }
- const {config} = dataRecord.base;
- return Object.keys(config)
- .map(_key => { return {_key, info: config[_key]}; });
- }
-
- _isArray(type) {
- return type === CONFIG_ENTRY_TYPE.ARRAY;
- }
-
- _isBoolean(type) {
- return type === CONFIG_ENTRY_TYPE.BOOLEAN;
- }
-
- _isList(type) {
- return type === CONFIG_ENTRY_TYPE.LIST;
- }
-
- _isString(type) {
- // Treat numbers like strings for simplicity.
- return type === CONFIG_ENTRY_TYPE.STRING ||
- type === CONFIG_ENTRY_TYPE.INT ||
- type === CONFIG_ENTRY_TYPE.LONG;
- }
-
- _computeDisabled(editable) {
- return editable === 'false';
- }
-
- /**
- * @param {string} value - fallback to 'false' if undefined
- */
- _computeChecked(value = 'false') {
- return JSON.parse(value);
- }
-
- _handleStringChange(e) {
- const el = dom(e).localTarget;
- const _key = el.getAttribute('data-option-key');
- const configChangeInfo =
- this._buildConfigChangeInfo(el.value, _key);
- this._handleChange(configChangeInfo);
- }
-
- _handleListChange(e) {
- const el = dom(e).localTarget;
- const _key = el.getAttribute('data-option-key');
- const configChangeInfo =
- this._buildConfigChangeInfo(el.value, _key);
- this._handleChange(configChangeInfo);
- }
-
- _handleBooleanChange(e) {
- const el = dom(e).localTarget;
- const _key = el.getAttribute('data-option-key');
- const configChangeInfo =
- this._buildConfigChangeInfo(JSON.stringify(el.checked), _key);
- this._handleChange(configChangeInfo);
- }
-
- _buildConfigChangeInfo(value, _key) {
- const info = this.pluginData.config[_key];
- info.value = value;
- return {
- _key,
- info,
- notifyPath: `${_key}.value`,
- };
- }
-
- _handleArrayChange({detail}) {
- this._handleChange(detail);
- }
-
- _handleChange({_key, info, notifyPath}) {
- const {name, config} = this.pluginData;
-
- /** @type {Object} */
- const detail = {
- name,
- config: Object.assign(config, {[_key]: info}, {}),
- notifyPath: `${name}.${notifyPath}`,
- };
-
- this.dispatchEvent(new CustomEvent(PLUGIN_CONFIG_CHANGED_EVENT_NAME,
- {detail, bubbles: true, composed: true}));
- }
-
- /**
- * Work around a issue on iOS when clicking turns into double tap
- */
- _onTapPluginBoolean(e) {
- e.preventDefault();
- }
-}
-
-customElements.define(GrRepoPluginConfig.is, GrRepoPluginConfig);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
new file mode 100644
index 0000000..e9a6158
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -0,0 +1,207 @@
+/**
+ * @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-input/iron-input';
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-repo-plugin-config_html';
+import {customElement, property} from '@polymer/decorators';
+import {ConfigParameterInfoType} from '../../../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {
+ ConfigParameterInfo,
+ PluginParameterToConfigParameterInfoMap,
+} from '../../../types/common';
+import {PaperToggleButtonElement} from '@polymer/paper-toggle-button/paper-toggle-button';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
+import {
+ PluginConfigOptionsChangedEventDetail,
+ PluginOption,
+} from './gr-repo-plugin-config-types';
+
+const PLUGIN_CONFIG_CHANGED_EVENT_NAME = 'plugin-config-changed';
+
+export interface ConfigChangeInfo {
+ _key: string; // parameterName of PluginParameterToConfigParameterInfoMap
+ info: ConfigParameterInfo;
+ notifyPath: string;
+}
+
+export interface PluginData {
+ name: string; // parameterName of PluginParameterToConfigParameterInfoMap
+ config: PluginParameterToConfigParameterInfoMap;
+}
+
+export interface PluginConfigChangeDetail {
+ name: string; // parameterName of PluginParameterToConfigParameterInfoMap
+ config: PluginParameterToConfigParameterInfoMap;
+ notifyPath: string;
+}
+
+@customElement('gr-repo-plugin-config')
+class GrRepoPluginConfig extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the plugin config changes.
+ *
+ * @event plugin-config-changed
+ */
+
+ @property({type: Object})
+ pluginData?: PluginData;
+
+ @property({
+ type: Array,
+ computed: '_computePluginConfigOptions(pluginData.*)',
+ })
+ _pluginConfigOptions!: PluginOption[]; // _computePluginConfigOptions never returns null
+
+ _computePluginConfigOptions(
+ dataRecord: PolymerDeepPropertyChange<PluginData, PluginData>
+ ): PluginOption[] {
+ if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
+ return [];
+ }
+ const config = dataRecord.base.config;
+ return Object.keys(config).map(_key => {
+ return {_key, info: config[_key]};
+ });
+ }
+
+ _isArray(type: ConfigParameterInfoType) {
+ return type === ConfigParameterInfoType.ARRAY;
+ }
+
+ _isBoolean(type: ConfigParameterInfoType) {
+ return type === ConfigParameterInfoType.BOOLEAN;
+ }
+
+ _isList(type: ConfigParameterInfoType) {
+ return type === ConfigParameterInfoType.LIST;
+ }
+
+ _isString(type: ConfigParameterInfoType) {
+ // Treat numbers like strings for simplicity.
+ return (
+ type === ConfigParameterInfoType.STRING ||
+ type === ConfigParameterInfoType.INT ||
+ type === ConfigParameterInfoType.LONG
+ );
+ }
+
+ _computeDisabled(editable: string) {
+ return editable === 'false';
+ }
+
+ _computeChecked(value = 'false') {
+ return JSON.parse(value) as boolean;
+ }
+
+ _handleStringChange(e: Event) {
+ const el = (dom(e) as EventApi).localTarget as IronInputElement;
+ // In the template, the data-option-key is assigned to each editor
+ const _key = el.getAttribute('data-option-key')!;
+ const configChangeInfo = this._buildConfigChangeInfo(el.value, _key);
+ this._handleChange(configChangeInfo);
+ }
+
+ _handleListChange(e: Event) {
+ const el = (dom(e) as EventApi).localTarget as HTMLOptionElement;
+ // In the template, the data-option-key is assigned to each editor
+ const _key = el.getAttribute('data-option-key')!;
+ const configChangeInfo = this._buildConfigChangeInfo(el.value, _key);
+ this._handleChange(configChangeInfo);
+ }
+
+ _handleBooleanChange(e: Event) {
+ const el = (dom(e) as EventApi).localTarget as PaperToggleButtonElement;
+ // In the template, the data-option-key is assigned to each editor
+ const _key = el.getAttribute('data-option-key')!;
+ const configChangeInfo = this._buildConfigChangeInfo(
+ JSON.stringify(el.checked),
+ _key
+ );
+ this._handleChange(configChangeInfo);
+ }
+
+ _buildConfigChangeInfo(
+ value: string | null | undefined,
+ _key: string
+ ): ConfigChangeInfo {
+ // If pluginData is not set, editors are not created and this method
+ // can't be called
+ const info = this.pluginData!.config[_key];
+ info.value = value !== null ? value : undefined;
+ return {
+ _key,
+ info,
+ notifyPath: `${_key}.value`,
+ };
+ }
+
+ _handleArrayChange(e: CustomEvent<PluginConfigOptionsChangedEventDetail>) {
+ this._handleChange(e.detail);
+ }
+
+ _handleChange({_key, info, notifyPath}: ConfigChangeInfo) {
+ // If pluginData is not set, editors are not created and this method
+ // can't be called
+ const {name, config} = this.pluginData!;
+
+ /** @type {Object} */
+ const detail: PluginConfigChangeDetail = {
+ name,
+ config: {...config, [_key]: info},
+ notifyPath: `${name}.${notifyPath}`,
+ };
+
+ this.dispatchEvent(
+ new CustomEvent(PLUGIN_CONFIG_CHANGED_EVENT_NAME, {
+ detail,
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ /**
+ * Work around a issue on iOS when clicking turns into double tap
+ */
+ _onTapPluginBoolean(e: Event) {
+ e.preventDefault();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo-plugin-config': GrRepoPluginConfig;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
index 1730839..168984a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
@@ -77,7 +77,7 @@
name: 'testName',
config: {plugin: {value: 'test', type: 'ARRAY'}},
};
- flushAsynchronousOperations();
+ flush();
const editor = element.shadowRoot
.querySelector('gr-plugin-config-array-editor');
@@ -92,13 +92,13 @@
name: 'testName',
config: {plugin: {value: 'true', type: 'BOOLEAN'}},
};
- flushAsynchronousOperations();
+ flush();
const toggle = element.shadowRoot
.querySelector('paper-toggle-button');
assert.ok(toggle);
toggle.click();
- flushAsynchronousOperations();
+ flush();
assert.isTrue(buildStub.called);
assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
@@ -111,14 +111,14 @@
name: 'testName',
config: {plugin: {value: 'test', type: 'STRING'}},
};
- flushAsynchronousOperations();
+ flush();
const input = element.shadowRoot
.querySelector('input');
assert.ok(input);
input.value = 'newTest';
input.dispatchEvent(new Event('input'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(buildStub.called);
assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
@@ -132,7 +132,7 @@
name: 'testName',
config: {plugin: {value: 'test', type: 'LIST', permitted_values}},
};
- flushAsynchronousOperations();
+ flush();
const select = element.shadowRoot
.querySelector('select');
@@ -140,7 +140,7 @@
select.value = 'newTest';
select.dispatchEvent(new Event(
'change', {bubbles: true, composed: true}));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(buildStub.called);
assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
deleted file mode 100644
index f272708..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.js
+++ /dev/null
@@ -1,378 +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 '@polymer/iron-input/iron-input.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-download-commands/gr-download-commands.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-subpage-styles.js';
-import '../../../styles/shared-styles.js';
-import '../gr-repo-plugin-config/gr-repo-plugin-config.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-repo_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const STATES = {
- active: {value: 'ACTIVE', label: 'Active'},
- readOnly: {value: 'READ_ONLY', label: 'Read Only'},
- hidden: {value: 'HIDDEN', label: 'Hidden'},
-};
-
-const SUBMIT_TYPES = {
- // Exclude INHERIT, which is handled specially.
- mergeIfNecessary: {
- value: 'MERGE_IF_NECESSARY',
- label: 'Merge if necessary',
- },
- fastForwardOnly: {
- value: 'FAST_FORWARD_ONLY',
- label: 'Fast forward only',
- },
- rebaseAlways: {
- value: 'REBASE_ALWAYS',
- label: 'Rebase Always',
- },
- rebaseIfNecessary: {
- value: 'REBASE_IF_NECESSARY',
- label: 'Rebase if necessary',
- },
- mergeAlways: {
- value: 'MERGE_ALWAYS',
- label: 'Merge always',
- },
- cherryPick: {
- value: 'CHERRY_PICK',
- label: 'Cherry pick',
- },
-};
-
-/**
- * @extends PolymerElement
- */
-class GrRepo extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo'; }
-
- static get properties() {
- return {
- params: Object,
- repo: String,
-
- _configChanged: {
- type: Boolean,
- value: false,
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- observer: '_loggedInChanged',
- },
- /** @type {?} */
- _repoConfig: Object,
- /** @type {?} */
- _pluginData: {
- type: Array,
- computed: '_computePluginData(_repoConfig.plugin_config.*)',
- },
- _readOnly: {
- type: Boolean,
- value: true,
- },
- _states: {
- type: Array,
- value() {
- return Object.values(STATES);
- },
- },
- _submitTypes: {
- type: Array,
- value() {
- return Object.values(SUBMIT_TYPES);
- },
- },
- _schemes: {
- type: Array,
- value() { return []; },
- computed: '_computeSchemes(_schemesObj)',
- observer: '_schemesChanged',
- },
- _selectedCommand: {
- type: String,
- value: 'Clone',
- },
- _selectedScheme: String,
- _schemesObj: Object,
- };
- }
-
- static get observers() {
- return [
- '_handleConfigChanged(_repoConfig.*)',
- ];
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadRepo();
-
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: this.repo},
- composed: true, bubbles: true,
- }));
- }
-
- _computePluginData(configRecord) {
- if (!configRecord ||
- !configRecord.base) { return []; }
-
- const pluginConfig = configRecord.base;
- return Object.keys(pluginConfig)
- .map(name => { return {name, config: pluginConfig[name]}; });
- }
-
- _loadRepo() {
- if (!this.repo) { return Promise.resolve(); }
-
- const promises = [];
-
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
-
- promises.push(this._getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- if (loggedIn) {
- this.$.restAPI.getRepoAccess(this.repo).then(access => {
- if (!access) { return Promise.resolve(); }
-
- // If the user is not an owner, is_owner is not a property.
- this._readOnly = !access[this.repo].is_owner;
- });
- }
- }));
-
- promises.push(this.$.restAPI.getProjectConfig(this.repo, errFn)
- .then(config => {
- if (!config) { return Promise.resolve(); }
-
- if (config.default_submit_type) {
- // The gr-select is bound to submit_type, which needs to be the
- // *configured* submit type. When default_submit_type is
- // present, the server reports the *effective* submit type in
- // submit_type, so we need to overwrite it before storing the
- // config in this.
- config.submit_type =
- config.default_submit_type.configured_value;
- }
- if (!config.state) {
- config.state = STATES.active.value;
- }
- this._repoConfig = config;
- this._loading = false;
- }));
-
- promises.push(this.$.restAPI.getConfig().then(config => {
- if (!config) { return Promise.resolve(); }
-
- this._schemesObj = config.download.schemes;
- }));
-
- return Promise.all(promises);
- }
-
- _computeLoadingClass(loading) {
- return loading ? 'loading' : '';
- }
-
- _computeHideClass(arr) {
- return !arr || !arr.length ? 'hide' : '';
- }
-
- _loggedInChanged(_loggedIn) {
- if (!_loggedIn) { return; }
- this.$.restAPI.getPreferences().then(prefs => {
- if (prefs.download_scheme) {
- // Note (issue 5180): normalize the download scheme with lower-case.
- this._selectedScheme = prefs.download_scheme.toLowerCase();
- }
- });
- }
-
- _formatBooleanSelect(item) {
- if (!item) { return; }
- let inheritLabel = 'Inherit';
- if (!(item.inherited_value === undefined)) {
- inheritLabel = `Inherit (${item.inherited_value})`;
- }
- return [
- {
- label: inheritLabel,
- value: 'INHERIT',
- },
- {
- label: 'True',
- value: 'TRUE',
- }, {
- label: 'False',
- value: 'FALSE',
- },
- ];
- }
-
- _formatSubmitTypeSelect(projectConfig) {
- if (!projectConfig) { return; }
- const allValues = Object.values(SUBMIT_TYPES);
- const type = projectConfig.default_submit_type;
- if (!type) {
- // Server is too old to report default_submit_type, so assume INHERIT
- // is not a valid value.
- return allValues;
- }
-
- let inheritLabel = 'Inherit';
- if (type.inherited_value) {
- let inherited = type.inherited_value;
- for (const val of allValues) {
- if (val.value === type.inherited_value) {
- inherited = val.label;
- break;
- }
- }
- inheritLabel = `Inherit (${inherited})`;
- }
- return [
- {
- label: inheritLabel,
- value: 'INHERIT',
- },
- ...allValues,
- ];
- }
-
- _isLoading() {
- return this._loading || this._loading === undefined;
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _formatRepoConfigForSave(repoConfig) {
- const configInputObj = {};
- for (const key in repoConfig) {
- if (repoConfig.hasOwnProperty(key)) {
- if (key === 'default_submit_type') {
- // default_submit_type is not in the input type, and the
- // configured value was already copied to submit_type by
- // _loadProject. Omit this property when saving.
- continue;
- }
- if (key === 'plugin_config') {
- configInputObj.plugin_config_values = repoConfig[key];
- } else if (typeof repoConfig[key] === 'object') {
- configInputObj[key] = repoConfig[key].configured_value;
- } else {
- configInputObj[key] = repoConfig[key];
- }
- }
- }
- return configInputObj;
- }
-
- _handleSaveRepoConfig() {
- return this.$.restAPI.saveRepoConfig(this.repo,
- this._formatRepoConfigForSave(this._repoConfig)).then(() => {
- this._configChanged = false;
- });
- }
-
- _handleConfigChanged() {
- if (this._isLoading()) { return; }
- this._configChanged = true;
- }
-
- _computeButtonDisabled(readOnly, configChanged) {
- return readOnly || !configChanged;
- }
-
- _computeHeaderClass(configChanged) {
- return configChanged ? 'edited' : '';
- }
-
- _computeSchemes(schemesObj) {
- return Object.keys(schemesObj);
- }
-
- _schemesChanged(schemes) {
- if (schemes.length === 0) { return; }
- if (!schemes.includes(this._selectedScheme)) {
- this._selectedScheme = schemes.sort()[0];
- }
- }
-
- _computeCommands(repo, schemesObj, _selectedScheme) {
- if (!schemesObj || !repo || !_selectedScheme) {
- return [];
- }
- const commands = [];
- let commandObj;
- if (schemesObj.hasOwnProperty(_selectedScheme)) {
- commandObj = schemesObj[_selectedScheme].clone_commands;
- }
- for (const title in commandObj) {
- if (!commandObj.hasOwnProperty(title)) { continue; }
- commands.push({
- title,
- command: commandObj[title]
- .replace(/\${project}/gi, encodeURI(repo))
- .replace(/\${project-base-name}/gi,
- encodeURI(repo.substring(repo.lastIndexOf('/') + 1))),
- });
- }
- return commands;
- }
-
- _computeRepositoriesClass(config) {
- return config ? 'showConfig': '';
- }
-
- _computeChangesUrl(name) {
- return GerritNav.getUrlForProjectChanges(name);
- }
-
- _handlePluginConfigChanged({detail: {name, config, notifyPath}}) {
- this._repoConfig.plugin_config[name] = config;
- this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
- }
-}
-
-customElements.define(GrRepo.is, GrRepo);
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
new file mode 100644
index 0000000..101c77a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -0,0 +1,455 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-download-commands/gr-download-commands';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-subpage-styles';
+import '../../../styles/shared-styles';
+import '../gr-repo-plugin-config/gr-repo-plugin-config';
+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-repo_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+ RestApiService,
+ ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ ConfigInfo,
+ RepoName,
+ InheritedBooleanInfo,
+ SchemesInfoMap,
+ ConfigInput,
+ PluginParameterToConfigParameterInfoMap,
+ PluginNameToPluginParametersMap,
+} from '../../../types/common';
+import {PluginData} from '../gr-repo-plugin-config/gr-repo-plugin-config';
+import {ProjectState} from '../../../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const STATES = {
+ active: {value: ProjectState.ACTIVE, label: 'Active'},
+ readOnly: {value: ProjectState.READ_ONLY, label: 'Read Only'},
+ hidden: {value: ProjectState.HIDDEN, label: 'Hidden'},
+};
+
+const SUBMIT_TYPES = {
+ // Exclude INHERIT, which is handled specially.
+ mergeIfNecessary: {
+ value: 'MERGE_IF_NECESSARY',
+ label: 'Merge if necessary',
+ },
+ fastForwardOnly: {
+ value: 'FAST_FORWARD_ONLY',
+ label: 'Fast forward only',
+ },
+ rebaseAlways: {
+ value: 'REBASE_ALWAYS',
+ label: 'Rebase Always',
+ },
+ rebaseIfNecessary: {
+ value: 'REBASE_IF_NECESSARY',
+ label: 'Rebase if necessary',
+ },
+ mergeAlways: {
+ value: 'MERGE_ALWAYS',
+ label: 'Merge always',
+ },
+ cherryPick: {
+ value: 'CHERRY_PICK',
+ label: 'Cherry pick',
+ },
+};
+
+export interface GrRepo {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-repo')
+export class GrRepo extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ repo?: RepoName;
+
+ @property({type: Boolean})
+ _configChanged = false;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Boolean, observer: '_loggedInChanged'})
+ _loggedIn = false;
+
+ @property({type: Object})
+ _repoConfig?: ConfigInfo;
+
+ @property({
+ type: Array,
+ computed: '_computePluginData(_repoConfig.plugin_config.*)',
+ })
+ _pluginData?: PluginData[];
+
+ @property({type: Boolean})
+ _readOnly = true;
+
+ @property({type: Array})
+ _states = Object.values(STATES);
+
+ @property({
+ type: Array,
+ computed: '_computeSchemes(_schemesDefault, _schemesObj)',
+ observer: '_schemesChanged',
+ })
+ _schemes: string[] = [];
+
+ // This is workaround to have _schemes with default value [],
+ // because assignment doesn't work when property has a computed attribute.
+ @property({type: Array})
+ _schemesDefault: string[] = [];
+
+ @property({type: String})
+ _selectedCommand = 'Clone';
+
+ @property({type: String})
+ _selectedScheme?: string;
+
+ @property({type: Object})
+ _schemesObj?: SchemesInfoMap;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadRepo();
+
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: this.repo},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _computePluginData(
+ configRecord: PolymerDeepPropertyChange<
+ PluginNameToPluginParametersMap,
+ PluginNameToPluginParametersMap
+ >
+ ) {
+ if (!configRecord || !configRecord.base) {
+ return [];
+ }
+
+ const pluginConfig = configRecord.base;
+ return Object.keys(pluginConfig).map(name => {
+ return {name, config: pluginConfig[name]};
+ });
+ }
+
+ _loadRepo() {
+ if (!this.repo) {
+ return Promise.resolve();
+ }
+
+ const promises = [];
+
+ const errFn: ErrorCallback = response => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+
+ promises.push(
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ if (loggedIn) {
+ const repo = this.repo;
+ if (!repo) throw new Error('undefined repo');
+ this.$.restAPI.getRepoAccess(repo).then(access => {
+ if (!access || this.repo !== repo) {
+ return;
+ }
+
+ // If the user is not an owner, is_owner is not a property.
+ this._readOnly = !access[repo].is_owner;
+ });
+ }
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getProjectConfig(this.repo, errFn).then(config => {
+ if (!config) {
+ return;
+ }
+
+ if (config.default_submit_type) {
+ // The gr-select is bound to submit_type, which needs to be the
+ // *configured* submit type. When default_submit_type is
+ // present, the server reports the *effective* submit type in
+ // submit_type, so we need to overwrite it before storing the
+ // config in this.
+ config.submit_type = config.default_submit_type.configured_value;
+ }
+ if (!config.state) {
+ config.state = STATES.active.value;
+ }
+ this._repoConfig = config;
+ this._loading = false;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getConfig().then(config => {
+ if (!config) {
+ return;
+ }
+
+ this._schemesObj = config.download.schemes;
+ })
+ );
+
+ return Promise.all(promises);
+ }
+
+ _computeLoadingClass(loading: boolean) {
+ return loading ? 'loading' : '';
+ }
+
+ _computeHideClass(arr?: PluginData[] | string[]) {
+ return !arr || !arr.length ? 'hide' : '';
+ }
+
+ _loggedInChanged(_loggedIn?: boolean) {
+ if (!_loggedIn) {
+ return;
+ }
+ this.$.restAPI.getPreferences().then(prefs => {
+ if (prefs?.download_scheme) {
+ // Note (issue 5180): normalize the download scheme with lower-case.
+ this._selectedScheme = prefs.download_scheme.toLowerCase();
+ }
+ });
+ }
+
+ _formatBooleanSelect(item: InheritedBooleanInfo) {
+ if (!item) {
+ return;
+ }
+ let inheritLabel = 'Inherit';
+ if (!(item.inherited_value === undefined)) {
+ inheritLabel = `Inherit (${item.inherited_value})`;
+ }
+ return [
+ {
+ label: inheritLabel,
+ value: 'INHERIT',
+ },
+ {
+ label: 'True',
+ value: 'TRUE',
+ },
+ {
+ label: 'False',
+ value: 'FALSE',
+ },
+ ];
+ }
+
+ _formatSubmitTypeSelect(projectConfig: ConfigInfo) {
+ if (!projectConfig) {
+ return;
+ }
+ const allValues = Object.values(SUBMIT_TYPES);
+ const type = projectConfig.default_submit_type;
+ if (!type) {
+ // Server is too old to report default_submit_type, so assume INHERIT
+ // is not a valid value.
+ return allValues;
+ }
+
+ let inheritLabel = 'Inherit';
+ if (type.inherited_value) {
+ inheritLabel = `Inherit (${type.inherited_value})`;
+ for (const val of allValues) {
+ if (val.value === type.inherited_value) {
+ inheritLabel = `Inherit (${val.label})`;
+ break;
+ }
+ }
+ }
+ return [
+ {
+ label: inheritLabel,
+ value: 'INHERIT',
+ },
+ ...allValues,
+ ];
+ }
+
+ _isLoading() {
+ return this._loading || this._loading === undefined;
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _formatRepoConfigForSave(repoConfig: ConfigInfo): ConfigInput {
+ const configInputObj: ConfigInput = {};
+ for (const configKey of Object.keys(repoConfig)) {
+ const key = configKey as keyof ConfigInfo;
+ if (key === 'default_submit_type') {
+ // default_submit_type is not in the input type, and the
+ // configured value was already copied to submit_type by
+ // _loadProject. Omit this property when saving.
+ continue;
+ }
+ if (key === 'plugin_config') {
+ configInputObj.plugin_config_values = repoConfig.plugin_config;
+ } else if (typeof repoConfig[key] === 'object') {
+ const repoConfigObj: any = repoConfig[key];
+ if (repoConfigObj.configured_value) {
+ configInputObj[key as keyof ConfigInput] =
+ repoConfigObj.configured_value;
+ }
+ } else {
+ configInputObj[key as keyof ConfigInput] = repoConfig[key] as any;
+ }
+ }
+ return configInputObj;
+ }
+
+ _handleSaveRepoConfig() {
+ if (!this._repoConfig || !this.repo)
+ return Promise.reject(new Error('undefined repoConfig or repo'));
+ return this.$.restAPI
+ .saveRepoConfig(
+ this.repo,
+ this._formatRepoConfigForSave(this._repoConfig)
+ )
+ .then(() => {
+ this._configChanged = false;
+ });
+ }
+
+ @observe('_repoConfig.*')
+ _handleConfigChanged() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._configChanged = true;
+ }
+
+ _computeButtonDisabled(readOnly: boolean, configChanged: boolean) {
+ return readOnly || !configChanged;
+ }
+
+ _computeHeaderClass(configChanged: boolean) {
+ return configChanged ? 'edited' : '';
+ }
+
+ _computeSchemes(schemesDefault: string[], schemesObj?: SchemesInfoMap) {
+ return !schemesObj ? schemesDefault : Object.keys(schemesObj);
+ }
+
+ _schemesChanged(schemes: string[]) {
+ if (schemes.length === 0) {
+ return;
+ }
+ if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
+ this._selectedScheme = schemes.sort()[0];
+ }
+ }
+
+ _computeCommands(
+ repo?: RepoName,
+ schemesObj?: SchemesInfoMap,
+ _selectedScheme?: string
+ ) {
+ if (!schemesObj || !repo || !_selectedScheme) {
+ return [];
+ }
+ const commands = [];
+ let commandObj: {[title: string]: string} = {};
+ if (hasOwnProperty(schemesObj, _selectedScheme)) {
+ commandObj = schemesObj[_selectedScheme].clone_commands;
+ }
+ for (const title in commandObj) {
+ if (!hasOwnProperty(commandObj, title)) {
+ continue;
+ }
+ commands.push({
+ title,
+ command: commandObj[title]
+ .replace(/\${project}/gi, encodeURI(repo))
+ .replace(
+ /\${project-base-name}/gi,
+ encodeURI(repo.substring(repo.lastIndexOf('/') + 1))
+ ),
+ });
+ }
+ return commands;
+ }
+
+ _computeRepositoriesClass(config: InheritedBooleanInfo) {
+ return config ? 'showConfig' : '';
+ }
+
+ _computeChangesUrl(name: RepoName) {
+ return GerritNav.getUrlForProjectChanges(name);
+ }
+
+ _handlePluginConfigChanged({
+ detail: {name, config, notifyPath},
+ }: {
+ detail: {
+ name: string;
+ config: PluginParameterToConfigParameterInfoMap;
+ notifyPath: string;
+ };
+ }) {
+ if (this._repoConfig?.plugin_config) {
+ this._repoConfig.plugin_config[name] = config;
+ this.notifyPath('_repoConfig.plugin_config.' + notifyPath);
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo': GrRepo;
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
index 26d05c3..2e86758 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_html.ts
@@ -52,8 +52,8 @@
<div class="info">
<h1 id="Title" class="heading-1">
[[repo]]
- <hr />
</h1>
+ <hr />
<div>
<a href$="[[_computeChangesUrl(repo)]]">(view changes)</a>
</div>
diff --git a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
index 3b42e3b..93a9d64 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-repo.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
const basicFixture = fixtureFromElement('gr-repo');
@@ -90,11 +89,11 @@
function getFormFields() {
const selects = Array.from(
- dom(element.root).querySelectorAll('select'));
+ element.root.querySelectorAll('select'));
const textareas = Array.from(
- dom(element.root).querySelectorAll('iron-autogrow-textarea'));
+ element.root.querySelectorAll('iron-autogrow-textarea'));
const inputs = Array.from(
- dom(element.root).querySelectorAll('input'));
+ element.root.querySelectorAll('input'));
return inputs.concat(textareas).concat(selects);
}
@@ -128,7 +127,7 @@
config: 'data',
notifyPath: 'path',
}});
- flushAsynchronousOperations();
+ flush();
assert.equal(element._repoConfig.plugin_config.test, 'data');
assert.equal(notifyStub.lastCall.args[0],
@@ -145,12 +144,12 @@
test('download commands visibility', () => {
element._loading = false;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.downloadContent.classList.contains('hide'));
assert.isTrue(getComputedStyle(element.$.downloadContent)
.display == 'none');
element._schemesObj = SCHEMES;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.downloadContent.classList.contains('hide'));
assert.isFalse(getComputedStyle(element.$.downloadContent)
.display == 'none');
@@ -184,7 +183,7 @@
test('all form elements are disabled when not admin', done => {
element.repo = REPO;
element._loadRepo().then(() => {
- flushAsynchronousOperations();
+ flush();
const formFields = getFormFields();
for (const field of formFields) {
assert.isTrue(field.hasAttribute('disabled'));
@@ -272,7 +271,7 @@
test('all form elements are enabled', done => {
element._loadRepo().then(() => {
- flushAsynchronousOperations();
+ flush();
const formFields = getFormFields();
for (const field of formFields) {
assert.isFalse(field.hasAttribute('disabled'));
@@ -326,7 +325,7 @@
const saveStub = sinon.stub(element.$.restAPI, 'saveRepoConfig')
.callsFake(() => Promise.resolve({}));
- const button = dom(element.root).querySelector('gr-button');
+ const button = element.root.querySelector('gr-button');
return element._loadRepo().then(() => {
assert.isTrue(button.hasAttribute('disabled'));
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
deleted file mode 100644
index a1f10de..0000000
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
+++ /dev/null
@@ -1,281 +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/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-rule-editor_html.js';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
-import {AccessPermissions} from '../../../utils/access-util.js';
-
-/**
- * Fired when the rule has been modified or removed.
- *
- * @event access-modified
- */
-
-/**
- * Fired when a rule that was previously added was removed.
- *
- * @event added-rule-removed
- */
-
-const PRIORITY_OPTIONS = [
- 'BATCH',
- 'INTERACTIVE',
-];
-
-const Action = {
- ALLOW: 'ALLOW',
- DENY: 'DENY',
- BLOCK: 'BLOCK',
-};
-
-const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.BLOCK];
-
-const ForcePushOptions = {
- ALLOW: [
- {name: 'Allow pushing (but not force pushing)', value: false},
- {name: 'Allow pushing with or without force', value: true},
- ],
- BLOCK: [
- {name: 'Block pushing with or without force', value: false},
- {name: 'Block force pushing', value: true},
- ],
-};
-
-const FORCE_EDIT_OPTIONS = [
- {
- name: 'No Force Edit',
- value: false,
- },
- {
- name: 'Force Edit',
- value: true,
- },
-];
-
-/**
- * @extends PolymerElement
- */
-class GrRuleEditor extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-rule-editor'; }
-
- static get properties() {
- return {
- hasRange: Boolean,
- /** @type {?} */
- label: Object,
- editing: {
- type: Boolean,
- value: false,
- observer: '_handleEditingChanged',
- },
- groupId: String,
- groupName: String,
- permission: String,
- /** @type {?} */
- rule: {
- type: Object,
- notify: true,
- },
- section: String,
-
- _deleted: {
- type: Boolean,
- value: false,
- },
- _originalRuleValues: Object,
- };
- }
-
- static get observers() {
- return [
- '_handleValueChange(rule.value.*)',
- ];
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('access-saved',
- () => this._handleAccessSaved());
- }
-
- /** @override */
- ready() {
- super.ready();
- // Called on ready rather than the observer because when new rules are
- // added, the observer is triggered prior to being ready.
- if (!this.rule) { return; } // Check needed for test purposes.
- this._setupValues(this.rule);
- }
-
- /** @override */
- attached() {
- super.attached();
- if (!this.rule) { return; } // Check needed for test purposes.
- if (!this._originalRuleValues) {
- // Observer _handleValueChange is called after the ready()
- // method finishes. Original values must be set later to
- // avoid set .modified flag to true
- this._setOriginalRuleValues(this.rule.value);
- }
- }
-
- _setupValues(rule) {
- if (!rule.value) {
- this._setDefaultRuleValues();
- }
- }
-
- _computeForce(permission, action) {
- if (AccessPermissions.push.id === permission &&
- action !== Action.DENY) {
- return true;
- }
-
- return AccessPermissions.editTopicName.id === permission;
- }
-
- _computeForceClass(permission, action) {
- return this._computeForce(permission, action) ? 'force' : '';
- }
-
- _computeGroupPath(group) {
- return `${getBaseUrl()}/admin/groups/${encodeURL(group, true)}`;
- }
-
- _handleAccessSaved() {
- // Set a new 'original' value to keep track of after the value has been
- // saved.
- this._setOriginalRuleValues(this.rule.value);
- }
-
- _handleEditingChanged(editing, editingOld) {
- // Ignore when editing gets set initially.
- if (!editingOld) { return; }
- // Restore original values if no longer editing.
- if (!editing) {
- this._handleUndoChange();
- }
- }
-
- _computeSectionClass(editing, deleted) {
- const classList = [];
- if (editing) {
- classList.push('editing');
- }
- if (deleted) {
- classList.push('deleted');
- }
- return classList.join(' ');
- }
-
- _computeForceOptions(permission, action) {
- if (permission === AccessPermissions.push.id) {
- if (action === Action.ALLOW) {
- return ForcePushOptions.ALLOW;
- } else if (action === Action.BLOCK) {
- return ForcePushOptions.BLOCK;
- } else {
- return [];
- }
- } else if (permission === AccessPermissions.editTopicName.id) {
- return FORCE_EDIT_OPTIONS;
- }
- return [];
- }
-
- _getDefaultRuleValues(permission, label) {
- const ruleAction = Action.ALLOW;
- const value = {};
- if (permission === 'priority') {
- value.action = PRIORITY_OPTIONS[0];
- return value;
- } else if (label) {
- value.min = label.values[0].value;
- value.max = label.values[label.values.length - 1].value;
- } else if (this._computeForce(permission, ruleAction)) {
- value.force =
- this._computeForceOptions(permission, ruleAction)[0].value;
- }
- value.action = DROPDOWN_OPTIONS[0];
- return value;
- }
-
- _setDefaultRuleValues() {
- this.set('rule.value', this._getDefaultRuleValues(this.permission,
- this.label));
- }
-
- _computeOptions(permission) {
- if (permission === 'priority') {
- return PRIORITY_OPTIONS;
- }
- return DROPDOWN_OPTIONS;
- }
-
- _handleRemoveRule() {
- if (this.rule.value.added) {
- this.dispatchEvent(new CustomEvent(
- 'added-rule-removed', {bubbles: true, composed: true}));
- }
- this._deleted = true;
- this.rule.value.deleted = true;
- this.dispatchEvent(
- new CustomEvent('access-modified', {bubbles: true, composed: true}));
- }
-
- _handleUndoRemove() {
- this._deleted = false;
- delete this.rule.value.deleted;
- }
-
- _handleUndoChange() {
- // gr-permission will take care of removing rules that were added but
- // unsaved. We need to keep the added bit for the filter.
- if (this.rule.value.added) { return; }
- this.set('rule.value', Object.assign({}, this._originalRuleValues));
- this._deleted = false;
- delete this.rule.value.deleted;
- delete this.rule.value.modified;
- }
-
- _handleValueChange() {
- if (!this._originalRuleValues) { return; }
- this.rule.value.modified = true;
- // Allows overall access page to know a change has been made.
- this.dispatchEvent(
- new CustomEvent('access-modified', {bubbles: true, composed: true}));
- }
-
- _setOriginalRuleValues(value) {
- this._originalRuleValues = Object.assign({}, value);
- }
-}
-
-customElements.define(GrRuleEditor.is, GrRuleEditor);
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
new file mode 100644
index 0000000..8843933
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.ts
@@ -0,0 +1,316 @@
+/**
+ * @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/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-rest-api-interface/gr-rest-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-rule-editor_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {AccessPermissionId} from '../../../utils/access-util';
+import {property, customElement, observe} from '@polymer/decorators';
+
+/**
+ * Fired when the rule has been modified or removed.
+ *
+ * @event access-modified
+ */
+
+/**
+ * Fired when a rule that was previously added was removed.
+ *
+ * @event added-rule-removed
+ */
+
+const PRIORITY_OPTIONS = ['BATCH', 'INTERACTIVE'];
+
+const Action = {
+ ALLOW: 'ALLOW',
+ DENY: 'DENY',
+ BLOCK: 'BLOCK',
+};
+
+const DROPDOWN_OPTIONS = [Action.ALLOW, Action.DENY, Action.BLOCK];
+
+const ForcePushOptions = {
+ ALLOW: [
+ {name: 'Allow pushing (but not force pushing)', value: false},
+ {name: 'Allow pushing with or without force', value: true},
+ ],
+ BLOCK: [
+ {name: 'Block pushing with or without force', value: false},
+ {name: 'Block force pushing', value: true},
+ ],
+};
+
+const FORCE_EDIT_OPTIONS = [
+ {
+ name: 'No Force Edit',
+ value: false,
+ },
+ {
+ name: 'Force Edit',
+ value: true,
+ },
+];
+
+interface Rule {
+ value: RuleValue;
+}
+
+interface RuleValue {
+ min?: number;
+ max?: number;
+ force?: boolean;
+ action?: string;
+ added?: boolean;
+ modified?: boolean;
+ deleted?: boolean;
+}
+
+interface RuleLabel {
+ values: RuleLabelValue[];
+}
+
+interface RuleLabelValue {
+ value: number;
+ text: string;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-rule-editor': GrRuleEditor;
+ }
+}
+
+@customElement('gr-rule-editor')
+export class GrRuleEditor extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean})
+ hasRange?: boolean;
+
+ @property({type: Object})
+ label?: RuleLabel;
+
+ @property({type: Boolean, observer: '_handleEditingChanged'})
+ editing = false;
+
+ @property({type: String})
+ groupId?: string;
+
+ @property({type: String})
+ groupName?: string;
+
+ // This is required value for this component
+ @property({type: String})
+ permission!: AccessPermissionId;
+
+ @property({type: Object, notify: true})
+ rule?: Rule;
+
+ @property({type: String})
+ section?: string;
+
+ @property({type: Boolean})
+ _deleted = false;
+
+ @property({type: Object})
+ _originalRuleValues?: RuleValue;
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('access-saved', () => this._handleAccessSaved());
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ // Called on ready rather than the observer because when new rules are
+ // added, the observer is triggered prior to being ready.
+ if (!this.rule) {
+ return;
+ } // Check needed for test purposes.
+ this._setupValues(this.rule);
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ // Check needed for test purposes.
+ if (!this._originalRuleValues && this.rule) {
+ // Observer _handleValueChange is called after the ready()
+ // method finishes. Original values must be set later to
+ // avoid set .modified flag to true
+ this._setOriginalRuleValues(this.rule.value);
+ }
+ }
+
+ _setupValues(rule: Rule) {
+ if (!rule.value) {
+ this._setDefaultRuleValues();
+ }
+ }
+
+ _computeForce(permission: AccessPermissionId, action: string) {
+ if (AccessPermissionId.PUSH === permission && action !== Action.DENY) {
+ return true;
+ }
+
+ return AccessPermissionId.EDIT_TOPIC_NAME === permission;
+ }
+
+ _computeForceClass(permission: AccessPermissionId, action: string) {
+ return this._computeForce(permission, action) ? 'force' : '';
+ }
+
+ _computeGroupPath(group: string) {
+ return `${getBaseUrl()}/admin/groups/${encodeURL(group, true)}`;
+ }
+
+ _handleAccessSaved() {
+ if (!this.rule) return;
+ // Set a new 'original' value to keep track of after the value has been
+ // saved.
+ this._setOriginalRuleValues(this.rule.value);
+ }
+
+ _handleEditingChanged(editing: boolean, editingOld: boolean) {
+ // Ignore when editing gets set initially.
+ if (!editingOld) {
+ return;
+ }
+ // Restore original values if no longer editing.
+ if (!editing) {
+ this._handleUndoChange();
+ }
+ }
+
+ _computeSectionClass(editing: boolean, deleted: boolean) {
+ const classList = [];
+ if (editing) {
+ classList.push('editing');
+ }
+ if (deleted) {
+ classList.push('deleted');
+ }
+ return classList.join(' ');
+ }
+
+ _computeForceOptions(permission: string, action: string) {
+ if (permission === AccessPermissionId.PUSH) {
+ if (action === Action.ALLOW) {
+ return ForcePushOptions.ALLOW;
+ } else if (action === Action.BLOCK) {
+ return ForcePushOptions.BLOCK;
+ } else {
+ return [];
+ }
+ } else if (permission === AccessPermissionId.EDIT_TOPIC_NAME) {
+ return FORCE_EDIT_OPTIONS;
+ }
+ return [];
+ }
+
+ _getDefaultRuleValues(permission: AccessPermissionId, label?: RuleLabel) {
+ const ruleAction = Action.ALLOW;
+ const value: RuleValue = {};
+ if (permission === AccessPermissionId.PRIORITY) {
+ value.action = PRIORITY_OPTIONS[0];
+ return value;
+ } else if (label) {
+ value.min = label.values[0].value;
+ value.max = label.values[label.values.length - 1].value;
+ } else if (this._computeForce(permission, ruleAction)) {
+ value.force = this._computeForceOptions(permission, ruleAction)[0].value;
+ }
+ value.action = DROPDOWN_OPTIONS[0];
+ return value;
+ }
+
+ _setDefaultRuleValues() {
+ this.set(
+ 'rule.value',
+ this._getDefaultRuleValues(this.permission, this.label)
+ );
+ }
+
+ _computeOptions(permission: string) {
+ if (permission === 'priority') {
+ return PRIORITY_OPTIONS;
+ }
+ return DROPDOWN_OPTIONS;
+ }
+
+ _handleRemoveRule() {
+ if (!this.rule) return;
+ if (this.rule.value.added) {
+ this.dispatchEvent(
+ new CustomEvent('added-rule-removed', {bubbles: true, composed: true})
+ );
+ }
+ this._deleted = true;
+ this.rule.value.deleted = true;
+ this.dispatchEvent(
+ new CustomEvent('access-modified', {bubbles: true, composed: true})
+ );
+ }
+
+ _handleUndoRemove() {
+ if (!this.rule) return;
+ this._deleted = false;
+ delete this.rule.value.deleted;
+ }
+
+ _handleUndoChange() {
+ if (!this.rule) return;
+ // gr-permission will take care of removing rules that were added but
+ // unsaved. We need to keep the added bit for the filter.
+ if (this.rule.value.added) {
+ return;
+ }
+ this.set('rule.value', {...this._originalRuleValues});
+ this._deleted = false;
+ delete this.rule.value.deleted;
+ delete this.rule.value.modified;
+ }
+
+ @observe('rule.value.*')
+ _handleValueChange() {
+ if (!this._originalRuleValues || !this.rule) {
+ return;
+ }
+ this.rule.value.modified = true;
+ // Allows overall access page to know a change has been made.
+ this.dispatchEvent(
+ new CustomEvent('access-modified', {bubbles: true, composed: true})
+ );
+ }
+
+ _setOriginalRuleValues(value: RuleValue) {
+ this._originalRuleValues = {...value};
+ }
+}
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
index 9364a50..b0065d9 100644
--- a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-rule-editor.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-rule-editor');
@@ -196,7 +195,7 @@
// Typically called on ready since elements will have properies defined
// by the parent element.
element._setupValues(element.rule);
- flushAsynchronousOperations();
+ flush();
flush(() => {
element.attached();
done();
@@ -209,8 +208,8 @@
test('values are set correctly', () => {
assert.equal(element.$.action.bindValue, element.rule.value.action);
- assert.isNotOk(dom(element.root).querySelector('#labelMin'));
- assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+ assert.isNotOk(element.root.querySelector('#labelMin'));
+ assert.isNotOk(element.root.querySelector('#labelMax'));
assert.isFalse(element.$.force.classList.contains('force'));
});
@@ -230,7 +229,7 @@
test('modify value', () => {
assert.isNotOk(element.rule.value.modified);
element.$.action.bindValue = 'DENY';
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.rule.value.modified);
// The original value should now differ from the rule values.
@@ -238,7 +237,7 @@
});
test('all selects are disabled when not in edit mode', () => {
- const selects = dom(element.root).querySelectorAll('select');
+ const selects = element.root.querySelectorAll('select');
for (const select of selects) {
assert.isTrue(select.disabled);
}
@@ -304,7 +303,7 @@
};
element.section = 'refs/*';
element._setupValues(element.rule);
- flushAsynchronousOperations();
+ flush();
element.rule.value.added = true;
flush(() => {
element.attached();
@@ -331,7 +330,7 @@
test('modify value', () => {
assert.isNotOk(element.rule.value.modified);
element.$.force.bindValue = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.rule.value.modified);
// The original value should now differ from the rule values.
@@ -343,7 +342,7 @@
const removeStub = sinon.stub();
element.addEventListener('added-rule-removed', removeStub);
MockInteractions.tap(element.$.removeBtn);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(removeStub.called);
});
});
@@ -370,7 +369,7 @@
};
element.section = 'refs/*';
element._setupValues(element.rule);
- flushAsynchronousOperations();
+ flush();
flush(() => {
element.attached();
done();
@@ -384,10 +383,10 @@
test('values are set correctly', () => {
assert.equal(element.$.action.bindValue, element.rule.value.action);
assert.equal(
- dom(element.root).querySelector('#labelMin').bindValue,
+ element.root.querySelector('#labelMin').bindValue,
element.rule.value.min);
assert.equal(
- dom(element.root).querySelector('#labelMax').bindValue,
+ element.root.querySelector('#labelMax').bindValue,
element.rule.value.max);
assert.isFalse(element.$.force.classList.contains('force'));
});
@@ -396,8 +395,8 @@
const removeStub = sinon.stub();
element.addEventListener('added-rule-removed', removeStub);
assert.isNotOk(element.rule.value.modified);
- dom(element.root).querySelector('#labelMin').bindValue = 1;
- flushAsynchronousOperations();
+ element.root.querySelector('#labelMin').bindValue = 1;
+ flush();
assert.isTrue(element.rule.value.modified);
assert.isFalse(removeStub.called);
@@ -423,7 +422,7 @@
};
element.section = 'refs/*';
element._setupValues(element.rule);
- flushAsynchronousOperations();
+ flush();
element.rule.value.added = true;
flush(() => {
element.attached();
@@ -449,18 +448,18 @@
element.$.action.bindValue,
expectedRuleValue.action);
assert.equal(
- dom(element.root).querySelector('#labelMin').bindValue,
+ element.root.querySelector('#labelMin').bindValue,
expectedRuleValue.min);
assert.equal(
- dom(element.root).querySelector('#labelMax').bindValue,
+ element.root.querySelector('#labelMax').bindValue,
expectedRuleValue.max);
});
});
test('modify value', () => {
assert.isNotOk(element.rule.value.modified);
- dom(element.root).querySelector('#labelMin').bindValue = 1;
- flushAsynchronousOperations();
+ element.root.querySelector('#labelMin').bindValue = 1;
+ flush();
assert.isTrue(element.rule.value.modified);
// The original value should now differ from the rule values.
@@ -481,7 +480,7 @@
};
element.section = 'refs/*';
element._setupValues(element.rule);
- flushAsynchronousOperations();
+ flush();
flush(() => {
element.attached();
done();
@@ -496,16 +495,16 @@
assert.isTrue(element.$.force.classList.contains('force'));
assert.equal(element.$.action.bindValue, element.rule.value.action);
assert.equal(
- dom(element.root).querySelector('#force').bindValue,
+ element.root.querySelector('#force').bindValue,
element.rule.value.force);
- assert.isNotOk(dom(element.root).querySelector('#labelMin'));
- assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+ assert.isNotOk(element.root.querySelector('#labelMin'));
+ assert.isNotOk(element.root.querySelector('#labelMax'));
});
test('modify value', () => {
assert.isNotOk(element.rule.value.modified);
element.$.action.bindValue = false;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.rule.value.modified);
// The original value should now differ from the rule values.
@@ -522,7 +521,7 @@
};
element.section = 'refs/*';
element._setupValues(element.rule);
- flushAsynchronousOperations();
+ flush();
element.rule.value.added = true;
flush(() => {
element.attached();
@@ -549,7 +548,7 @@
test('modify value', () => {
assert.isNotOk(element.rule.value.modified);
element.$.force.bindValue = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.rule.value.modified);
// The original value should now differ from the rule values.
@@ -570,7 +569,7 @@
};
element.section = 'refs/*';
element._setupValues(element.rule);
- flushAsynchronousOperations();
+ flush();
flush(() => {
element.attached();
done();
@@ -585,16 +584,16 @@
assert.isTrue(element.$.force.classList.contains('force'));
assert.equal(element.$.action.bindValue, element.rule.value.action);
assert.equal(
- dom(element.root).querySelector('#force').bindValue,
+ element.root.querySelector('#force').bindValue,
element.rule.value.force);
- assert.isNotOk(dom(element.root).querySelector('#labelMin'));
- assert.isNotOk(dom(element.root).querySelector('#labelMax'));
+ assert.isNotOk(element.root.querySelector('#labelMin'));
+ assert.isNotOk(element.root.querySelector('#labelMax'));
});
test('modify value', () => {
assert.isNotOk(element.rule.value.modified);
element.$.action.bindValue = false;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.rule.value.modified);
// The original value should now differ from the rule values.
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 b2ce8fd..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ /dev/null
@@ -1,318 +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 {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {pluginLoader} 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';
-
-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();
- pluginLoader.awaitPluginsLoaded().then(() => {
- this._dynamicCellEndpoints = pluginEndpoints.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
- );
- 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))
- .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..d70e891
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.ts
@@ -0,0 +1,446 @@
+/**
+ * @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,
+ Timestamp,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+enum ChangeSize {
+ XS = 10,
+ SMALL = 50,
+ MEDIUM = 250,
+ LARGE = 1000,
+}
+
+// export for testing
+export enum LabelCategory {
+ NOT_APPLICABLE = 'NOT_APPLICABLE',
+ APPROVED = 'APPROVED',
+ POSITIVE = 'POSITIVE',
+ NEUTRAL = 'NEUTRAL',
+ UNRESOLVED_COMMENTS = 'UNRESOLVED_COMMENTS',
+ NEGATIVE = 'NEGATIVE',
+ REJECTED = 'REJECTED',
+}
+
+export interface ChangeListToggleReviewedDetail {
+ change: ChangeInfo;
+ reviewed: boolean;
+}
+
+// How many reviewers should be shown with an account-label?
+const PRIMARY_REVIEWERS_COUNT = 2;
+
+@customElement('gr-change-list-item')
+export 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];
+ const category = this._computeLabelCategory(change, labelName);
+ if (!label || category === LabelCategory.NOT_APPLICABLE) {
+ return 'Label not applicable';
+ }
+ if (category === LabelCategory.UNRESOLVED_COMMENTS) {
+ const num = change?.unresolved_comment_count ?? 0;
+ const plural = num > 1 ? 's' : '';
+ return `${num} unresolved comment${plural}`;
+ }
+ 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 category = this._computeLabelCategory(change, labelName);
+ const classes = ['cell', 'label'];
+ switch (category) {
+ case LabelCategory.NOT_APPLICABLE:
+ classes.push('u-gray-background');
+ break;
+ case LabelCategory.APPROVED:
+ classes.push('u-green');
+ break;
+ case LabelCategory.POSITIVE:
+ classes.push('u-monospace');
+ classes.push('u-green');
+ break;
+ case LabelCategory.NEGATIVE:
+ classes.push('u-monospace');
+ classes.push('u-red');
+ break;
+ case LabelCategory.REJECTED:
+ classes.push('u-red');
+ break;
+ }
+ return classes.sort().join(' ');
+ }
+
+ _computeHasLabelIcon(change: ChangeInfo | undefined, labelName: string) {
+ return this._computeLabelIcon(change, labelName) !== '';
+ }
+
+ _computeLabelIcon(change: ChangeInfo | undefined, labelName: string): string {
+ const category = this._computeLabelCategory(change, labelName);
+ switch (category) {
+ case LabelCategory.APPROVED:
+ return 'gr-icons:check';
+ case LabelCategory.UNRESOLVED_COMMENTS:
+ return 'gr-icons:comment';
+ case LabelCategory.REJECTED:
+ return 'gr-icons:close';
+ default:
+ return '';
+ }
+ }
+
+ _computeLabelCategory(change: ChangeInfo | undefined, labelName: string) {
+ const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+ if (!label) {
+ return LabelCategory.NOT_APPLICABLE;
+ }
+ if (label.rejected) {
+ return LabelCategory.REJECTED;
+ }
+ if (label.value && label.value < 0) {
+ return LabelCategory.NEGATIVE;
+ }
+ if (change?.unresolved_comment_count && labelName === 'Code-Review') {
+ return LabelCategory.UNRESOLVED_COMMENTS;
+ }
+ if (label.approved) {
+ return LabelCategory.APPROVED;
+ }
+ if (label.value && label.value > 0) {
+ return LabelCategory.POSITIVE;
+ }
+ return LabelCategory.NEUTRAL;
+ }
+
+ _computeLabelValue(change: ChangeInfo | undefined, labelName: string) {
+ const label: QuickLabelInfo | undefined = change?.labels?.[labelName];
+ const category = this._computeLabelCategory(change, labelName);
+ switch (category) {
+ case LabelCategory.NOT_APPLICABLE:
+ return '';
+ case LabelCategory.APPROVED:
+ return '\u2713'; // ✓
+ case LabelCategory.POSITIVE:
+ return `+${label?.value}`;
+ case LabelCategory.NEUTRAL:
+ return '';
+ case LabelCategory.UNRESOLVED_COMMENTS:
+ return 'u';
+ case LabelCategory.NEGATIVE:
+ return `${label?.value}`;
+ case LabelCategory.REJECTED:
+ return '\u2715'; // ✕
+ }
+ }
+
+ _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 || !account._account_id) {
+ 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 < ChangeSize.XS) {
+ return 'XS';
+ } else if (delta < ChangeSize.SMALL) {
+ return 'S';
+ } else if (delta < ChangeSize.MEDIUM) {
+ return 'M';
+ } else if (delta < ChangeSize.LARGE) {
+ return 'L';
+ } else {
+ return 'XL';
+ }
+ }
+
+ _computeWaiting(
+ account?: AccountInfo,
+ change?: ChangeInfo
+ ): Timestamp | undefined {
+ if (!account?._account_id || !change?.attention_set) return undefined;
+ return change?.attention_set[account._account_id]?.last_update;
+ }
+
+ toggleReviewed() {
+ if (!this.change) return;
+ const newVal = !this.change?.reviewed;
+ this.set('change.reviewed', newVal);
+ const detail: ChangeListToggleReviewedDetail = {
+ change: this.change,
+ reviewed: newVal,
+ };
+ this.dispatchEvent(
+ new CustomEvent('toggle-reviewed', {
+ bubbles: true,
+ composed: true,
+ detail,
+ })
+ );
+ }
+
+ _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,
+ });
+ }
+
+ _computeCommaHidden(index?: number, change?: ChangeInfo) {
+ if (index === undefined) return false;
+ if (change === undefined) return false;
+
+ const additionalCount = this._computeAdditionalReviewersCount(change);
+ const primaryCount = this._computePrimaryReviewers(change).length;
+ const isLast = index === primaryCount - 1;
+ return isLast && additionalCount === 0;
+ }
+}
+
+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_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
index 7fa59d4..fdb4534 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_html.ts
@@ -32,9 +32,6 @@
font-weight: var(--font-weight-bold);
color: var(--primary-text-color);
}
- :host([highlight]) {
- background-color: var(--assignee-highlight-color);
- }
.container {
position: relative;
}
@@ -92,10 +89,12 @@
font-size: var(--font-size-mono);
line-height: var(--line-height-mono);
}
- .u-green {
+ .u-green,
+ .u-green iron-icon {
color: var(--positive-green-text-color);
}
- .u-red {
+ .u-red,
+ .u-red iron-icon {
color: var(--negative-red-text-color);
}
.u-gray-background {
@@ -108,8 +107,8 @@
.cell.label {
font-weight: var(--font-weight-normal);
}
- .lastChildHidden:last-of-type {
- display: none;
+ .cell.label iron-icon {
+ vertical-align: top;
}
@media only screen and (max-width: 50em) {
:host {
@@ -192,15 +191,21 @@
is="dom-repeat"
items="[[_computePrimaryReviewers(change)]]"
as="reviewer"
+ indexAs="index"
>
<gr-account-link
hide-avatar=""
hide-status=""
+ first-name
highlight-attention
change="[[change]]"
account="[[reviewer]]"
></gr-account-link
- ><span class="lastChildHidden" aria-hidden="true">, </span>
+ ><span
+ hidden$="[[_computeCommaHidden(index, change)]]"
+ aria-hidden="true"
+ >,
+ </span>
</template>
<template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
<span title="[[_computeAdditionalReviewersTitle(change, config)]]">
@@ -260,6 +265,26 @@
></gr-date-formatter>
</td>
<td
+ class="cell submitted"
+ hidden$="[[isColumnHidden('Submitted', visibleChangeTableColumns)]]"
+ >
+ <gr-date-formatter
+ has-tooltip=""
+ date-str="[[change.submitted]]"
+ ></gr-date-formatter>
+ </td>
+ <td
+ class="cell waiting"
+ hidden$="[[isColumnHidden('Waiting', visibleChangeTableColumns)]]"
+ >
+ <gr-date-formatter
+ has-tooltip=""
+ force-relative=""
+ relative-option-no-ago=""
+ date-str="[[_computeWaiting(account, change)]]"
+ ></gr-date-formatter>
+ </td>
+ <td
class="cell size"
hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]"
>
@@ -277,7 +302,12 @@
title$="[[_computeLabelTitle(change, labelName)]]"
class$="[[_computeLabelClass(change, labelName)]]"
>
- [[_computeLabelValue(change, labelName)]]
+ <template is="dom-if" if="[[_computeHasLabelIcon(change, labelName)]]">
+ <iron-icon icon="[[_computeLabelIcon(change, labelName)]]"></iron-icon>
+ </template>
+ <template is="dom-if" if="[[!_computeHasLabelIcon(change, labelName)]]">
+ <span>[[_computeLabelValue(change, labelName)]]</span>
+ </template>
</td>
</template>
<template
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..d3274f3 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
@@ -18,6 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-change-list-item.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {LabelCategory} from './gr-change-list-item.js';
const basicFixture = fixtureFromElement('gr-change-list-item');
@@ -32,17 +33,45 @@
element = basicFixture.instantiate();
});
- test('computed fields', () => {
+ test('_computeLabelCategory', () => {
+ assert.equal(element._computeLabelCategory({labels: {}}),
+ LabelCategory.NOT_APPLICABLE);
+ assert.equal(element._computeLabelCategory(
+ {labels: {}}, 'Verified'), LabelCategory.NOT_APPLICABLE);
+ assert.equal(element._computeLabelCategory(
+ {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
+ LabelCategory.APPROVED);
+ assert.equal(element._computeLabelCategory(
+ {labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
+ LabelCategory.REJECTED);
+ assert.equal(element._computeLabelCategory(
+ {
+ labels: {'Code-Review': {approved: true, value: 1}},
+ unresolved_comment_count: 1,
+ }, 'Code-Review'),
+ LabelCategory.UNRESOLVED_COMMENTS);
+ assert.equal(element._computeLabelCategory(
+ {labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
+ LabelCategory.POSITIVE);
+ assert.equal(element._computeLabelCategory(
+ {labels: {'Code-Review': {value: -1}}}, 'Code-Review'),
+ LabelCategory.NEGATIVE);
+ assert.equal(element._computeLabelCategory(
+ {labels: {'Code-Review': {value: -1}}}, 'Verified'),
+ LabelCategory.NOT_APPLICABLE);
+ });
+
+ test('_computeLabelClass', () => {
assert.equal(element._computeLabelClass({labels: {}}),
'cell label u-gray-background');
assert.equal(element._computeLabelClass(
{labels: {}}, 'Verified'), 'cell label u-gray-background');
assert.equal(element._computeLabelClass(
{labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
- 'cell label u-green u-monospace');
+ 'cell label u-green');
assert.equal(element._computeLabelClass(
{labels: {Verified: {rejected: true, value: -1}}}, 'Verified'),
- 'cell label u-monospace u-red');
+ 'cell label u-red');
assert.equal(element._computeLabelClass(
{labels: {'Code-Review': {value: 1}}}, 'Code-Review'),
'cell label u-green u-monospace');
@@ -52,7 +81,9 @@
assert.equal(element._computeLabelClass(
{labels: {'Code-Review': {value: -1}}}, 'Verified'),
'cell label u-gray-background');
+ });
+ test('_computeLabelTitle', () => {
assert.equal(element._computeLabelTitle({labels: {}}, 'Verified'),
'Label not applicable');
assert.equal(element._computeLabelTitle(
@@ -86,7 +117,34 @@
{labels: {'Code-Review': {approved: {name: 'Diffy'},
disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
'Code-Review\nby Diffy');
+ assert.equal(element._computeLabelTitle(
+ {
+ labels: {'Code-Review': {approved: true, value: 1}},
+ unresolved_comment_count: 1,
+ }, 'Code-Review'),
+ '1 unresolved comment');
+ assert.equal(element._computeLabelTitle(
+ {
+ labels: {'Code-Review': {approved: true, value: 1}},
+ unresolved_comment_count: 2,
+ }, 'Code-Review'),
+ '2 unresolved comments');
+ });
+ test('_computeLabelIcon', () => {
+ assert.equal(element._computeLabelIcon({labels: {}}), '');
+ assert.equal(element._computeLabelIcon(
+ {labels: {Verified: {approved: true, value: 1}}}, 'Verified'),
+ 'gr-icons:check');
+ assert.equal(element._computeLabelIcon(
+ {
+ labels: {'Code-Review': {approved: true, value: 1}},
+ unresolved_comment_count: 1,
+ }, 'Code-Review'),
+ 'gr-icons:comment');
+ });
+
+ test('_computeLabelValue', () => {
assert.equal(element._computeLabelValue({labels: {}}), '');
assert.equal(element._computeLabelValue({labels: {}}, 'Verified'), '');
assert.equal(element._computeLabelValue(
@@ -115,7 +173,7 @@
'Size',
];
- flushAsynchronousOperations();
+ flush();
for (const column of element.columnNames) {
const elementClass = '.' + column.toLowerCase();
@@ -140,7 +198,7 @@
'Size',
];
- flushAsynchronousOperations();
+ flush();
for (const column of element.columnNames) {
const elementClass = '.' + column.toLowerCase();
@@ -197,7 +255,7 @@
'Bad',
];
- flushAsynchronousOperations();
+ flush();
const elementClass = '.bad';
assert.isNotOk(element.shadowRoot
.querySelector(elementClass));
@@ -205,7 +263,7 @@
test('assignee only displayed if there is one', () => {
element.change = {};
- flushAsynchronousOperations();
+ flush();
assert.isNotOk(element.shadowRoot
.querySelector('.assignee gr-account-link'));
assert.equal(element.shadowRoot
@@ -216,7 +274,7 @@
status: 'test',
},
};
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('.assignee gr-account-link'));
});
@@ -272,13 +330,13 @@
branch: 'test-branch',
};
element.change = change;
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(GerritNav.getUrlForChange.lastCall.args, [change]);
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-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
deleted file mode 100644
index 953c917..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ /dev/null
@@ -1,305 +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 '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-list/gr-change-list.js';
-import '../gr-repo-header/gr-repo-header.js';
-import '../gr-user-header/gr-user-header.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-change-list-view_html.js';
-import page from 'page/page.mjs';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const LookupQueryPatterns = {
- CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
- CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
-};
-
-const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
-
-const REPO_QUERY_PATTERN =
- /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
-
-const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeListView extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-list-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
-
- /**
- * True when user is logged in.
- */
- _loggedIn: {
- type: Boolean,
- computed: '_computeLoggedIn(account)',
- },
-
- account: {
- type: Object,
- value: null,
- },
-
- /**
- * State persisted across restamps of the element.
- *
- * Need sub-property declaration since it is used in template before
- * assignment.
- *
- * @type {{ selectedChangeIndex: (number|undefined) }}
- *
- */
- viewState: {
- type: Object,
- notify: true,
- value() { return {}; },
- },
-
- preferences: Object,
-
- _changesPerPage: Number,
-
- /**
- * Currently active query.
- */
- _query: {
- type: String,
- value: '',
- },
-
- /**
- * Offset of currently visible query results.
- */
- _offset: Number,
-
- /**
- * Change objects loaded from the server.
- */
- _changes: {
- type: Array,
- observer: '_changesChanged',
- },
-
- /**
- * For showing a "loading..." string during ajax requests.
- */
- _loading: {
- type: Boolean,
- value: true,
- },
-
- /** @type {?string} */
- _userId: {
- type: String,
- value: null,
- },
-
- /** @type {?string} */
- _repo: {
- type: String,
- value: null,
- },
- };
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('next-page',
- () => this._handleNextPage());
- this.addEventListener('previous-page',
- () => this._handlePreviousPage());
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadPreferences();
- }
-
- _paramsChanged(value) {
- if (value.view !== GerritNav.View.SEARCH) { return; }
-
- this._loading = true;
- this._query = value.query;
- this._offset = value.offset || 0;
- if (this.viewState.query != this._query ||
- this.viewState.offset != this._offset) {
- this.set('viewState.selectedChangeIndex', 0);
- this.set('viewState.query', this._query);
- this.set('viewState.offset', this._offset);
- }
-
- // NOTE: This method may be called before attachment. Fire title-change
- // in an async so that attachment to the DOM can take place first.
- this.async(() => this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: this._query},
- composed: true, bubbles: true,
- })));
-
- this._getPreferences()
- .then(prefs => {
- this._changesPerPage = prefs.changes_per_page;
- return this._getChanges();
- })
- .then(changes => {
- changes = changes || [];
- if (this._query && changes.length === 1) {
- for (const query in LookupQueryPatterns) {
- if (LookupQueryPatterns.hasOwnProperty(query) &&
- this._query.match(LookupQueryPatterns[query])) {
- // "Back"/"Forward" buttons work correctly only with
- // opt_redirect options
- GerritNav.navigateToChange(changes[0], null, null, null, true);
- return;
- }
- }
- }
- this._changes = changes;
- this._loading = false;
- });
- }
-
- _loadPreferences() {
- return this.$.restAPI.getLoggedIn().then(loggedIn => {
- if (loggedIn) {
- this._getPreferences().then(preferences => {
- this.preferences = preferences;
- });
- } else {
- this.preferences = {};
- }
- });
- }
-
- _getChanges() {
- return this.$.restAPI.getChanges(this._changesPerPage, this._query,
- this._offset);
- }
-
- _getPreferences() {
- return this.$.restAPI.getPreferences();
- }
-
- _limitFor(query, defaultLimit) {
- const match = query.match(LIMIT_OPERATOR_PATTERN);
- if (!match) {
- return defaultLimit;
- }
- return parseInt(match[1], 10);
- }
-
- _computeNavLink(query, offset, direction, changesPerPage) {
- // Offset could be a string when passed from the router.
- offset = +(offset || 0);
- const limit = this._limitFor(query, changesPerPage);
- const newOffset = Math.max(0, offset + (limit * direction));
- return GerritNav.getUrlForSearchQuery(query, newOffset);
- }
-
- _computePrevArrowClass(offset) {
- return offset === 0 ? 'hide' : '';
- }
-
- _computeNextArrowClass(changes) {
- const more = changes.length && changes[changes.length - 1]._more_changes;
- return more ? '' : 'hide';
- }
-
- _computeNavClass(loading) {
- return loading || !this._changes || !this._changes.length ? 'hide' : '';
- }
-
- _handleNextPage() {
- if (this.$.nextArrow.hidden) { return; }
- page.show(this._computeNavLink(
- this._query, this._offset, 1, this._changesPerPage));
- }
-
- _handlePreviousPage() {
- if (this.$.prevArrow.hidden) { return; }
- page.show(this._computeNavLink(
- this._query, this._offset, -1, this._changesPerPage));
- }
-
- _changesChanged(changes) {
- this._userId = null;
- this._repo = null;
- if (!changes || !changes.length) {
- return;
- }
- if (USER_QUERY_PATTERN.test(this._query)) {
- const owner = changes[0].owner;
- const userId = owner._account_id ? owner._account_id : owner.email;
- if (userId) {
- this._userId = userId;
- return;
- }
- }
- if (REPO_QUERY_PATTERN.test(this._query)) {
- this._repo = changes[0].project;
- }
- }
-
- _computeHeaderClass(id) {
- return id ? '' : 'hide';
- }
-
- _computePage(offset, changesPerPage) {
- return offset / changesPerPage + 1;
- }
-
- _computeLoggedIn(account) {
- return !!(account && Object.keys(account).length > 0);
- }
-
- _handleToggleStar(e) {
- this.$.restAPI.saveChangeStarred(e.detail.change._number,
- e.detail.starred);
- }
-
- _handleToggleReviewed(e) {
- this.$.restAPI.saveChangeReviewed(e.detail.change._number,
- e.detail.reviewed);
- }
-}
-
-customElements.define(GrChangeListView.is, GrChangeListView);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
new file mode 100644
index 0000000..927d32f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.ts
@@ -0,0 +1,307 @@
+/**
+ * @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 '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-list/gr-change-list';
+import '../gr-repo-header/gr-repo-header';
+import '../gr-user-header/gr-user-header';
+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-change-list-view_html';
+import {page} from '../../../utils/page-wrapper-utils';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {AppElementParams} from '../../gr-app-types';
+import {
+ AccountDetailInfo,
+ AccountId,
+ ChangeInfo,
+ EmailAddress,
+ PreferencesInput,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {ChangeListViewState} from '../../../types/types';
+
+const LookupQueryPatterns = {
+ CHANGE_ID: /^\s*i?[0-9a-f]{7,40}\s*$/i,
+ CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
+ COMMIT: /[0-9a-f]{40}/,
+};
+
+const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
+
+const REPO_QUERY_PATTERN = /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+
+const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
+
+export interface GrChangeListView {
+ $: {
+ restAPI: RestApiService & Element;
+ prevArrow: HTMLAnchorElement;
+ nextArrow: HTMLAnchorElement;
+ };
+}
+
+@customElement('gr-change-list-view')
+export class GrChangeListView extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: AppElementParams;
+
+ @property({type: Boolean, computed: '_computeLoggedIn(account)'})
+ _loggedIn?: boolean;
+
+ @property({type: Object})
+ account: AccountDetailInfo | null = null;
+
+ @property({type: Object, notify: true})
+ viewState: ChangeListViewState = {};
+
+ @property({type: Object})
+ preferences?: PreferencesInput;
+
+ @property({type: Number})
+ _changesPerPage?: number;
+
+ @property({type: String})
+ _query = '';
+
+ @property({type: Number})
+ _offset?: number;
+
+ @property({type: Array, observer: '_changesChanged'})
+ _changes?: ChangeInfo[];
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _userId: AccountId | EmailAddress | null = null;
+
+ @property({type: String})
+ _repo: string | null = null;
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('next-page', () => this._handleNextPage());
+ this.addEventListener('previous-page', () => this._handlePreviousPage());
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadPreferences();
+ }
+
+ _paramsChanged(value: AppElementParams) {
+ if (value.view !== GerritView.SEARCH) return;
+
+ this._loading = true;
+ this._query = value.query;
+ const offset = Number(value.offset);
+ this._offset = isNaN(offset) ? 0 : offset;
+ if (
+ this.viewState.query !== this._query ||
+ this.viewState.offset !== this._offset
+ ) {
+ this.set('viewState.selectedChangeIndex', 0);
+ this.set('viewState.query', this._query);
+ this.set('viewState.offset', this._offset);
+ }
+
+ // NOTE: This method may be called before attachment. Fire title-change
+ // in an async so that attachment to the DOM can take place first.
+ this.async(() =>
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: this._query},
+ composed: true,
+ bubbles: true,
+ })
+ )
+ );
+
+ this.$.restAPI
+ .getPreferences()
+ .then(prefs => {
+ if (!prefs) {
+ throw new Error('getPreferences returned undefined');
+ }
+ this._changesPerPage = prefs.changes_per_page;
+ return this._getChanges();
+ })
+ .then(changes => {
+ changes = changes || [];
+ if (this._query && changes.length === 1) {
+ let query: keyof typeof LookupQueryPatterns;
+ for (query in LookupQueryPatterns) {
+ if (
+ hasOwnProperty(LookupQueryPatterns, query) &&
+ this._query.match(LookupQueryPatterns[query])
+ ) {
+ // "Back"/"Forward" buttons work correctly only with
+ // opt_redirect options
+ GerritNav.navigateToChange(
+ changes[0],
+ undefined,
+ undefined,
+ undefined,
+ true
+ );
+ return;
+ }
+ }
+ }
+ this._changes = changes;
+ this._loading = false;
+ });
+ }
+
+ _loadPreferences() {
+ return this.$.restAPI.getLoggedIn().then(loggedIn => {
+ if (loggedIn) {
+ this.$.restAPI.getPreferences().then(preferences => {
+ this.preferences = preferences;
+ });
+ } else {
+ this.preferences = {};
+ }
+ });
+ }
+
+ _getChanges() {
+ return this.$.restAPI.getChanges(
+ this._changesPerPage,
+ this._query,
+ this._offset
+ );
+ }
+
+ _limitFor(query: string, defaultLimit: number) {
+ const match = query.match(LIMIT_OPERATOR_PATTERN);
+ if (!match) {
+ return defaultLimit;
+ }
+ return Number(match[1]);
+ }
+
+ _computeNavLink(
+ query: string,
+ offset: number | undefined,
+ direction: number,
+ changesPerPage: number
+ ) {
+ offset = offset ?? 0;
+ const limit = this._limitFor(query, changesPerPage);
+ const newOffset = Math.max(0, offset + limit * direction);
+ return GerritNav.getUrlForSearchQuery(query, newOffset);
+ }
+
+ _computePrevArrowClass(offset?: number) {
+ return offset === 0 ? 'hide' : '';
+ }
+
+ _computeNextArrowClass(changes?: ChangeInfo[]) {
+ const more = changes?.length && changes[changes.length - 1]._more_changes;
+ return more ? '' : 'hide';
+ }
+
+ _computeNavClass(loading?: boolean) {
+ return loading || !this._changes || !this._changes.length ? 'hide' : '';
+ }
+
+ _handleNextPage() {
+ if (this.$.nextArrow.hidden || !this._changesPerPage) return;
+ page.show(
+ this._computeNavLink(this._query, this._offset, 1, this._changesPerPage)
+ );
+ }
+
+ _handlePreviousPage() {
+ if (this.$.prevArrow.hidden || !this._changesPerPage) return;
+ page.show(
+ this._computeNavLink(this._query, this._offset, -1, this._changesPerPage)
+ );
+ }
+
+ _changesChanged(changes?: ChangeInfo[]) {
+ this._userId = null;
+ this._repo = null;
+ if (!changes || !changes.length) {
+ return;
+ }
+ if (USER_QUERY_PATTERN.test(this._query)) {
+ const owner = changes[0].owner;
+ const userId = owner._account_id ? owner._account_id : owner.email;
+ if (userId) {
+ this._userId = userId;
+ return;
+ }
+ }
+ if (REPO_QUERY_PATTERN.test(this._query)) {
+ this._repo = changes[0].project;
+ }
+ }
+
+ _computeHeaderClass(id?: string) {
+ return id ? '' : 'hide';
+ }
+
+ _computePage(offset?: number, changesPerPage?: number) {
+ if (offset === undefined || changesPerPage === undefined) return;
+ return offset / changesPerPage + 1;
+ }
+
+ _computeLoggedIn(account?: AccountDetailInfo) {
+ return !!(account && Object.keys(account).length > 0);
+ }
+
+ _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+ this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+ }
+
+ _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
+ this.$.restAPI.saveChangeReviewed(
+ e.detail.change._number,
+ e.detail.reviewed
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-list-view': GrChangeListView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
index db622d5..af3acd8 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.js
@@ -17,8 +17,9 @@
import '../../../test/common-test-setup-karma.js';
import './gr-change-list-view.js';
-import page from 'page/page.mjs';
+import {page} from '../../../utils/page-wrapper-utils.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import 'lodash/lodash.js';
const basicFixture = fixtureFromElement('gr-change-list-view');
@@ -108,6 +109,7 @@
test('_handleNextPage', () => {
const showStub = sinon.stub(page, 'show');
+ element._changesPerPage = 10;
element.$.nextArrow.hidden = true;
element._handleNextPage();
assert.isFalse(showStub.called);
@@ -118,6 +120,7 @@
test('_handlePreviousPage', () => {
const showStub = sinon.stub(page, 'show');
+ element._changesPerPage = 10;
element.$.prevArrow.hidden = true;
element._handlePreviousPage();
assert.isFalse(showStub.called);
@@ -236,7 +239,7 @@
const stub = sinon.stub(GerritNav, 'navigateToChange');
element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
- flushAsynchronousOperations();
+ flush();
assert.isFalse(stub.called);
});
@@ -247,7 +250,7 @@
const stub = sinon.stub(GerritNav, 'navigateToChange');
element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
- flushAsynchronousOperations();
+ flush();
assert.isFalse(stub.called);
});
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
deleted file mode 100644
index 648d70e..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ /dev/null
@@ -1,439 +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-cursor-manager/gr-cursor-manager.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-list-item/gr-change-list-item.js';
-import '../../../styles/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.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_html.js';
-import {appContext} from '../../../services/app-context.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const NUMBER_FIXED_COLUMNS = 3;
-const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
-const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
-const MAX_SHORTCUT_CHARS = 5;
-
-/**
- * @extends PolymerElement
- */
-class GrChangeList extends ChangeTableMixin(
- KeyboardShortcutMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement)))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-list'; }
- /**
- * Fired when next page key shortcut was pressed.
- *
- * @event next-page
- */
-
- /**
- * Fired when previous page key shortcut was pressed.
- *
- * @event previous-page
- */
-
- static get properties() {
- return {
- /**
- * The logged-in user's account, or an empty object if no user is logged
- * in.
- */
- account: {
- type: Object,
- value: null,
- },
- /**
- * An array of ChangeInfo objects to render.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
- */
- changes: {
- type: Array,
- observer: '_changesChanged',
- },
- /**
- * ChangeInfo objects grouped into arrays. The sections and changes
- * properties should not be used together.
- *
- * @type {!Array<{
- * name: string,
- * query: string,
- * results: !Array<!Object>
- * }>}
- */
- sections: {
- type: Array,
- value() { return []; },
- },
- labelNames: {
- type: Array,
- computed: '_computeLabelNames(sections)',
- },
- _dynamicHeaderEndpoints: {
- type: Array,
- },
- selectedIndex: {
- type: Number,
- notify: true,
- },
- showNumber: Boolean, // No default value to prevent flickering.
- showStar: {
- type: Boolean,
- value: false,
- },
- showReviewedState: {
- type: Boolean,
- value: false,
- },
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- changeTableColumns: Array,
- visibleChangeTableColumns: Array,
- preferences: Object,
- _config: Object,
- };
- }
-
- static get observers() {
- return [
- '_sectionsChanged(sections.*)',
- '_computePreferences(account, preferences, _config)',
- ];
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
- [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
- [Shortcut.NEXT_PAGE]: '_nextPage',
- [Shortcut.PREV_PAGE]: '_prevPage',
- [Shortcut.OPEN_CHANGE]: '_openChange',
- [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
- [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
- [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
- };
- }
-
- constructor() {
- super();
- this.flagsService = appContext.flagsService;
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('keydown',
- e => this._scopedKeydownHandler(e));
- }
-
- /** @override */
- ready() {
- super.ready();
- this.$.restAPI.getConfig().then(config => {
- this._config = config;
- });
- }
-
- /** @override */
- attached() {
- super.attached();
- pluginLoader.awaitPluginsLoaded().then(() => {
- this._dynamicHeaderEndpoints = pluginEndpoints.getDynamicEndpoints(
- 'change-list-header');
- });
- }
-
- /**
- * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
- * events must be scoped to a component level (e.g. `enter`) in order to not
- * override native browser functionality.
- *
- * Context: Issue 7294
- */
- _scopedKeydownHandler(e) {
- if (e.keyCode === 13) {
- // Enter.
- this._openChange(e);
- }
- }
-
- _lowerCase(column) {
- return column.toLowerCase();
- }
-
- _computePreferences(account, preferences, config) {
- // Polymer 2: check for undefined
- if ([account, preferences, config].includes(undefined)) {
- return;
- }
-
- this.changeTableColumns = this.columnNames;
- this.showNumber = false;
- this.visibleChangeTableColumns = this.getEnabledColumns(this.columnNames,
- config, this.flagsService.enabledExperiments);
-
- if (account) {
- this.showNumber = !!(preferences &&
- preferences.legacycid_in_change_table);
- if (preferences.change_table &&
- preferences.change_table.length > 0) {
- const prefColumns = this.getVisibleColumns(preferences.change_table);
- this.visibleChangeTableColumns = this.getEnabledColumns(prefColumns,
- config, this.flagsService.enabledExperiments);
- }
- }
- }
-
- _computeColspan(changeTableColumns, labelNames) {
- if (!changeTableColumns || !labelNames) return;
- return changeTableColumns.length + labelNames.length +
- NUMBER_FIXED_COLUMNS;
- }
-
- _computeLabelNames(sections) {
- if (!sections) { return []; }
- let labels = [];
- const nonExistingLabel = function(item) {
- return !labels.includes(item);
- };
- for (const section of sections) {
- if (!section.results) { continue; }
- for (const change of section.results) {
- if (!change.labels) { continue; }
- const currentLabels = Object.keys(change.labels);
- labels = labels.concat(currentLabels.filter(nonExistingLabel));
- }
- }
- return labels.sort();
- }
-
- _computeLabelShortcut(labelName) {
- if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
- labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
- }
- return labelName.split('-')
- .reduce((a, i) => {
- if (!i) { return a; }
- return a + i[0].toUpperCase();
- }, '')
- .slice(0, MAX_SHORTCUT_CHARS);
- }
-
- _changesChanged(changes) {
- this.sections = changes ? [{results: changes}] : [];
- }
-
- _processQuery(query) {
- let tokens = query.split(' ');
- const invalidTokens = ['limit:', 'age:', '-age:'];
- tokens = tokens.filter(token => !invalidTokens
- .some(invalidToken => token.startsWith(invalidToken)));
- return tokens.join(' ');
- }
-
- _sectionHref(query) {
- return GerritNav.getUrlForSearchQuery(this._processQuery(query));
- }
-
- /**
- * Maps an index local to a particular section to the absolute index
- * across all the changes on the page.
- *
- * @param {number} sectionIndex index of section
- * @param {number} localIndex index of row within section
- * @return {number} absolute index of row in the aggregate dashboard
- */
- _computeItemAbsoluteIndex(sectionIndex, localIndex) {
- let idx = 0;
- for (let i = 0; i < sectionIndex; i++) {
- idx += this.sections[i].results.length;
- }
- return idx + localIndex;
- }
-
- _computeItemSelected(sectionIndex, index, selectedIndex) {
- const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
- return idx == selectedIndex;
- }
-
- _computeTabIndex(sectionIndex, index, selectedIndex) {
- return this._computeItemSelected(sectionIndex, index, selectedIndex)
- ? 0 : undefined;
- }
-
- _computeItemNeedsReview(account, change, showReviewedState, config) {
- const isAttentionSetEnabled =
- !!config && !!config.change && config.change.enable_attention_set;
- return !isAttentionSetEnabled && showReviewedState && !change.reviewed &&
- !change.work_in_progress &&
- changeIsOpen(change) &&
- (!account || account._account_id != change.owner._account_id);
- }
-
- _computeItemHighlight(account, change) {
- // Do not show the assignee highlight if the change is not open.
- if (!change ||!change.assignee ||
- !account ||
- CLOSED_STATUS.indexOf(change.status) !== -1) {
- return false;
- }
- return account._account_id === change.assignee._account_id;
- }
-
- _nextChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.cursor.next();
- }
-
- _prevChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.cursor.previous();
- }
-
- _openChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- GerritNav.navigateToChange(this._changeForIndex(this.selectedIndex));
- }
-
- _nextPage(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
- return;
- }
-
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('next-page', {
- composed: true, bubbles: true,
- }));
- }
-
- _prevPage(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
- return;
- }
-
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('previous-page', {
- composed: true, bubbles: true,
- }));
- }
-
- _toggleChangeReviewed(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._toggleReviewedForIndex(this.selectedIndex);
- }
-
- _toggleReviewedForIndex(index) {
- const changeEls = this._getListItems();
- if (index >= changeEls.length || !changeEls[index]) {
- return;
- }
-
- const changeEl = changeEls[index];
- changeEl.toggleReviewed();
- }
-
- _refreshChangeList(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this._reloadWindow();
- }
-
- _reloadWindow() {
- window.location.reload();
- }
-
- _toggleChangeStar(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._toggleStarForIndex(this.selectedIndex);
- }
-
- _toggleStarForIndex(index) {
- const changeEls = this._getListItems();
- if (index >= changeEls.length || !changeEls[index]) {
- return;
- }
-
- const changeEl = changeEls[index];
- changeEl.shadowRoot
- .querySelector('gr-change-star').toggleStar();
- }
-
- _changeForIndex(index) {
- const changeEls = this._getListItems();
- if (index < changeEls.length && changeEls[index]) {
- return changeEls[index].change;
- }
- return null;
- }
-
- _getListItems() {
- return Array.from(
- dom(this.root).querySelectorAll('gr-change-list-item'));
- }
-
- _sectionsChanged() {
- // Flush DOM operations so that the list item elements will be loaded.
- afterNextRender(this, () => {
- this.$.cursor.stops = this._getListItems();
- this.$.cursor.moveToStart();
- });
- }
-
- _isOutgoing(section) {
- return !!section.isOutgoing;
- }
-
- _isEmpty(section) {
- return !section.results.length;
- }
-}
-
-customElements.define(GrChangeList.is, GrChangeList);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
new file mode 100644
index 0000000..3acdaf9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
@@ -0,0 +1,542 @@
+/**
+ * @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-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-list-item/gr-change-list-item';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+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_html';
+import {appContext} from '../../../services/app-context';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {
+ KeyboardShortcutMixin,
+ Shortcut,
+ Modifier,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+ GerritNav,
+ DashboardSection,
+ YOUR_TURN,
+ CLOSED,
+} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {changeIsOpen, isOwner} from '../../../utils/change-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {
+ AccountInfo,
+ ChangeInfo,
+ ServerInfo,
+ PreferencesInput,
+} from '../../../types/common';
+import {
+ hasAttention,
+ isAttentionSetEnabled,
+} from '../../../utils/attention-set-util';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+const NUMBER_FIXED_COLUMNS = 3;
+const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
+const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
+const MAX_SHORTCUT_CHARS = 5;
+
+export interface ChangeListSection {
+ name?: string;
+ query?: string;
+ results: ChangeInfo[];
+}
+export interface GrChangeList {
+ $: {
+ restAPI: RestApiService & Element;
+ cursor: GrCursorManager;
+ };
+}
+@customElement('gr-change-list')
+export class GrChangeList extends ChangeTableMixin(
+ KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+ )
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when next page key shortcut was pressed.
+ *
+ * @event next-page
+ */
+
+ /**
+ * Fired when previous page key shortcut was pressed.
+ *
+ * @event previous-page
+ */
+
+ /**
+ * The logged-in user's account, or an empty object if no user is logged
+ * in.
+ */
+ @property({type: Object})
+ account: AccountInfo | undefined = undefined;
+
+ @property({type: Array, observer: '_changesChanged'})
+ changes?: ChangeInfo[];
+
+ /**
+ * ChangeInfo objects grouped into arrays. The sections and changes
+ * properties should not be used together.
+ */
+ @property({type: Array})
+ sections: ChangeListSection[] = [];
+
+ @property({type: Array, computed: '_computeLabelNames(sections)'})
+ labelNames?: string[];
+
+ @property({type: Array})
+ _dynamicHeaderEndpoints?: string[];
+
+ @property({type: Number, notify: true})
+ selectedIndex?: number;
+
+ @property({type: Boolean})
+ showNumber?: boolean; // No default value to prevent flickering.
+
+ @property({type: Boolean})
+ showStar = false;
+
+ @property({type: Boolean})
+ showReviewedState = false;
+
+ @property({type: Object})
+ keyEventTarget: HTMLElement = document.body;
+
+ @property({type: Array})
+ changeTableColumns?: string[];
+
+ @property({type: Array})
+ visibleChangeTableColumns?: string[];
+
+ @property({type: Object})
+ preferences?: PreferencesInput;
+
+ @property({type: Object})
+ _config?: ServerInfo;
+
+ flagsService = appContext.flagsService;
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
+ [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
+ [Shortcut.NEXT_PAGE]: '_nextPage',
+ [Shortcut.PREV_PAGE]: '_prevPage',
+ [Shortcut.OPEN_CHANGE]: '_openChange',
+ [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
+ [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
+ [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
+ };
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this.$.restAPI.getConfig().then(config => {
+ this._config = config;
+ });
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-list-header'
+ );
+ });
+ }
+
+ /**
+ * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+ * events must be scoped to a component level (e.g. `enter`) in order to not
+ * override native browser functionality.
+ *
+ * Context: Issue 7294
+ */
+ _scopedKeydownHandler(e: KeyboardEvent) {
+ if (e.keyCode === 13) {
+ // Enter.
+ this._openChange((e as unknown) as CustomKeyboardEvent);
+ }
+ }
+
+ _lowerCase(column: string) {
+ return column.toLowerCase();
+ }
+
+ @observe('account', 'preferences', '_config')
+ _computePreferences(
+ account?: AccountInfo,
+ preferences?: PreferencesInput,
+ config?: ServerInfo
+ ) {
+ if (!config) {
+ return;
+ }
+
+ this.changeTableColumns = this.columnNames;
+ this.showNumber = false;
+ this.visibleChangeTableColumns = this.getEnabledColumns(
+ this.columnNames,
+ config,
+ this.flagsService.enabledExperiments
+ );
+
+ if (account && preferences) {
+ this.showNumber = !!(
+ preferences && preferences.legacycid_in_change_table
+ );
+ if (preferences.change_table && preferences.change_table.length > 0) {
+ const prefColumns = this.getVisibleColumns(preferences.change_table);
+ this.visibleChangeTableColumns = this.getEnabledColumns(
+ prefColumns,
+ config,
+ this.flagsService.enabledExperiments
+ );
+ }
+ }
+ }
+
+ /**
+ * This methods allows us to customize the columns per section.
+ *
+ * @param visibleColumns are the columns according to configs and user prefs
+ */
+ _computeColumns(
+ section?: ChangeListSection,
+ visibleColumns?: string[]
+ ): string[] {
+ if (!section || !visibleColumns) return [];
+ const cols = [...visibleColumns];
+ const updatedIndex = cols.indexOf('Updated');
+ if (section.name === YOUR_TURN.name && updatedIndex !== -1) {
+ cols[updatedIndex] = 'Waiting';
+ }
+ if (section.name === CLOSED.name && updatedIndex !== -1) {
+ cols[updatedIndex] = 'Submitted';
+ }
+ return cols;
+ }
+
+ _computeColspan(
+ section?: ChangeListSection,
+ visibleColumns?: string[],
+ labelNames?: string[]
+ ) {
+ const cols = this._computeColumns(section, visibleColumns);
+ if (!cols || !labelNames) return 1;
+ return cols.length + labelNames.length + NUMBER_FIXED_COLUMNS;
+ }
+
+ _computeLabelNames(sections: ChangeListSection[]) {
+ if (!sections) {
+ return [];
+ }
+ let labels: string[] = [];
+ const nonExistingLabel = function (item: string) {
+ return !labels.includes(item);
+ };
+ for (const section of sections) {
+ if (!section.results) {
+ continue;
+ }
+ for (const change of section.results) {
+ if (!change.labels) {
+ continue;
+ }
+ const currentLabels = Object.keys(change.labels);
+ labels = labels.concat(currentLabels.filter(nonExistingLabel));
+ }
+ }
+ return labels.sort();
+ }
+
+ _computeLabelShortcut(labelName: string) {
+ if (labelName.startsWith(LABEL_PREFIX_INVALID_PROLOG)) {
+ labelName = labelName.slice(LABEL_PREFIX_INVALID_PROLOG.length);
+ }
+ return labelName
+ .split('-')
+ .reduce((a, i) => {
+ if (!i) {
+ return a;
+ }
+ return a + i[0].toUpperCase();
+ }, '')
+ .slice(0, MAX_SHORTCUT_CHARS);
+ }
+
+ _changesChanged(changes: ChangeInfo[]) {
+ this.sections = changes ? [{results: changes}] : [];
+ }
+
+ _processQuery(query: string) {
+ let tokens = query.split(' ');
+ const invalidTokens = ['limit:', 'age:', '-age:'];
+ tokens = tokens.filter(
+ token =>
+ !invalidTokens.some(invalidToken => token.startsWith(invalidToken))
+ );
+ return tokens.join(' ');
+ }
+
+ _sectionHref(query: string) {
+ return GerritNav.getUrlForSearchQuery(this._processQuery(query));
+ }
+
+ /**
+ * Maps an index local to a particular section to the absolute index
+ * across all the changes on the page.
+ *
+ * @param sectionIndex index of section
+ * @param localIndex index of row within section
+ * @return absolute index of row in the aggregate dashboard
+ */
+ _computeItemAbsoluteIndex(sectionIndex: number, localIndex: number) {
+ let idx = 0;
+ for (let i = 0; i < sectionIndex; i++) {
+ idx += this.sections[i].results.length;
+ }
+ return idx + localIndex;
+ }
+
+ _computeItemSelected(
+ sectionIndex: number,
+ index: number,
+ selectedIndex: number
+ ) {
+ const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
+ return idx === selectedIndex;
+ }
+
+ _computeTabIndex(sectionIndex: number, index: number, selectedIndex: number) {
+ return this._computeItemSelected(sectionIndex, index, selectedIndex)
+ ? 0
+ : undefined;
+ }
+
+ _computeItemNeedsReview(
+ account: AccountInfo | undefined,
+ change: ChangeInfo,
+ showReviewedState: boolean,
+ config?: ServerInfo
+ ) {
+ return (
+ !isAttentionSetEnabled(config) &&
+ showReviewedState &&
+ !change.reviewed &&
+ !change.work_in_progress &&
+ changeIsOpen(change) &&
+ (!account || account._account_id !== change.owner._account_id)
+ );
+ }
+
+ _computeItemHighlight(
+ account?: AccountInfo,
+ change?: ChangeInfo,
+ config?: ServerInfo,
+ sectionName?: string
+ ) {
+ if (!change || !account) return false;
+ if (CLOSED_STATUS.indexOf(change.status) !== -1) return false;
+ return isAttentionSetEnabled(config)
+ ? hasAttention(config, account, change) &&
+ !isOwner(change, account) &&
+ sectionName === YOUR_TURN.name
+ : account._account_id === change.assignee?._account_id;
+ }
+
+ _nextChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.cursor.next();
+ }
+
+ _prevChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.cursor.previous();
+ }
+
+ _openChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ const change = this._changeForIndex(this.selectedIndex);
+ if (change) GerritNav.navigateToChange(change);
+ }
+
+ _nextPage(e: CustomKeyboardEvent) {
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ (this.modifierPressed(e) &&
+ !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('next-page', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _prevPage(e: CustomKeyboardEvent) {
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ (this.modifierPressed(e) &&
+ !this.isModifierPressed(e, Modifier.SHIFT_KEY))
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('previous-page', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _toggleChangeReviewed(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._toggleReviewedForIndex(this.selectedIndex);
+ }
+
+ _toggleReviewedForIndex(index?: number) {
+ const changeEls = this._getListItems();
+ if (index === undefined || index >= changeEls.length || !changeEls[index]) {
+ return;
+ }
+
+ const changeEl = changeEls[index];
+ changeEl.toggleReviewed();
+ }
+
+ _refreshChangeList(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._reloadWindow();
+ }
+
+ _reloadWindow() {
+ window.location.reload();
+ }
+
+ _toggleChangeStar(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._toggleStarForIndex(this.selectedIndex);
+ }
+
+ _toggleStarForIndex(index?: number) {
+ const changeEls = this._getListItems();
+ if (index === undefined || index >= changeEls.length || !changeEls[index]) {
+ return;
+ }
+
+ const changeEl = changeEls[index];
+ const grChangeStar = changeEl?.shadowRoot?.querySelector('gr-change-star');
+ if (grChangeStar) grChangeStar.toggleStar();
+ }
+
+ _changeForIndex(index?: number) {
+ const changeEls = this._getListItems();
+ if (index !== undefined && index < changeEls.length && changeEls[index]) {
+ return changeEls[index].change;
+ }
+ return null;
+ }
+
+ _getListItems() {
+ const items = this.root?.querySelectorAll('gr-change-list-item');
+ return !items ? [] : Array.from(items);
+ }
+
+ @observe('sections.*')
+ _sectionsChanged() {
+ // Flush DOM operations so that the list item elements will be loaded.
+ afterNextRender(this, () => {
+ this.$.cursor.stops = this._getListItems();
+ this.$.cursor.moveToStart();
+ });
+ }
+
+ _getSpecialEmptySlot(section: DashboardSection) {
+ if (section.isOutgoing) return 'empty-outgoing';
+ if (section.name === YOUR_TURN.name) return 'empty-your-turn';
+ return '';
+ }
+
+ _isEmpty(section: DashboardSection) {
+ return !section.results?.length;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-list': GrChangeList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
index 404456c..06957d9 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_html.ts
@@ -61,17 +61,19 @@
></td>
<td
class="cell"
- colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+ colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
>
- <a
- href$="[[_sectionHref(changeSection.query)]]"
- class="section-title"
- >
- <span class="section-name">[[changeSection.name]]</span>
- <span class="section-count-label"
- >[[changeSection.countLabel]]</span
+ <h2>
+ <a
+ href$="[[_sectionHref(changeSection.query)]]"
+ class="section-title"
>
- </a>
+ <span class="section-name">[[changeSection.name]]</span>
+ <span class="section-count-label"
+ >[[changeSection.countLabel]]</span
+ >
+ </a>
+ </h2>
</td>
</tr>
</tbody>
@@ -83,12 +85,18 @@
<td aria-hidden="true" class="star" hidden></td>
<td
class="cell"
- colspan$="[[_computeColspan(changeTableColumns, labelNames)]]"
+ colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
>
- <template is="dom-if" if="[[_isOutgoing(changeSection)]]">
- <slot name="empty-outgoing"></slot>
+ <template
+ is="dom-if"
+ if="[[_getSpecialEmptySlot(changeSection)]]"
+ >
+ <slot name="[[_getSpecialEmptySlot(changeSection)]]"></slot>
</template>
- <template is="dom-if" if="[[!_isOutgoing(changeSection)]]">
+ <template
+ is="dom-if"
+ if="[[!_getSpecialEmptySlot(changeSection)]]"
+ >
No changes
</template>
</td>
@@ -104,11 +112,12 @@
hidden=""
></td>
<td class="number" hidden$="[[!showNumber]]" hidden="">#</td>
- <template is="dom-repeat" items="[[changeTableColumns]]" as="item">
- <td
- class$="[[_lowerCase(item)]]"
- hidden$="[[isColumnHidden(item, visibleChangeTableColumns)]]"
- >
+ <template
+ is="dom-repeat"
+ items="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
+ as="item"
+ >
+ <td class$="[[_lowerCase(item)]]">
[[item]]
</td>
</template>
@@ -133,12 +142,12 @@
<gr-change-list-item
account="[[account]]"
selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
- highlight$="[[_computeItemHighlight(account, change)]]"
+ highlight$="[[_computeItemHighlight(account, change, _config, changeSection.name)]]"
needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState, _config)]]"
change="[[change]]"
config="[[_config]]"
section-name="[[changeSection.name]]"
- visible-change-table-columns="[[visibleChangeTableColumns]]"
+ visible-change-table-columns="[[_computeColumns(changeSection, visibleChangeTableColumns)]]"
show-number="[[showNumber]]"
show-star="[[showStar]]"
tabindex$="[[_computeTabIndex(sectionIndex, index, selectedIndex)]]"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 9c8b04d..494d05a 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -17,11 +17,11 @@
import '../../../test/common-test-setup-karma.js';
import './gr-change-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {YOUR_TURN} from '../../core/gr-navigation/gr-navigation.js';
const basicFixture = fixtureFromElement('gr-change-list');
@@ -51,8 +51,8 @@
suite('test show change number not logged in', () => {
setup(() => {
element = basicFixture.instantiate();
- element.account = null;
- element.preferences = null;
+ element.account = undefined;
+ element.preferences = undefined;
element._config = {};
});
@@ -71,7 +71,7 @@
};
element.account = {_account_id: 1001};
element._config = {};
- flushAsynchronousOperations();
+ flush();
});
test('show number enabled', () => {
@@ -89,7 +89,7 @@
};
element.account = {_account_id: 1001};
element._config = {};
- flushAsynchronousOperations();
+ flush();
});
test('show number disabled', () => {
@@ -139,14 +139,14 @@
element.sections = [
{results: [{}]},
];
- flushAsynchronousOperations();
- const tdItemCount = dom(element.root).querySelectorAll(
+ flush();
+ const tdItemCount = element.root.querySelectorAll(
'td').length;
const changeTableColumns = [];
const labelNames = [];
assert.equal(tdItemCount, element._computeColspan(
- changeTableColumns, labelNames));
+ {}, changeTableColumns, labelNames));
});
test('keyboard shortcuts', done => {
@@ -161,9 +161,9 @@
{_number: 1},
{_number: 2},
];
- flushAsynchronousOperations();
+ flush();
afterNextRender(element, () => {
- const elementItems = dom(element.root).querySelectorAll(
+ const elementItems = element.root.querySelectorAll(
'gr-change-list-item');
assert.equal(elementItems.length, 3);
@@ -230,8 +230,8 @@
owner: {_account_id: 0},
},
];
- flushAsynchronousOperations();
- let elementItems = dom(element.root).querySelectorAll(
+ flush();
+ let elementItems = element.root.querySelectorAll(
'gr-change-list-item');
assert.equal(elementItems.length, 5);
for (let i = 0; i < elementItems.length; i++) {
@@ -239,7 +239,7 @@
}
element.showReviewedState = true;
- elementItems = dom(element.root).querySelectorAll(
+ elementItems = element.root.querySelectorAll(
'gr-change-list-item');
assert.equal(elementItems.length, 5);
assert.isFalse(elementItems[0].hasAttribute('needs-review'));
@@ -249,7 +249,7 @@
assert.isFalse(elementItems[4].hasAttribute('needs-review'));
element.account = {_account_id: 42};
- elementItems = dom(element.root).querySelectorAll(
+ elementItems = element.root.querySelectorAll(
'gr-change-list-item');
assert.equal(elementItems.length, 5);
assert.isFalse(elementItems[0].hasAttribute('needs-review'));
@@ -261,7 +261,7 @@
element._config = {
change: {enable_attention_set: true},
};
- elementItems = dom(element.root).querySelectorAll(
+ elementItems = element.root.querySelectorAll(
'gr-change-list-item');
for (let i = 0; i < elementItems.length; i++) {
assert.isFalse(elementItems[i].hasAttribute('needs-review'));
@@ -270,52 +270,52 @@
test('no changes', () => {
element.changes = [];
- flushAsynchronousOperations();
- const listItems = dom(element.root).querySelectorAll(
+ flush();
+ const listItems = element.root.querySelectorAll(
'gr-change-list-item');
assert.equal(listItems.length, 0);
const noChangesMsg =
- dom(element.root).querySelector('.noChanges');
+ element.root.querySelector('.noChanges');
assert.ok(noChangesMsg);
});
test('empty sections', () => {
element.sections = [{results: []}, {results: []}];
- flushAsynchronousOperations();
- const listItems = dom(element.root).querySelectorAll(
+ flush();
+ const listItems = element.root.querySelectorAll(
'gr-change-list-item');
assert.equal(listItems.length, 0);
- const noChangesMsg = dom(element.root).querySelectorAll(
+ const noChangesMsg = element.root.querySelectorAll(
'.noChanges');
assert.equal(noChangesMsg.length, 2);
});
- suite('empty outgoing', () => {
+ suite('empty section', () => {
test('not shown on empty non-outgoing sections', () => {
const section = {results: []};
assert.isTrue(element._isEmpty(section));
- assert.isFalse(element._isOutgoing(section));
+ assert.equal(element._getSpecialEmptySlot(section), '');
});
test('shown on empty outgoing sections', () => {
const section = {results: [], isOutgoing: true};
assert.isTrue(element._isEmpty(section));
- assert.isTrue(element._isOutgoing(section));
+ assert.equal(element._getSpecialEmptySlot(section), 'empty-outgoing');
+ });
+
+ test('shown on empty outgoing sections', () => {
+ const section = {results: [], name: YOUR_TURN.name};
+ assert.isTrue(element._isEmpty(section));
+ assert.equal(element._getSpecialEmptySlot(section), 'empty-your-turn');
});
test('not shown on non-empty outgoing sections', () => {
const section = {isOutgoing: true, results: [
{_number: 0, labels: {Verified: {approved: {}}}}]};
assert.isFalse(element._isEmpty(section));
- assert.isTrue(element._isOutgoing(section));
});
});
- test('_isOutgoing', () => {
- assert.isTrue(element._isOutgoing({results: [], isOutgoing: true}));
- assert.isFalse(element._isOutgoing({results: []}));
- });
-
suite('empty column preference', () => {
let element;
@@ -331,7 +331,7 @@
change_table: [],
};
element._config = {};
- flushAsynchronousOperations();
+ flush();
});
test('show number enabled', () => {
@@ -373,7 +373,7 @@
],
};
element._config = {};
- flushAsynchronousOperations();
+ flush();
});
test('all columns visible', () => {
@@ -410,18 +410,16 @@
],
};
element._config = {};
- flushAsynchronousOperations();
+ flush();
});
test('all columns except repo visible', () => {
for (const column of element.changeTableColumns) {
const elementClass = '.' + column.toLowerCase();
if (column === 'Repo') {
- assert.isTrue(element.shadowRoot
- .querySelector(elementClass).hidden);
+ assert.isNotOk(element.shadowRoot.querySelector(elementClass));
} else {
- assert.isFalse(element.shadowRoot
- .querySelector(elementClass).hidden);
+ assert.isOk(element.shadowRoot.querySelector(elementClass));
}
}
});
@@ -442,7 +440,7 @@
'Bad',
],
};
- flushAsynchronousOperations();
+ flush();
});
test('bad column does not exist', () => {
@@ -535,9 +533,9 @@
],
},
];
- flushAsynchronousOperations();
+ flush();
afterNextRender(element, () => {
- const elementItems = dom(element.root).querySelectorAll(
+ const elementItems = element.root.querySelectorAll(
'gr-change-list-item');
assert.equal(elementItems.length, 9);
@@ -591,7 +589,7 @@
},
];
element.account = {_account_id: 42};
- flushAsynchronousOperations();
+ flush();
let items = element._getListItems();
assert.equal(items.length, 2);
assert.isFalse(items[0].hasAttribute('highlight'));
@@ -602,7 +600,7 @@
element.set(['changes', 0, 'assignee'], {_account_id: 12});
element.set(['changes', 1, 'assignee'], {_account_id: 12});
element.account = {_account_id: 12};
- flushAsynchronousOperations();
+ flush();
items = element._getListItems();
assert.isTrue(items[0].hasAttribute('highlight'));
assert.isFalse(items[1].hasAttribute('highlight'));
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
deleted file mode 100644
index d9fd378..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.js
+++ /dev/null
@@ -1,47 +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 '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-icons/gr-icons.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-create-change-help_html.js';
-
-/** @extends PolymerElement */
-class GrCreateChangeHelp extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-create-change-help'; }
-
- /**
- * Fired when the "Create change" button is tapped.
- *
- * @event create-tap
- */
-
- _handleCreateTap(e) {
- e.preventDefault();
- this.dispatchEvent(
- new CustomEvent('create-tap', {bubbles: true, composed: true}));
- }
-}
-
-customElements.define(GrCreateChangeHelp.is, GrCreateChangeHelp);
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
new file mode 100644
index 0000000..35f3aeb
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help.ts
@@ -0,0 +1,50 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+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 {customElement} from '@polymer/decorators';
+import {htmlTemplate} from './gr-create-change-help_html';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-create-change-help': GrCreateChangeHelp;
+ }
+}
+
+@customElement('gr-create-change-help')
+class GrCreateChangeHelp extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the "Create change" button is tapped.
+ */
+ _handleCreateTap(e: Event) {
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('create-tap', {bubbles: true, composed: true})
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
deleted file mode 100644
index cc53e49..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.js
+++ /dev/null
@@ -1,73 +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 '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-shell-command/gr-shell-command.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-create-commands-dialog_html.js';
-
-const Commands = {
- CREATE: 'git commit',
- AMEND: 'git commit --amend',
- PUSH_PREFIX: 'git push origin HEAD:refs/for/',
-};
-
-/** @extends PolymerElement */
-class GrCreateCommandsDialog extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-create-commands-dialog'; }
-
- static get properties() {
- return {
- branch: String,
- _createNewCommitCommand: {
- type: String,
- readonly: true,
- value: Commands.CREATE,
- },
- _amendExistingCommitCommand: {
- type: String,
- readonly: true,
- value: Commands.AMEND,
- },
- _pushCommand: {
- type: String,
- computed: '_computePushCommand(branch)',
- },
- };
- }
-
- open() {
- this.$.commandsOverlay.open();
- }
-
- _handleClose() {
- this.$.commandsOverlay.close();
- }
-
- _computePushCommand(branch) {
- return Commands.PUSH_PREFIX + branch;
- }
-}
-
-customElements.define(GrCreateCommandsDialog.is, GrCreateCommandsDialog);
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
new file mode 100644
index 0000000..1ee8cb5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
@@ -0,0 +1,80 @@
+/**
+ * @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 '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-shell-command/gr-shell-command';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+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 {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-create-commands-dialog_html';
+
+enum Commands {
+ CREATE = 'git commit',
+ AMEND = 'git commit --amend',
+ PUSH_PREFIX = 'git push origin HEAD:refs/for/',
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-create-commands-dialog': GrCreateCommandsDialog;
+ }
+}
+
+export interface GrCreateCommandsDialog {
+ $: {
+ commandsOverlay: GrOverlay;
+ };
+}
+
+@customElement('gr-create-commands-dialog')
+export class GrCreateCommandsDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ branch?: string;
+
+ @property({type: String})
+ readonly _createNewCommitCommand = Commands.CREATE;
+
+ @property({type: String})
+ readonly _amendExistingCommitCommand = Commands.AMEND;
+
+ @property({
+ type: String,
+ computed: '_computePushCommand(branch)',
+ })
+ _pushCommand?: string;
+
+ open() {
+ this.$.commandsOverlay.open();
+ }
+
+ _handleClose() {
+ this.$.commandsOverlay.close();
+ }
+
+ _computePushCommand(branch: string): string {
+ return Commands.PUSH_PREFIX + branch;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
deleted file mode 100644
index 9062a3f..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.js
+++ /dev/null
@@ -1,78 +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 '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker.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-create-destination-dialog_html.js';
-
-/**
- * Fired when a destination has been picked. Event details contain the repo
- * name and the branch name.
- *
- * @event confirm
- * @extends PolymerElement
- */
-class GrCreateDestinationDialog extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-create-destination-dialog'; }
-
- static get properties() {
- return {
- _repo: String,
- _branch: String,
- _repoAndBranchSelected: {
- type: Boolean,
- value: false,
- computed: '_computeRepoAndBranchSelected(_repo, _branch)',
- },
- };
- }
-
- open() {
- this._repo = '';
- this._branch = '';
- this.$.createOverlay.open();
- }
-
- _handleClose() {
- this.$.createOverlay.close();
- }
-
- _pickerConfirm(e) {
- this.$.createOverlay.close();
- const detail = {repo: this._repo, branch: this._branch};
- // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
- // 'confirm' event here, so let's stop propagation of the bare event.
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
- }
-
- _computeRepoAndBranchSelected(repo, branch) {
- return !!(repo && branch);
- }
-}
-
-customElements.define(GrCreateDestinationDialog.is,
- GrCreateDestinationDialog);
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-dialog.ts
new file mode 100644
index 0000000..e53f68b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-destination-dialog/gr-create-destination-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 '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-repo-branch-picker/gr-repo-branch-picker';
+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-create-destination-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {RepoName, BranchName} from '../../../types/common';
+
+export interface CreateDestinationConfirmDetail {
+ repo?: RepoName;
+ branch?: BranchName;
+}
+
+/**
+ * Fired when a destination has been picked. Event details contain the repo
+ * name and the branch name.
+ *
+ * @event confirm
+ */
+export interface GrCreateDestinationDialog {
+ $: {
+ createOverlay: GrOverlay;
+ };
+}
+
+@customElement('gr-create-destination-dialog')
+export class GrCreateDestinationDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ _repo?: RepoName;
+
+ @property({type: String})
+ _branch?: BranchName;
+
+ @property({
+ type: Boolean,
+ computed: '_computeRepoAndBranchSelected(_repo, _branch)',
+ })
+ _repoAndBranchSelected = false;
+
+ open() {
+ this._repo = '' as RepoName;
+ this._branch = '' as BranchName;
+ this.$.createOverlay.open();
+ }
+
+ _handleClose() {
+ this.$.createOverlay.close();
+ }
+
+ _pickerConfirm(e: Event) {
+ this.$.createOverlay.close();
+ const detail: CreateDestinationConfirmDetail = {
+ repo: this._repo,
+ branch: this._branch,
+ };
+ // e is a 'confirm' event from gr-dialog. We want to fire a more detailed
+ // 'confirm' event here, so let's stop propagation of the bare event.
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent('confirm', {detail, bubbles: false}));
+ }
+
+ _computeRepoAndBranchSelected(repo?: RepoName, branch?: BranchName) {
+ return !!(repo && branch);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-create-destination-dialog': GrCreateDestinationDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
deleted file mode 100644
index ac53b74..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ /dev/null
@@ -1,339 +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/shared-styles.js';
-import '../gr-change-list/gr-change-list.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-create-commands-dialog/gr-create-commands-dialog.js';
-import '../gr-create-change-help/gr-create-change-help.js';
-import '../gr-create-destination-dialog/gr-create-destination-dialog.js';
-import '../gr-user-header/gr-user-header.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-dashboard-view_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {appContext} from '../../../services/app-context.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
-
-/**
- * @extends PolymerElement
- */
-class GrDashboardView extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-dashboard-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- static get properties() {
- return {
- account: {
- type: Object,
- value: null,
- },
- preferences: Object,
- /** @type {{ selectedChangeIndex: number }} */
- viewState: Object,
-
- /** @type {{ project: string, user: string }} */
- params: {
- type: Object,
- },
-
- createChangeTap: {
- type: Function,
- value() {
- return this._createChangeTap.bind(this);
- },
- },
-
- _results: Array,
-
- /**
- * For showing a "loading..." string during ajax requests.
- */
- _loading: {
- type: Boolean,
- value: true,
- },
-
- _showDraftsBanner: {
- type: Boolean,
- value: false,
- },
-
- _showNewUserHelp: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- static get observers() {
- return [
- '_paramsChanged(params.*)',
- ];
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadPreferences();
- }
-
- _loadPreferences() {
- return this.$.restAPI.getLoggedIn().then(loggedIn => {
- if (loggedIn) {
- this.$.restAPI.getPreferences().then(preferences => {
- this.preferences = preferences;
- });
- } else {
- this.preferences = {};
- }
- });
- }
-
- _getProjectDashboard(project, dashboard) {
- const errFn = response => {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
- return this.$.restAPI.getDashboard(
- project, dashboard, errFn).then(response => {
- if (!response) {
- return;
- }
- return {
- title: response.title,
- sections: response.sections.map(section => {
- const suffix = response.foreach ? ' ' + response.foreach : '';
- return {
- name: section.name,
- query: (section.query + suffix).replace(
- PROJECT_PLACEHOLDER_PATTERN, project),
- };
- }),
- };
- });
- }
-
- _computeTitle(user) {
- if (!user || user === 'self') {
- return 'My Reviews';
- }
- return 'Dashboard for ' + user;
- }
-
- _isViewActive(params) {
- return params.view === GerritNav.View.DASHBOARD;
- }
-
- _paramsChanged(paramsChangeRecord) {
- const params = paramsChangeRecord.base;
-
- if (!this._isViewActive(params)) {
- return Promise.resolve();
- }
-
- return this._reload();
- }
-
- /**
- * Reloads the element.
- *
- * @return {Promise<!Object>}
- */
- _reload() {
- this._loading = true;
- const {project, dashboard, title, user, sections} = this.params;
- const dashboardPromise = project ?
- this._getProjectDashboard(project, dashboard) :
- this.$.restAPI.getConfig().then(
- config => Promise.resolve(GerritNav.getUserDashboard(
- user,
- sections,
- title || this._computeTitle(user),
- config
- ))
- );
-
- const checkForNewUser = !project && user === 'self';
- return dashboardPromise
- .then(res => {
- if (res && res.title) {
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: res.title},
- composed: true, bubbles: true,
- }));
- }
- return this._fetchDashboardChanges(res, checkForNewUser);
- })
- .then(() => {
- this._maybeShowDraftsBanner();
- this.reporting.dashboardDisplayed();
- })
- .catch(err => {
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {
- title: title || this._computeTitle(user),
- },
- composed: true, bubbles: true,
- }));
- console.warn(err);
- })
- .then(() => { this._loading = false; });
- }
-
- /**
- * Fetches the changes for each dashboard section and sets this._results
- * with the response.
- *
- * @param {!Object} res
- * @param {boolean} checkForNewUser
- * @return {Promise}
- */
- _fetchDashboardChanges(res, checkForNewUser) {
- if (!res) { return Promise.resolve(); }
-
- const queries = res.sections
- .map(section => (section.suffixForDashboard ?
- section.query + ' ' + section.suffixForDashboard :
- section.query));
-
- if (checkForNewUser) {
- queries.push('owner:self limit:1');
- }
-
- return this.$.restAPI.getChanges(null, queries)
- .then(changes => {
- if (checkForNewUser) {
- // Last set of results is not meant for dashboard display.
- const lastResultSet = changes.pop();
- this._showNewUserHelp = lastResultSet.length == 0;
- }
- this._results = changes.map((results, i) => {
- return {
- name: res.sections[i].name,
- countLabel: this._computeSectionCountLabel(results),
- query: res.sections[i].query,
- results,
- isOutgoing: res.sections[i].isOutgoing,
- };
- }).filter((section, i) => i < res.sections.length && (
- !res.sections[i].hideIfEmpty ||
- section.results.length));
- });
- }
-
- _computeSectionCountLabel(changes) {
- if (!changes || !changes.length || changes.length == 0) {
- return '';
- }
- const more = changes[changes.length - 1]._more_changes;
- const numChanges = changes.length;
- const andMore = more ? ' and more' : '';
- return `(${numChanges}${andMore})`;
- }
-
- _computeUserHeaderClass(params) {
- if (!params || !!params.project || !params.user ||
- params.user === 'self') {
- return 'hide';
- }
- return '';
- }
-
- _handleToggleStar(e) {
- this.$.restAPI.saveChangeStarred(e.detail.change._number,
- e.detail.starred);
- }
-
- _handleToggleReviewed(e) {
- this.$.restAPI.saveChangeReviewed(e.detail.change._number,
- e.detail.reviewed);
- }
-
- /**
- * Banner is shown if a user is on their own dashboard and they have draft
- * comments on closed changes.
- */
- _maybeShowDraftsBanner() {
- this._showDraftsBanner = false;
- if (!(this.params.user === 'self')) { return; }
-
- const draftSection = this._results
- .find(section => section.query === 'has:draft');
- if (!draftSection || !draftSection.results.length) { return; }
-
- const closedChanges = draftSection.results
- .filter(change => !changeIsOpen(change));
- if (!closedChanges.length) { return; }
-
- this._showDraftsBanner = true;
- }
-
- _computeBannerClass(show) {
- return show ? '' : 'hide';
- }
-
- _handleOpenDeleteDialog() {
- this.$.confirmDeleteOverlay.open();
- }
-
- _handleConfirmDelete() {
- this.$.confirmDeleteDialog.disabled = true;
- return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
- this._closeConfirmDeleteOverlay();
- this._reload();
- });
- }
-
- _closeConfirmDeleteOverlay() {
- this.$.confirmDeleteOverlay.close();
- }
-
- _computeDraftsLink() {
- return GerritNav.getUrlForSearchQuery('has:draft -is:open');
- }
-
- _createChangeTap(e) {
- this.$.destinationDialog.open();
- }
-
- _handleDestinationConfirm(e) {
- this.$.commandsDialog.branch = e.detail.branch;
- this.$.commandsDialog.open();
- }
-}
-
-customElements.define(GrDashboardView.is, GrDashboardView);
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
new file mode 100644
index 0000000..57d3dd9
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.ts
@@ -0,0 +1,450 @@
+/**
+ * @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/shared-styles';
+import '../gr-change-list/gr-change-list';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-create-commands-dialog/gr-create-commands-dialog';
+import '../gr-create-change-help/gr-create-change-help';
+import '../gr-create-destination-dialog/gr-create-destination-dialog';
+import '../gr-user-header/gr-user-header';
+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-dashboard-view_html';
+import {
+ GerritNav,
+ GerritView,
+ UserDashboard,
+ YOUR_TURN,
+} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {changeIsOpen} from '../../../utils/change-util';
+import {parseDate} from '../../../utils/date-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+ AccountDetailInfo,
+ ChangeInfo,
+ DashboardId,
+ ElementPropertyDeepChange,
+ PreferencesInput,
+ RepoName,
+} from '../../../types/common';
+import {AppElementDashboardParams, AppElementParams} from '../../gr-app-types';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateCommandsDialog} from '../gr-create-commands-dialog/gr-create-commands-dialog';
+import {
+ CreateDestinationConfirmDetail,
+ GrCreateDestinationDialog,
+} from '../gr-create-destination-dialog/gr-create-destination-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
+import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
+import {DashboardViewState} from '../../../types/types';
+
+const PROJECT_PLACEHOLDER_PATTERN = /\$\{project\}/g;
+
+export interface GrDashboardView {
+ $: {
+ restAPI: RestApiService & Element;
+ confirmDeleteDialog: GrDialog;
+ commandsDialog: GrCreateCommandsDialog;
+ destinationDialog: GrCreateDestinationDialog;
+ confirmDeleteOverlay: GrOverlay;
+ };
+}
+
+interface DashboardChange {
+ name: string;
+ countLabel: string;
+ query: string;
+ results: ChangeInfo[];
+ isOutgoing?: boolean;
+}
+
+@customElement('gr-dashboard-view')
+export class GrDashboardView extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ @property({type: Object})
+ account: AccountDetailInfo | null = null;
+
+ @property({type: Object})
+ preferences?: PreferencesInput;
+
+ @property({type: Object})
+ viewState?: DashboardViewState;
+
+ @property({type: Object})
+ params?: AppElementParams;
+
+ @property({type: Array})
+ _results?: DashboardChange[];
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Boolean})
+ _showDraftsBanner = false;
+
+ @property({type: Boolean})
+ _showNewUserHelp = false;
+
+ private reporting = appContext.reportingService;
+
+ constructor() {
+ super();
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadPreferences();
+ this.addEventListener('reload', e => {
+ e.stopPropagation();
+ this._reload();
+ });
+ }
+
+ _loadPreferences() {
+ return this.$.restAPI.getLoggedIn().then(loggedIn => {
+ if (loggedIn) {
+ this.$.restAPI.getPreferences().then(preferences => {
+ this.preferences = preferences;
+ });
+ } else {
+ this.preferences = {};
+ }
+ });
+ }
+
+ _getProjectDashboard(
+ project: RepoName,
+ dashboard: DashboardId
+ ): Promise<UserDashboard | undefined> {
+ const errFn = (response?: Response | null) => {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+ return this.$.restAPI
+ .getDashboard(project, dashboard, errFn)
+ .then(response => {
+ if (!response) {
+ return;
+ }
+ return {
+ title: response.title,
+ sections: response.sections.map(section => {
+ const suffix = response.foreach ? ' ' + response.foreach : '';
+ return {
+ name: section.name,
+ query: (section.query + suffix).replace(
+ PROJECT_PLACEHOLDER_PATTERN,
+ project
+ ),
+ };
+ }),
+ };
+ });
+ }
+
+ _computeTitle(user?: string) {
+ if (!user || user === 'self') {
+ return 'My Reviews';
+ }
+ return 'Dashboard for ' + user;
+ }
+
+ _isViewActive(params: AppElementParams): params is AppElementDashboardParams {
+ return params.view === GerritView.DASHBOARD;
+ }
+
+ @observe('params.*')
+ _paramsChanged(
+ paramsChangeRecord: ElementPropertyDeepChange<GrDashboardView, 'params'>
+ ) {
+ const params = paramsChangeRecord.base;
+
+ return this._reload(params);
+ }
+
+ /**
+ * Reloads the element.
+ */
+ _reload(params?: AppElementParams) {
+ if (!params || !this._isViewActive(params)) {
+ return Promise.resolve();
+ }
+ this._loading = true;
+ const {project, dashboard, title, user, sections} = params;
+ const dashboardPromise: Promise<UserDashboard | undefined> = project
+ ? this._getProjectDashboard(project, dashboard)
+ : this.$.restAPI
+ .getConfig()
+ .then(config =>
+ Promise.resolve(
+ GerritNav.getUserDashboard(
+ user,
+ sections,
+ title || this._computeTitle(user),
+ config
+ )
+ )
+ );
+
+ const checkForNewUser = !project && user === 'self';
+ return dashboardPromise
+ .then(res => {
+ if (res && res.title) {
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: res.title},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ return this._fetchDashboardChanges(res, checkForNewUser);
+ })
+ .then(() => {
+ this._maybeShowDraftsBanner(params);
+ this.reporting.dashboardDisplayed();
+ })
+ .catch(err => {
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {
+ title: title || this._computeTitle(user),
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ console.warn(err);
+ })
+ .then(() => {
+ this._loading = false;
+ });
+ }
+
+ /**
+ * Fetches the changes for each dashboard section and sets this._results
+ * with the response.
+ */
+ _fetchDashboardChanges(
+ res: UserDashboard | undefined,
+ checkForNewUser: boolean
+ ): Promise<void> {
+ if (!res) {
+ return Promise.resolve();
+ }
+
+ let queries: string[];
+
+ if (window.PRELOADED_QUERIES && window.PRELOADED_QUERIES.dashboardQuery) {
+ queries = window.PRELOADED_QUERIES.dashboardQuery;
+ // we use preloaded query from index only on first page load
+ window.PRELOADED_QUERIES.dashboardQuery = undefined;
+ } else {
+ queries = res.sections.map(section =>
+ section.suffixForDashboard
+ ? section.query + ' ' + section.suffixForDashboard
+ : section.query
+ );
+
+ if (checkForNewUser) {
+ queries.push('owner:self limit:1');
+ }
+ }
+
+ return this.$.restAPI.getChanges(undefined, queries).then(changes => {
+ if (!changes) {
+ throw new Error('getChanges returns undefined');
+ }
+ if (checkForNewUser) {
+ // Last set of results is not meant for dashboard display.
+ const lastResultSet = changes.pop();
+ this._showNewUserHelp = lastResultSet!.length === 0;
+ }
+ this._results = changes
+ .map((results, i) => {
+ return {
+ name: res.sections[i].name,
+ countLabel: this._computeSectionCountLabel(results),
+ query: res.sections[i].query,
+ results: this._maybeSortResults(res.sections[i].name, results),
+ isOutgoing: res.sections[i].isOutgoing,
+ };
+ })
+ .filter(
+ (section, i) =>
+ i < res.sections.length &&
+ (!res.sections[i].hideIfEmpty || section.results.length)
+ );
+ });
+ }
+
+ /**
+ * Usually we really want to stick to the sorting that the backend provides,
+ * but for the "Your Turn" section it is important to put the changes at the
+ * top where the current user is a reviewer. Owned changes are less important.
+ * And then we want to emphasize the changes where the waiting time is larger.
+ */
+ _maybeSortResults(name: string, results: ChangeInfo[]) {
+ const userId = this.account && this.account._account_id;
+ const sortedResults = [...results];
+ if (name === YOUR_TURN.name && userId) {
+ sortedResults.sort((c1, c2) => {
+ const c1Owner = c1.owner._account_id === userId;
+ const c2Owner = c2.owner._account_id === userId;
+ if (c1Owner !== c2Owner) return c1Owner ? 1 : -1;
+ // Should never happen, because the change is in the 'Your Turn'
+ // section, so the userId should be found in the attention set of both.
+ if (!c1.attention_set || !c1.attention_set[userId]) return 0;
+ if (!c2.attention_set || !c2.attention_set[userId]) return 0;
+ const c1Update = c1.attention_set[userId].last_update;
+ const c2Update = c2.attention_set[userId].last_update;
+ // Should never happen that an attention set entry has no update.
+ if (!c1Update || !c2Update) return c1Update ? 1 : -1;
+ return parseDate(c1Update).valueOf() - parseDate(c2Update).valueOf();
+ });
+ }
+ return sortedResults;
+ }
+
+ _computeSectionCountLabel(changes: ChangeInfo[]) {
+ if (!changes || !changes.length || changes.length === 0) {
+ return '';
+ }
+ const more = changes[changes.length - 1]._more_changes;
+ const numChanges = changes.length;
+ const andMore = more ? ' and more' : '';
+ return `(${numChanges}${andMore})`;
+ }
+
+ _computeUserHeaderClass(params: AppElementParams) {
+ if (
+ !params ||
+ params.view !== GerritView.DASHBOARD ||
+ !!params.project ||
+ !params.user ||
+ params.user === 'self'
+ ) {
+ return 'hide';
+ }
+ return '';
+ }
+
+ _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+ this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+ }
+
+ _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
+ this.$.restAPI.saveChangeReviewed(
+ e.detail.change._number,
+ e.detail.reviewed
+ );
+ }
+
+ /**
+ * Banner is shown if a user is on their own dashboard and they have draft
+ * comments on closed changes.
+ */
+ _maybeShowDraftsBanner(params: AppElementDashboardParams) {
+ this._showDraftsBanner = false;
+ if (!(params.user === 'self')) {
+ return;
+ }
+
+ if (!this._results) {
+ throw new Error('this._results must be set. restAPI returned undefined');
+ }
+
+ const draftSection = this._results.find(
+ section => section.query === 'has:draft'
+ );
+ if (!draftSection || !draftSection.results.length) {
+ return;
+ }
+
+ const closedChanges = draftSection.results.filter(
+ change => !changeIsOpen(change)
+ );
+ if (!closedChanges.length) {
+ return;
+ }
+
+ this._showDraftsBanner = true;
+ }
+
+ _computeBannerClass(show: boolean) {
+ return show ? '' : 'hide';
+ }
+
+ _handleOpenDeleteDialog() {
+ this.$.confirmDeleteOverlay.open();
+ }
+
+ _handleConfirmDelete() {
+ this.$.confirmDeleteDialog.disabled = true;
+ return this.$.restAPI.deleteDraftComments('-is:open').then(() => {
+ this._closeConfirmDeleteOverlay();
+ this._reload(this.params);
+ });
+ }
+
+ _closeConfirmDeleteOverlay() {
+ this.$.confirmDeleteOverlay.close();
+ }
+
+ _computeDraftsLink() {
+ return GerritNav.getUrlForSearchQuery('has:draft -is:open');
+ }
+
+ _handleCreateChangeTap() {
+ this.$.destinationDialog.open();
+ }
+
+ _handleDestinationConfirm(e: CustomEvent<CreateDestinationConfirmDetail>) {
+ this.$.commandsDialog.branch = e.detail.branch;
+ this.$.commandsDialog.open();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-dashboard-view': GrDashboardView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
index ea04c5a..6dae176 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts
@@ -50,6 +50,9 @@
#emptyOutgoing {
display: block;
}
+ #emptyYourTurn {
+ text-align: center;
+ }
@media only screen and (max-width: 50em) {
.loading {
padding: 0 var(--spacing-l);
@@ -75,6 +78,7 @@
user-id="[[params.user]]"
class$="[[_computeUserHeaderClass(params)]]"
></gr-user-header>
+ <h1 class="assistive-tech-only">Dashboard</h1>
<gr-change-list
show-star=""
show-reviewed-state=""
@@ -88,13 +92,16 @@
<div id="emptyOutgoing" slot="empty-outgoing">
<template is="dom-if" if="[[_showNewUserHelp]]">
<gr-create-change-help
- on-create-tap="createChangeTap"
+ on-create-tap="_handleCreateChangeTap"
></gr-create-change-help>
</template>
<template is="dom-if" if="[[!_showNewUserHelp]]">
No changes
</template>
</div>
+ <div id="emptyYourTurn" slot="empty-your-turn">
+ <span>🎉 No changes need your attention 🎉</span>
+ </div>
</gr-change-list>
</div>
<gr-overlay id="confirmDeleteOverlay" with-backdrop="">
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index f56ad75..44f203d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
@@ -18,7 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-dashboard-view.js';
import {isHidden} from '../../../test/test-utils.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation.js';
import {changeIsOpen} from '../../../utils/change-util.js';
import {ChangeStatus} from '../../../constants/constants.js';
@@ -54,44 +54,52 @@
suite('drafts banner functionality', () => {
suite('_maybeShowDraftsBanner', () => {
test('not dashboard/self', () => {
- element.params = {user: 'notself'};
- element._maybeShowDraftsBanner();
+ element._maybeShowDraftsBanner({
+ view: GerritView.DASHBOARD,
+ user: 'notself',
+ });
assert.isFalse(element._showDraftsBanner);
});
test('no drafts at all', () => {
- element.params = {user: 'self'};
element._results = [];
- element._maybeShowDraftsBanner();
+ element._maybeShowDraftsBanner({
+ view: GerritView.DASHBOARD,
+ user: 'self',
+ });
assert.isFalse(element._showDraftsBanner);
});
test('no drafts on open changes', () => {
- element.params = {user: 'self'};
const openChange = {status: ChangeStatus.NEW};
element._results = [{query: 'has:draft', results: [openChange]}];
- element._maybeShowDraftsBanner();
+ element._maybeShowDraftsBanner({
+ view: GerritView.DASHBOARD,
+ user: 'self',
+ });
assert.isFalse(element._showDraftsBanner);
});
test('no drafts on not open changes', () => {
- element.params = {user: 'self'};
const notOpenChange = {status: '_'};
element._results = [{query: 'has:draft', results: [notOpenChange]}];
assert.isFalse(changeIsOpen(element._results[0].results[0]));
- element._maybeShowDraftsBanner();
+ element._maybeShowDraftsBanner({
+ view: GerritView.DASHBOARD,
+ user: 'self',
+ });
assert.isTrue(element._showDraftsBanner);
});
});
test('_showDraftsBanner', () => {
element._showDraftsBanner = false;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.banner')));
element._showDraftsBanner = true;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(isHidden(element.shadowRoot
.querySelector('.banner')));
});
@@ -99,7 +107,7 @@
test('delete tap opens dialog', () => {
sinon.stub(element, '_handleOpenDeleteDialog');
element._showDraftsBanner = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('.banner .delete'));
@@ -121,7 +129,7 @@
// Open confirmation dialog and tap confirm button.
await element.$.confirmDeleteOverlay.open();
MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.restAPI.deleteDraftComments
.calledWithExactly('-is:open'));
assert.isTrue(element.$.confirmDeleteDialog.disabled);
@@ -163,11 +171,11 @@
suite('_isViewActive', () => {
test('nothing happens when user param is falsy', () => {
element.params = {};
- flushAsynchronousOperations();
+ flush();
assert.equal(getChangesStub.callCount, 0);
element.params = {user: ''};
- flushAsynchronousOperations();
+ flush();
assert.equal(getChangesStub.callCount, 0);
});
@@ -194,7 +202,8 @@
};
return paramsChangedPromise.then(() => {
assert.isTrue(
- getChangesStub.calledWith(null, ['1', '2', 'owner:self limit:1']));
+ getChangesStub.calledWith(undefined,
+ ['1', '2', 'owner:self limit:1']));
});
});
@@ -208,7 +217,7 @@
user: 'user',
};
return paramsChangedPromise.then(() => {
- assert.isTrue(getChangesStub.calledWith(null, ['1']));
+ assert.isTrue(getChangesStub.calledWith(undefined, ['1']));
});
});
});
@@ -224,7 +233,7 @@
return paramsChangedPromise.then(() => {
assert.isTrue(getChangesStub.calledOnce);
assert.deepEqual(
- getChangesStub.firstCall.args, [null, ['1', '2 suffix']]);
+ getChangesStub.firstCall.args, [undefined, ['1', '2 suffix']]);
});
});
@@ -312,13 +321,13 @@
test('_showNewUserHelp', () => {
element._loading = false;
element._showNewUserHelp = false;
- flushAsynchronousOperations();
+ flush();
assert.equal(element.$.emptyOutgoing.textContent.trim(), 'No changes');
assert.isNotOk(element.shadowRoot
.querySelector('gr-create-change-help'));
element._showNewUserHelp = true;
- flushAsynchronousOperations();
+ flush();
assert.notEqual(element.$.emptyOutgoing.textContent.trim(), 'No changes');
assert.isOk(element.shadowRoot
@@ -329,10 +338,23 @@
assert.equal(element._computeUserHeaderClass(undefined), 'hide');
assert.equal(element._computeUserHeaderClass({}), 'hide');
assert.equal(element._computeUserHeaderClass({user: 'self'}), 'hide');
- assert.equal(element._computeUserHeaderClass({user: 'user'}), '');
+ assert.equal(element._computeUserHeaderClass({user: 'user'}), 'hide');
+ assert.equal(
+ element._computeUserHeaderClass({
+ view: GerritView.DASHBOARD,
+ user: 'user',
+ }),
+ '');
assert.equal(
element._computeUserHeaderClass({project: 'p', user: 'user'}),
'hide');
+ assert.equal(
+ element._computeUserHeaderClass({
+ view: GerritView.DASHBOARD,
+ project: 'p',
+ user: 'user',
+ }),
+ 'hide');
});
test('404 page', done => {
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
deleted file mode 100644
index 303c612..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.js
+++ /dev/null
@@ -1,57 +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 '../../../styles/dashboard-header-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 {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-repo-header_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/** @extends PolymerElement */
-class GrRepoHeader extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo-header'; }
-
- static get properties() {
- return {
- /** @type {?string} */
- repo: {
- type: String,
- observer: '_repoChanged',
- },
- /** @type {string|null} */
- _repoUrl: String,
- };
- }
-
- _repoChanged(repoName) {
- if (!repoName) {
- this._repoUrl = null;
- return;
- }
- this._repoUrl = GerritNav.getUrlForRepo(repoName);
- }
-}
-
-customElements.define(GrRepoHeader.is, GrRepoHeader);
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
new file mode 100644
index 0000000..e501cfd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header.ts
@@ -0,0 +1,58 @@
+/**
+ * @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 '../../../styles/dashboard-header-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-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-repo-header_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {RepoName} from '../../../types/common';
+
+/** @extends PolymerElement */
+@customElement('gr-repo-header')
+class GrRepoHeader extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, observer: '_repoChanged'})
+ repo?: string;
+
+ @property({type: String})
+ _repoUrl: string | null = null;
+
+ _repoChanged(repoName: RepoName) {
+ if (!repoName) {
+ this._repoUrl = null;
+ return;
+ }
+ this._repoUrl = GerritNav.getUrlForRepo(repoName);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo-header': GrRepoHeader;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
index 9fd27c6..d1221d15 100644
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
@@ -26,8 +26,8 @@
<div class="info">
<h1 class="heading-1">
[[repo]]
- <hr />
</h1>
+ <hr />
<div><span>Detail:</span> <a href$="[[_repoUrl]]">Repo settings</a></div>
</div>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
deleted file mode 100644
index 7901c53..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
+++ /dev/null
@@ -1,116 +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/shared-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-avatar/gr-avatar.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/dashboard-header-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-user-header_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/**
- * @extends PolymerElement
- */
-class GrUserHeader extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-user-header'; }
-
- static get properties() {
- return {
- /** @type {?string} */
- userId: {
- type: String,
- observer: '_accountChanged',
- },
-
- showDashboardLink: {
- type: Boolean,
- value: false,
- },
-
- loggedIn: {
- type: Boolean,
- value: false,
- },
-
- /**
- * @type {?{name: ?, email: ?, registered_on: ?}}
- */
- _accountDetails: {
- type: Object,
- value: null,
- },
-
- /** @type {?string} */
- _status: {
- type: String,
- value: null,
- },
- };
- }
-
- _accountChanged(userId) {
- if (!userId) {
- this._accountDetails = null;
- this._status = null;
- return;
- }
-
- this.$.restAPI.getAccountDetails(userId).then(details => {
- this._accountDetails = details;
- });
- this.$.restAPI.getAccountStatus(userId).then(status => {
- this._status = status;
- });
- }
-
- _computeDisplayClass(status) {
- return status ? ' ' : 'hide';
- }
-
- _computeDetail(accountDetails, name) {
- return accountDetails ? accountDetails[name] : '';
- }
-
- _computeStatusClass(accountDetails) {
- return this._computeDetail(accountDetails, 'status') ? '' : 'hide';
- }
-
- _computeDashboardUrl(accountDetails) {
- if (!accountDetails) { return null; }
- const id = accountDetails._account_id;
- const email = accountDetails.email;
- if (!id && !email ) { return null; }
- return GerritNav.getUrlForUserDashboard(id ? id : email);
- }
-
- _computeDashboardLinkClass(showDashboardLink, loggedIn) {
- return showDashboardLink && loggedIn ?
- 'dashboardLink' : 'dashboardLink hide';
- }
-}
-
-customElements.define(GrUserHeader.is, GrUserHeader);
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
new file mode 100644
index 0000000..055c82c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.ts
@@ -0,0 +1,119 @@
+/**
+ * @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/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-avatar/gr-avatar';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/dashboard-header-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-user-header_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountDetailInfo, AccountId} from '../../../types/common';
+import {getDisplayName} from '../../../utils/display-name-util';
+
+export interface GrUserHeader {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-user-header')
+export class GrUserHeader extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, observer: '_accountChanged'})
+ userId?: AccountId;
+
+ @property({type: Boolean})
+ showDashboardLink = false;
+
+ @property({type: Boolean})
+ loggedIn = false;
+
+ @property({type: Object})
+ _accountDetails: AccountDetailInfo | null = null;
+
+ @property({type: String})
+ _status = '';
+
+ _accountChanged(userId?: AccountId) {
+ if (!userId) {
+ this._accountDetails = null;
+ this._status = '';
+ return;
+ }
+
+ this.$.restAPI.getAccountDetails(userId).then(details => {
+ this._accountDetails = details ?? null;
+ this._status = details?.status ?? '';
+ });
+ }
+
+ _computeDetail(
+ accountDetails: AccountDetailInfo | null,
+ name: keyof AccountDetailInfo
+ ) {
+ return accountDetails ? accountDetails[name] : '';
+ }
+
+ _computeHeading(accountDetails: AccountDetailInfo | null) {
+ if (!accountDetails) return '';
+ return getDisplayName(undefined, accountDetails);
+ }
+
+ _computeStatusClass(status: string) {
+ return status ? '' : 'hide';
+ }
+
+ _computeDashboardUrl(accountDetails: AccountDetailInfo | null) {
+ if (!accountDetails) {
+ return null;
+ }
+ const id = accountDetails._account_id;
+ if (id) {
+ return GerritNav.getUrlForUserDashboard(String(id));
+ }
+ const email = accountDetails.email;
+ if (email) {
+ return GerritNav.getUrlForUserDashboard(email);
+ }
+ return null;
+ }
+
+ _computeDashboardLinkClass(showDashboardLink: boolean, loggedIn: boolean) {
+ return showDashboardLink && loggedIn
+ ? 'dashboardLink'
+ : 'dashboardLink hide';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-user-header': GrUserHeader;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
index 72bdca6..002a4ba 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
@@ -34,10 +34,10 @@
></gr-avatar>
<div class="info">
<h1 class="heading-1">
- [[_computeDetail(_accountDetails, 'name')]]
+ [[_computeHeading(_accountDetails)]]
</h1>
<hr />
- <div class$="status [[_computeStatusClass(_accountDetails)]]">
+ <div class$="status [[_computeStatusClass(_status)]]">
<span>Status:</span> [[_status]]
</div>
<div>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
index 6baacef..a808f3c 100644
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
@@ -32,10 +32,9 @@
.returns(Promise.resolve({
name: 'foo',
email: 'bar',
+ status: 'OOO',
registered_on: '2015-03-12 18:32:08.000000000',
}));
- sinon.stub(element.$.restAPI, 'getAccountStatus')
- .returns(Promise.resolve('baz'));
element.userId = 'foo.bar@baz';
flush(() => {
@@ -44,9 +43,9 @@
element.userId = null;
flush(() => {
- flushAsynchronousOperations();
+ flush();
assert.isNull(element._accountDetails);
- assert.isNull(element._status);
+ assert.equal(element._status, '');
done();
});
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
deleted file mode 100644
index f816ee9..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ /dev/null
@@ -1,1707 +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 '../../admin/gr-create-change-dialog/gr-create-change-dialog.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js';
-import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js';
-import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js';
-import '../gr-confirm-move-dialog/gr-confirm-move-dialog.js';
-import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js';
-import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog.js';
-import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js';
-import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-actions_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {
- fetchChangeUpdates,
- patchNumEquals,
-} from '../../../utils/patch-set-util.js';
-import {
- changeIsOpen,
- ListChangesOption,
- listChangesOptionsToHex,
-} from '../../../utils/change-util.js';
-
-const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
-const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
-const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
-/**
- * @enum {string}
- */
-const LabelStatus = {
- /**
- * This label provides what is necessary for submission.
- */
- OK: 'OK',
- /**
- * This label prevents the change from being submitted.
- */
- REJECT: 'REJECT',
- /**
- * The label may be set, but it's neither necessary for submission
- * nor does it block submission if set.
- */
- MAY: 'MAY',
- /**
- * The label is required for submission, but has not been satisfied.
- */
- NEED: 'NEED',
- /**
- * The label is required for submission, but is impossible to complete.
- * The likely cause is access has not been granted correctly by the
- * project owner or site administrator.
- */
- IMPOSSIBLE: 'IMPOSSIBLE',
- OPTIONAL: 'OPTIONAL',
-};
-
-const ChangeActions = {
- ABANDON: 'abandon',
- DELETE: '/',
- DELETE_EDIT: 'deleteEdit',
- EDIT: 'edit',
- FOLLOW_UP: 'followup',
- IGNORE: 'ignore',
- MOVE: 'move',
- PRIVATE: 'private',
- PRIVATE_DELETE: 'private.delete',
- PUBLISH_EDIT: 'publishEdit',
- REBASE: 'rebase',
- REBASE_EDIT: 'rebaseEdit',
- READY: 'ready',
- RESTORE: 'restore',
- REVERT: 'revert',
- REVERT_SUBMISSION: 'revert_submission',
- REVIEWED: 'reviewed',
- STOP_EDIT: 'stopEdit',
- SUBMIT: 'submit',
- UNIGNORE: 'unignore',
- UNREVIEWED: 'unreviewed',
- WIP: 'wip',
-};
-
-const RevisionActions = {
- CHERRYPICK: 'cherrypick',
- REBASE: 'rebase',
- SUBMIT: 'submit',
- DOWNLOAD: 'download',
-};
-
-const ActionLoadingLabels = {
- abandon: 'Abandoning...',
- cherrypick: 'Cherry-picking...',
- delete: 'Deleting...',
- move: 'Moving..',
- rebase: 'Rebasing...',
- restore: 'Restoring...',
- revert: 'Reverting...',
- revert_submission: 'Reverting Submission...',
- submit: 'Submitting...',
-};
-
-const ActionType = {
- CHANGE: 'change',
- REVISION: 'revision',
-};
-
-const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
-
-const QUICK_APPROVE_ACTION = {
- __key: 'review',
- __type: 'change',
- enabled: true,
- key: 'review',
- label: 'Quick approve',
- method: 'POST',
-};
-
-const ActionPriority = {
- CHANGE: 2,
- DEFAULT: 0,
- PRIMARY: 3,
- REVIEW: -3,
- REVISION: 1,
-};
-
-const DOWNLOAD_ACTION = {
- enabled: true,
- label: 'Download patch',
- title: 'Open download dialog',
- __key: 'download',
- __primary: false,
- __type: 'revision',
-};
-
-const REBASE_EDIT = {
- enabled: true,
- label: 'Rebase edit',
- title: 'Rebase change edit',
- __key: 'rebaseEdit',
- __primary: false,
- __type: 'change',
- method: 'POST',
-};
-
-const PUBLISH_EDIT = {
- enabled: true,
- label: 'Publish edit',
- title: 'Publish change edit',
- __key: 'publishEdit',
- __primary: false,
- __type: 'change',
- method: 'POST',
-};
-
-const DELETE_EDIT = {
- enabled: true,
- label: 'Delete edit',
- title: 'Delete change edit',
- __key: 'deleteEdit',
- __primary: false,
- __type: 'change',
- method: 'DELETE',
-};
-
-const EDIT = {
- enabled: true,
- label: 'Edit',
- title: 'Edit this change',
- __key: 'edit',
- __primary: false,
- __type: 'change',
-};
-
-const STOP_EDIT = {
- enabled: true,
- label: 'Stop editing',
- title: 'Stop editing this change',
- __key: 'stopEdit',
- __primary: false,
- __type: 'change',
-};
-
-// Set of keys that have icons. As more icons are added to gr-icons.html, this
-// set should be expanded.
-const ACTIONS_WITH_ICONS = new Set([
- ChangeActions.ABANDON,
- ChangeActions.DELETE_EDIT,
- ChangeActions.EDIT,
- ChangeActions.PUBLISH_EDIT,
- ChangeActions.READY,
- ChangeActions.REBASE_EDIT,
- ChangeActions.RESTORE,
- ChangeActions.REVERT,
- ChangeActions.REVERT_SUBMISSION,
- ChangeActions.STOP_EDIT,
- QUICK_APPROVE_ACTION.key,
- RevisionActions.REBASE,
- RevisionActions.SUBMIT,
-]);
-
-const AWAIT_CHANGE_ATTEMPTS = 5;
-const AWAIT_CHANGE_TIMEOUT_MS = 1000;
-
-const REVERT_TYPES = {
- REVERT_SINGLE_CHANGE: 1,
- REVERT_SUBMISSION: 2,
-};
-
-/* Revert submission is skipped as the normal revert dialog will now show
-the user a choice between reverting single change or an entire submission.
-Hence, a second button is not needed.
-*/
-const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
-
-const SKIP_ACTION_KEYS_ATTENTION_SET = [
- ChangeActions.REVIEWED,
- ChangeActions.UNREVIEWED,
-];
-
-/**
- * @extends PolymerElement
- */
-class GrChangeActions extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-actions'; }
- /**
- * Fired when the change should be reloaded.
- *
- * @event reload-change
- */
-
- /**
- * Fired when an action is tapped.
- *
- * @event custom-tap - naming pattern: <action key>-tap
- */
-
- /**
- * Fires to show an alert when a send is attempted on the non-latest patch.
- *
- * @event show-alert
- */
-
- /**
- * Fires when a change action fails.
- *
- * @event show-error
- */
-
- constructor() {
- super();
- this.ActionType = ActionType;
- this.ChangeActions = ChangeActions;
- this.RevisionActions = RevisionActions;
- this.reporting = appContext.reportingService;
- }
-
- static get properties() {
- return {
- /**
- * @type {{
- * _number: number,
- * branch: string,
- * id: string,
- * project: string,
- * subject: string,
- * }}
- */
- change: Object,
- actions: {
- type: Object,
- value() { return {}; },
- },
- primaryActionKeys: {
- type: Array,
- value() {
- return [
- ChangeActions.READY,
- RevisionActions.SUBMIT,
- ];
- },
- },
- disableEdit: {
- type: Boolean,
- value: false,
- },
- _hasKnownChainState: {
- type: Boolean,
- value: false,
- },
- _hideQuickApproveAction: {
- type: Boolean,
- value: false,
- },
- changeNum: String,
- changeStatus: String,
- commitNum: String,
- hasParent: {
- type: Boolean,
- observer: '_computeChainState',
- },
- latestPatchNum: String,
- commitMessage: {
- type: String,
- value: '',
- },
- /** @type {?} */
- revisionActions: {
- type: Object,
- notify: true,
- value() { return {}; },
- },
- // If property binds directly to [[revisionActions.submit]] it is not
- // updated when revisionActions doesn't contain submit action.
- /** @type {?} */
- _revisionSubmitAction: {
- type: Object,
- computed: '_getSubmitAction(revisionActions)',
- },
- // If property binds directly to [[revisionActions.rebase]] it is not
- // updated when revisionActions doesn't contain rebase action.
- /** @type {?} */
- _revisionRebaseAction: {
- type: Object,
- computed: '_getRebaseAction(revisionActions)',
- },
- privateByDefault: String,
-
- _loading: {
- type: Boolean,
- value: true,
- },
- _actionLoadingMessage: {
- type: String,
- value: '',
- },
- _allActionValues: {
- type: Array,
- computed: '_computeAllActions(actions.*, revisionActions.*,' +
- 'primaryActionKeys.*, _additionalActions.*, change, ' +
- '_config, _actionPriorityOverrides.*)',
- },
- _topLevelActions: {
- type: Array,
- computed: '_computeTopLevelActions(_allActionValues.*, ' +
- '_hiddenActions.*, _overflowActions.*)',
- observer: '_filterPrimaryActions',
- },
- _topLevelPrimaryActions: Array,
- _topLevelSecondaryActions: Array,
- _menuActions: {
- type: Array,
- computed: '_computeMenuActions(_allActionValues.*, ' +
- '_hiddenActions.*, _overflowActions.*)',
- },
- _overflowActions: {
- type: Array,
- value() {
- const value = [
- {
- type: ActionType.CHANGE,
- key: ChangeActions.WIP,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.DELETE,
- },
- {
- type: ActionType.REVISION,
- key: RevisionActions.CHERRYPICK,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.MOVE,
- },
- {
- type: ActionType.REVISION,
- key: RevisionActions.DOWNLOAD,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.IGNORE,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.UNIGNORE,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.REVIEWED,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.UNREVIEWED,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.PRIVATE,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.PRIVATE_DELETE,
- },
- {
- type: ActionType.CHANGE,
- key: ChangeActions.FOLLOW_UP,
- },
- ];
- return value;
- },
- },
- _actionPriorityOverrides: {
- type: Array,
- value() { return []; },
- },
- _additionalActions: {
- type: Array,
- value() { return []; },
- },
- _hiddenActions: {
- type: Array,
- value() { return []; },
- },
- _disabledMenuActions: {
- type: Array,
- value() { return []; },
- },
- // editPatchsetLoaded == "does the current selected patch range have
- // 'edit' as one of either basePatchNum or patchNum".
- editPatchsetLoaded: {
- type: Boolean,
- value: false,
- },
- // editMode == "is edit mode enabled in the file list".
- editMode: {
- type: Boolean,
- value: false,
- },
- editBasedOnCurrentPatchSet: {
- type: Boolean,
- value: true,
- },
- _config: Object,
- };
- }
-
- static get observers() {
- return [
- '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
- '_changeChanged(change)',
- '_editStatusChanged(editMode, editPatchsetLoaded, ' +
- 'editBasedOnCurrentPatchSet, disableEdit, actions.*, change.*)',
- ];
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('fullscreen-overlay-opened',
- () => this._handleHideBackgroundContent());
- this.addEventListener('fullscreen-overlay-closed',
- () => this._handleShowBackgroundContent());
- }
-
- /** @override */
- ready() {
- super.ready();
- this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
- this.$.restAPI.getConfig().then(config => {
- this._config = config;
- });
- this._handleLoadingComplete();
- }
-
- _getSubmitAction(revisionActions) {
- return this._getRevisionAction(revisionActions, 'submit', null);
- }
-
- _getRebaseAction(revisionActions) {
- return this._getRevisionAction(revisionActions, 'rebase', null);
- }
-
- _getRevisionAction(revisionActions, actionName, emptyActionValue) {
- if (!revisionActions) {
- return undefined;
- }
- if (revisionActions[actionName] === undefined) {
- // Return null to fire an event when reveisionActions was loaded
- // but doesn't contain actionName. undefined doesn't fire an event
- return emptyActionValue;
- }
- return revisionActions[actionName];
- }
-
- reload() {
- if (!this.changeNum || !this.latestPatchNum) {
- return Promise.resolve();
- }
-
- this._loading = true;
- return this._getRevisionActions()
- .then(revisionActions => {
- if (!revisionActions) { return; }
-
- this.revisionActions = revisionActions;
- this._sendShowRevisionActions({
- change: this.change,
- revisionActions,
- });
- this._handleLoadingComplete();
- })
- .catch(err => {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_REVISION_ACTIONS},
- composed: true, bubbles: true,
- }));
- this._loading = false;
- throw err;
- });
- }
-
- _handleLoadingComplete() {
- pluginLoader.awaitPluginsLoaded().then(() => this._loading = false);
- }
-
- _sendShowRevisionActions(detail) {
- this.$.jsAPI.handleEvent(
- this.$.jsAPI.EventType.SHOW_REVISION_ACTIONS,
- detail
- );
- }
-
- _changeChanged() {
- this.reload();
- }
-
- addActionButton(type, label) {
- if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
- throw Error(`Invalid action type: ${type}`);
- }
- const action = {
- enabled: true,
- label,
- __type: type,
- __key: ADDITIONAL_ACTION_KEY_PREFIX +
- Math.random().toString(36)
- .substr(2),
- };
- this.push('_additionalActions', action);
- return action.__key;
- }
-
- removeActionButton(key) {
- const idx = this._indexOfActionButtonWithKey(key);
- if (idx === -1) {
- return;
- }
- this.splice('_additionalActions', idx, 1);
- }
-
- setActionButtonProp(key, prop, value) {
- this.set([
- '_additionalActions',
- this._indexOfActionButtonWithKey(key),
- prop,
- ], value);
- }
-
- setActionOverflow(type, key, overflow) {
- if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
- throw Error(`Invalid action type given: ${type}`);
- }
- const index = this._getActionOverflowIndex(type, key);
- const action = {
- type,
- key,
- overflow,
- };
- if (!overflow && index !== -1) {
- this.splice('_overflowActions', index, 1);
- } else if (overflow) {
- this.push('_overflowActions', action);
- }
- }
-
- setActionPriority(type, key, priority) {
- if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
- throw Error(`Invalid action type given: ${type}`);
- }
- const index = this._actionPriorityOverrides
- .findIndex(action => action.type === type && action.key === key);
- const action = {
- type,
- key,
- priority,
- };
- if (index !== -1) {
- this.set('_actionPriorityOverrides', index, action);
- } else {
- this.push('_actionPriorityOverrides', action);
- }
- }
-
- setActionHidden(type, key, hidden) {
- if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
- throw Error(`Invalid action type given: ${type}`);
- }
-
- const idx = this._hiddenActions.indexOf(key);
- if (hidden && idx === -1) {
- this.push('_hiddenActions', key);
- } else if (!hidden && idx !== -1) {
- this.splice('_hiddenActions', idx, 1);
- }
- }
-
- getActionDetails(action) {
- if (this.revisionActions[action]) {
- return this.revisionActions[action];
- } else if (this.actions[action]) {
- return this.actions[action];
- }
- }
-
- _indexOfActionButtonWithKey(key) {
- for (let i = 0; i < this._additionalActions.length; i++) {
- if (this._additionalActions[i].__key === key) {
- return i;
- }
- }
- return -1;
- }
-
- _getRevisionActions() {
- return this.$.restAPI.getChangeRevisionActions(this.changeNum,
- this.latestPatchNum);
- }
-
- _shouldHideActions(actions, loading) {
- return loading || !actions || !actions.base || !actions.base.length;
- }
-
- _keyCount(changeRecord) {
- return Object.keys((changeRecord && changeRecord.base) || {}).length;
- }
-
- _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
- additionalActionsChangeRecord) {
- // Polymer 2: check for undefined
- if ([
- actionsChangeRecord,
- revisionActionsChangeRecord,
- additionalActionsChangeRecord,
- ].includes(undefined)) {
- return;
- }
-
- const additionalActions = (additionalActionsChangeRecord &&
- additionalActionsChangeRecord.base) || [];
- this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
- this._keyCount(revisionActionsChangeRecord) === 0 &&
- additionalActions.length === 0;
- this._actionLoadingMessage = '';
- this._disabledMenuActions = [];
-
- const revisionActions = revisionActionsChangeRecord.base || {};
- if (Object.keys(revisionActions).length !== 0) {
- if (!revisionActions.download) {
- this.set('revisionActions.download', DOWNLOAD_ACTION);
- }
- }
- }
-
- /**
- * @param {string=} actionName
- */
- _deleteAndNotify(actionName) {
- if (this.actions && this.actions[actionName]) {
- delete this.actions[actionName];
- // We assign a fake value of 'false' to support Polymer 2
- // see https://github.com/Polymer/polymer/issues/2631
- this.notifyPath('actions.' + actionName, false);
- }
- }
-
- _editStatusChanged(editMode, editPatchsetLoaded,
- editBasedOnCurrentPatchSet, disableEdit) {
- // Polymer 2: check for undefined
- if ([
- editMode,
- editBasedOnCurrentPatchSet,
- disableEdit,
- ].includes(undefined)) {
- return;
- }
-
- if (disableEdit) {
- this._deleteAndNotify('publishEdit');
- this._deleteAndNotify('rebaseEdit');
- this._deleteAndNotify('deleteEdit');
- this._deleteAndNotify('stopEdit');
- this._deleteAndNotify('edit');
- return;
- }
- if (this.actions && editPatchsetLoaded) {
- // Only show actions that mutate an edit if an actual edit patch set
- // is loaded.
- if (changeIsOpen(this.change)) {
- if (editBasedOnCurrentPatchSet) {
- if (!this.actions.publishEdit) {
- this.set('actions.publishEdit', PUBLISH_EDIT);
- }
- this._deleteAndNotify('rebaseEdit');
- } else {
- if (!this.actions.rebaseEdit) {
- this.set('actions.rebaseEdit', REBASE_EDIT);
- }
- this._deleteAndNotify('publishEdit');
- }
- }
- if (!this.actions.deleteEdit) {
- this.set('actions.deleteEdit', DELETE_EDIT);
- }
- } else {
- this._deleteAndNotify('publishEdit');
- this._deleteAndNotify('rebaseEdit');
- this._deleteAndNotify('deleteEdit');
- }
-
- if (this.actions && changeIsOpen(this.change)) {
- // Only show edit button if there is no edit patchset loaded and the
- // file list is not in edit mode.
- if (editPatchsetLoaded || editMode) {
- this._deleteAndNotify('edit');
- } else {
- if (!this.actions.edit) { this.set('actions.edit', EDIT); }
- }
- // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
- // is loaded.
- if (editMode && !editPatchsetLoaded) {
- if (!this.actions.stopEdit) {
- this.set('actions.stopEdit', STOP_EDIT);
- }
- } else {
- this._deleteAndNotify('stopEdit');
- }
- } else {
- // Remove edit button.
- this._deleteAndNotify('edit');
- }
- }
-
- _getValuesFor(obj) {
- return Object.keys(obj).map(key => obj[key]);
- }
-
- _getLabelStatus(label) {
- if (label.approved) {
- return LabelStatus.OK;
- } else if (label.rejected) {
- return LabelStatus.REJECT;
- } else if (label.optional) {
- return LabelStatus.OPTIONAL;
- } else {
- return LabelStatus.NEED;
- }
- }
-
- /**
- * Get highest score for last missing permitted label for current change.
- * Returns null if no labels permitted or more than one label missing.
- *
- * @return {{label: string, score: string}|null}
- */
- _getTopMissingApproval() {
- if (!this.change ||
- !this.change.labels ||
- !this.change.permitted_labels) {
- return null;
- }
- let result;
- for (const label in this.change.labels) {
- if (!(label in this.change.permitted_labels)) {
- continue;
- }
- if (this.change.permitted_labels[label].length === 0) {
- continue;
- }
- const status = this._getLabelStatus(this.change.labels[label]);
- if (status === LabelStatus.NEED) {
- if (result) {
- // More than one label is missing, so it's unclear which to quick
- // approve, return null;
- return null;
- }
- result = label;
- } else if (status === LabelStatus.REJECT ||
- status === LabelStatus.IMPOSSIBLE) {
- return null;
- }
- }
- if (result) {
- const score = this.change.permitted_labels[result].slice(-1)[0];
- const maxScore =
- Object.keys(this.change.labels[result].values).slice(-1)[0];
- if (score === maxScore) {
- // Allow quick approve only for maximal score.
- return {
- label: result,
- score,
- };
- }
- }
- return null;
- }
-
- hideQuickApproveAction() {
- this._topLevelSecondaryActions =
- this._topLevelSecondaryActions
- .filter(sa => sa.key !== QUICK_APPROVE_ACTION.key);
- this._hideQuickApproveAction = true;
- }
-
- _getQuickApproveAction() {
- if (this._hideQuickApproveAction) {
- return null;
- }
- const approval = this._getTopMissingApproval();
- if (!approval) {
- return null;
- }
- const action = Object.assign({}, QUICK_APPROVE_ACTION);
- action.label = approval.label + approval.score;
- const review = {
- drafts: 'PUBLISH_ALL_REVISIONS',
- labels: {},
- };
- review.labels[approval.label] = approval.score;
- action.payload = review;
- return action;
- }
-
- _getActionValues(actionsChangeRecord, primariesChangeRecord,
- additionalActionsChangeRecord, type) {
- if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
-
- const actions = actionsChangeRecord.base || {};
- const primaryActionKeys = primariesChangeRecord.base || [];
- const result = [];
- const values = this._getValuesFor(
- type === ActionType.CHANGE ? ChangeActions : RevisionActions);
- const pluginActions = [];
- Object.keys(actions).forEach(a => {
- actions[a].__key = a;
- actions[a].__type = type;
- actions[a].__primary = primaryActionKeys.includes(a);
- // Plugin actions always contain ~ in the key.
- if (a.indexOf('~') !== -1) {
- this._populateActionUrl(actions[a]);
- pluginActions.push(actions[a]);
- // Add server-side provided plugin actions to overflow menu.
- this._overflowActions.push({
- type,
- key: a,
- });
- return;
- } else if (!values.includes(a)) {
- return;
- }
- actions[a].label = this._getActionLabel(actions[a]);
-
- // Triggers a re-render by ensuring object inequality.
- result.push(Object.assign({}, actions[a]));
- });
-
- let additionalActions = (additionalActionsChangeRecord &&
- additionalActionsChangeRecord.base) || [];
- additionalActions = additionalActions
- .filter(a => a.__type === type)
- .map(a => {
- a.__primary = primaryActionKeys.includes(a.__key);
- // Triggers a re-render by ensuring object inequality.
- return Object.assign({}, a);
- });
- return result.concat(additionalActions).concat(pluginActions);
- }
-
- _populateActionUrl(action) {
- const patchNum =
- action.__type === ActionType.REVISION ? this.latestPatchNum : null;
- this.$.restAPI.getChangeActionURL(
- this.changeNum, patchNum, '/' + action.__key)
- .then(url => action.__url = url);
- }
-
- /**
- * Given a change action, return a display label that uses the appropriate
- * casing or includes explanatory details.
- */
- _getActionLabel(action) {
- if (action.label === 'Delete') {
- // This label is common within change and revision actions. Make it more
- // explicit to the user.
- return 'Delete change';
- } else if (action.label === 'WIP') {
- return 'Mark as work in progress';
- }
- // Otherwise, just map the name to sentence case.
- return this._toSentenceCase(action.label);
- }
-
- /**
- * Capitalize the first letter and lowecase all others.
- *
- * @param {string} s
- * @return {string}
- */
- _toSentenceCase(s) {
- if (!s.length) { return ''; }
- return s[0].toUpperCase() + s.slice(1).toLowerCase();
- }
-
- _computeLoadingLabel(action) {
- return ActionLoadingLabels[action] || 'Working...';
- }
-
- _canSubmitChange() {
- return this.$.jsAPI.canSubmitChange(this.change,
- this._getRevision(this.change, this.latestPatchNum));
- }
-
- _getRevision(change, patchNum) {
- for (const rev of Object.values(change.revisions)) {
- if (patchNumEquals(rev._number, patchNum)) {
- return rev;
- }
- }
- return null;
- }
-
- showRevertDialog() {
- // The search is still broken if there is a " in the topic.
- const query = `submissionid: "${this.change.submission_id}"`;
- /* A chromium plugin expects that the modifyRevertMsg hook will only
- be called after the revert button is pressed, hence we populate the
- revert dialog after revert button is pressed. */
- this.$.restAPI.getChanges('', query)
- .then(changes => {
- this.$.confirmRevertDialog.populate(this.change,
- this.commitMessage, changes);
- this._showActionDialog(this.$.confirmRevertDialog);
- });
- }
-
- showRevertSubmissionDialog() {
- const query = 'submissionid:' + this.change.submission_id;
- this.$.restAPI.getChanges('', query)
- .then(changes => {
- this.$.confirmRevertSubmissionDialog.
- _populateRevertSubmissionMessage(
- this.commitMessage, this.change, changes);
- this._showActionDialog(this.$.confirmRevertSubmissionDialog);
- });
- }
-
- _handleActionTap(e) {
- e.preventDefault();
- let el = dom(e).localTarget;
- while (el.tagName.toLowerCase() !== 'gr-button') {
- if (!el.parentElement) { return; }
- el = el.parentElement;
- }
-
- const key = el.getAttribute('data-action-key');
- if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
- key.indexOf('~') !== -1) {
- this.dispatchEvent(new CustomEvent(`${key}-tap`, {
- detail: {node: el},
- composed: true, bubbles: true,
- }));
- return;
- }
- const type = el.getAttribute('data-action-type');
- this._handleAction(type, key);
- }
-
- _handleOverflowItemTap(e) {
- e.preventDefault();
- const el = dom(e).localTarget;
- const key = e.detail.action.__key;
- if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
- key.indexOf('~') !== -1) {
- this.dispatchEvent(new CustomEvent(`${key}-tap`, {
- detail: {node: el},
- composed: true, bubbles: true,
- }));
- return;
- }
- this._handleAction(e.detail.action.__type, e.detail.action.__key);
- }
-
- _handleAction(type, key) {
- this.reporting.reportInteraction(`${type}-${key}`);
- switch (type) {
- case ActionType.REVISION:
- this._handleRevisionAction(key);
- break;
- case ActionType.CHANGE:
- this._handleChangeAction(key);
- break;
- default:
- this._fireAction(this._prependSlash(key), this.actions[key], false);
- }
- }
-
- _handleChangeAction(key) {
- let action;
- switch (key) {
- case ChangeActions.REVERT:
- this.showRevertDialog();
- break;
- case ChangeActions.REVERT_SUBMISSION:
- this.showRevertSubmissionDialog();
- break;
- case ChangeActions.ABANDON:
- this._showActionDialog(this.$.confirmAbandonDialog);
- break;
- case QUICK_APPROVE_ACTION.key:
- action = this._allActionValues.find(o => o.key === key);
- this._fireAction(
- this._prependSlash(key), action, true, action.payload);
- break;
- case ChangeActions.EDIT:
- this._handleEditTap();
- break;
- case ChangeActions.STOP_EDIT:
- this._handleStopEditTap();
- break;
- case ChangeActions.DELETE:
- this._handleDeleteTap();
- break;
- case ChangeActions.DELETE_EDIT:
- this._handleDeleteEditTap();
- break;
- case ChangeActions.FOLLOW_UP:
- this._handleFollowUpTap();
- break;
- case ChangeActions.WIP:
- this._handleWipTap();
- break;
- case ChangeActions.MOVE:
- this._handleMoveTap();
- break;
- case ChangeActions.PUBLISH_EDIT:
- this._handlePublishEditTap();
- break;
- case ChangeActions.REBASE_EDIT:
- this._handleRebaseEditTap();
- break;
- default:
- this._fireAction(this._prependSlash(key), this.actions[key], false);
- }
- }
-
- _handleRevisionAction(key) {
- switch (key) {
- case RevisionActions.REBASE:
- this._showActionDialog(this.$.confirmRebase);
- this.$.confirmRebase.fetchRecentChanges();
- break;
- case RevisionActions.CHERRYPICK:
- this._handleCherrypickTap();
- break;
- case RevisionActions.DOWNLOAD:
- this._handleDownloadTap();
- break;
- case RevisionActions.SUBMIT:
- if (!this._canSubmitChange()) { return; }
- this._showActionDialog(this.$.confirmSubmitDialog);
- break;
- default:
- this._fireAction(this._prependSlash(key),
- this.revisionActions[key], true);
- }
- }
-
- _prependSlash(key) {
- return key === '/' ? key : `/${key}`;
- }
-
- /**
- * _hasKnownChainState set to true true if hasParent is defined (can be
- * either true or false). set to false otherwise.
- */
- _computeChainState(hasParent) {
- this._hasKnownChainState = true;
- }
-
- _calculateDisabled(action, hasKnownChainState) {
- if (action.__key === 'rebase') {
- // Rebase button is only disabled when change has no parent(s).
- return hasKnownChainState === false;
- }
- return !action.enabled;
- }
-
- _handleConfirmDialogCancel() {
- this._hideAllDialogs();
- }
-
- _hideAllDialogs() {
- const dialogEls =
- dom(this.root).querySelectorAll('.confirmDialog');
- for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
- this.$.overlay.close();
- }
-
- _handleRebaseConfirm(e) {
- const el = this.$.confirmRebase;
- const payload = {base: e.detail.base};
- this.$.overlay.close();
- el.hidden = true;
- this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
- }
-
- _handleCherrypickConfirm() {
- this._handleCherryPickRestApi(false);
- }
-
- _handleCherrypickConflictConfirm() {
- this._handleCherryPickRestApi(true);
- }
-
- _handleCherryPickRestApi(conflicts) {
- const el = this.$.confirmCherrypick;
- if (!el.branch) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_BRANCH_EMPTY},
- composed: true, bubbles: true,
- }));
- return;
- }
- if (!el.message) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_COMMIT_EMPTY},
- composed: true, bubbles: true,
- }));
- return;
- }
- this.$.overlay.close();
- el.hidden = true;
- this._fireAction(
- '/cherrypick',
- this.revisionActions.cherrypick,
- true,
- {
- destination: el.branch,
- base: el.baseCommit ? el.baseCommit : null,
- message: el.message,
- allow_conflicts: conflicts,
- }
- );
- }
-
- _handleMoveConfirm() {
- const el = this.$.confirmMove;
- if (!el.branch) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_BRANCH_EMPTY},
- composed: true, bubbles: true,
- }));
- return;
- }
- this.$.overlay.close();
- el.hidden = true;
- this._fireAction(
- '/move',
- this.actions.move,
- false,
- {
- destination_branch: el.branch,
- message: el.message,
- }
- );
- }
-
- _handleRevertDialogConfirm(e) {
- const revertType = e.detail.revertType;
- const message = e.detail.message;
- const el = this.$.confirmRevertDialog;
- this.$.overlay.close();
- el.hidden = true;
- switch (revertType) {
- case REVERT_TYPES.REVERT_SINGLE_CHANGE:
- this._fireAction('/revert', this.actions.revert, false,
- {message});
- break;
- case REVERT_TYPES.REVERT_SUBMISSION:
- this._fireAction('/revert_submission', this.actions.revert_submission,
- false, {message});
- break;
- default:
- console.error('invalid revert type');
- }
- }
-
- _handleRevertSubmissionDialogConfirm() {
- const el = this.$.confirmRevertSubmissionDialog;
- this.$.overlay.close();
- el.hidden = true;
- this._fireAction('/revert_submission', this.actions.revert_submission,
- false, {message: el.message});
- }
-
- _handleAbandonDialogConfirm() {
- const el = this.$.confirmAbandonDialog;
- this.$.overlay.close();
- el.hidden = true;
- this._fireAction('/abandon', this.actions.abandon, false,
- {message: el.message});
- }
-
- _handleCreateFollowUpChange() {
- this.$.createFollowUpChange.handleCreateChange();
- this._handleCloseCreateFollowUpChange();
- }
-
- _handleCloseCreateFollowUpChange() {
- this.$.overlay.close();
- }
-
- _handleDeleteConfirm() {
- this._fireAction('/', this.actions[ChangeActions.DELETE], false);
- }
-
- _handleDeleteEditConfirm() {
- this._hideAllDialogs();
-
- this._fireAction('/edit', this.actions.deleteEdit, false);
- }
-
- _handleSubmitConfirm() {
- if (!this._canSubmitChange()) { return; }
- this._hideAllDialogs();
- this._fireAction('/submit', this.revisionActions.submit, true);
- }
-
- _getActionOverflowIndex(type, key) {
- return this._overflowActions
- .findIndex(action => action.type === type && action.key === key);
- }
-
- _setLoadingOnButtonWithKey(type, key) {
- this._actionLoadingMessage = this._computeLoadingLabel(key);
- let buttonKey = key;
- // TODO(dhruvsri): clean this up later
- // If key is revert-submission, then button key should be 'revert'
- if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
- // Revert submission button no longer exists
- buttonKey = ChangeActions.REVERT;
- }
-
- // If the action appears in the overflow menu.
- if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
- this.push('_disabledMenuActions', buttonKey === '/' ? 'delete' :
- buttonKey);
- return function() {
- this._actionLoadingMessage = '';
- this._disabledMenuActions = [];
- }.bind(this);
- }
-
- // Otherwise it's a top-level action.
- const buttonEl = this.shadowRoot
- .querySelector(`[data-action-key="${buttonKey}"]`);
- buttonEl.setAttribute('loading', true);
- buttonEl.disabled = true;
- return function() {
- this._actionLoadingMessage = '';
- buttonEl.removeAttribute('loading');
- buttonEl.disabled = false;
- }.bind(this);
- }
-
- /**
- * @param {string} endpoint
- * @param {!Object|undefined} action
- * @param {boolean} revAction
- * @param {!Object|string=} opt_payload
- */
- _fireAction(endpoint, action, revAction, opt_payload) {
- const cleanupFn =
- this._setLoadingOnButtonWithKey(action.__type, action.__key);
-
- this._send(action.method, opt_payload, endpoint, revAction, cleanupFn,
- action).then(this._handleResponse.bind(this, action));
- }
-
- _showActionDialog(dialog) {
- this._hideAllDialogs();
-
- dialog.hidden = false;
- this.$.overlay.open().then(() => {
- if (dialog.resetFocus) {
- dialog.resetFocus();
- }
- });
- }
-
- // TODO(rmistry): Redo this after
- // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
- _setLabelValuesOnRevert(newChangeId) {
- const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
- if (!labels) { return Promise.resolve(); }
- return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
- }
-
- _handleResponse(action, response) {
- if (!response) { return; }
- return this.$.restAPI.getResponseObject(response).then(obj => {
- switch (action.__key) {
- case ChangeActions.REVERT:
- this._waitForChangeReachable(obj._number)
- .then(() => this._setLabelValuesOnRevert(obj._number))
- .then(() => {
- GerritNav.navigateToChange(obj);
- });
- break;
- case RevisionActions.CHERRYPICK:
- this._waitForChangeReachable(obj._number).then(() => {
- GerritNav.navigateToChange(obj);
- });
- break;
- case ChangeActions.DELETE:
- if (action.__type === ActionType.CHANGE) {
- GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
- }
- break;
- case ChangeActions.WIP:
- case ChangeActions.DELETE_EDIT:
- case ChangeActions.PUBLISH_EDIT:
- case ChangeActions.REBASE_EDIT:
- case ChangeActions.REBASE:
- case ChangeActions.SUBMIT:
- GerritNav.navigateToChange(this.change);
- break;
- case ChangeActions.REVERT_SUBMISSION:
- if (!obj.revert_changes || !obj.revert_changes.length) return;
- /* If there is only 1 change then gerrit will automatically
- redirect to that change */
- GerritNav.navigateToSearchQuery('topic: ' +
- obj.revert_changes[0].topic);
- break;
- default:
- this.dispatchEvent(new CustomEvent('reload-change',
- {detail: {action: action.__key}, bubbles: false}));
- break;
- }
- });
- }
-
- _handleShowRevertSubmissionChangesConfirm() {
- this._hideAllDialogs();
- }
-
- _handleResponseError(action, response, body) {
- if (action && action.__key === RevisionActions.CHERRYPICK) {
- if (response && response.status === 409 &&
- body && !body.allow_conflicts) {
- return this._showActionDialog(
- this.$.confirmCherrypickConflict);
- }
- }
- return response.text().then(errText => {
- this.dispatchEvent(new CustomEvent('show-error', {
- detail: {message: `Could not perform action: ${errText}`},
- composed: true, bubbles: true,
- }));
- if (!errText.startsWith('Change is already up to date')) {
- throw Error(errText);
- }
- });
- }
-
- /**
- * @param {string} method
- * @param {string|!Object|undefined} payload
- * @param {string} actionEndpoint
- * @param {boolean} revisionAction
- * @param {?Function} cleanupFn
- * @param {!Object|undefined} action
- */
- _send(method, payload, actionEndpoint, revisionAction, cleanupFn, action) {
- const handleError = response => {
- cleanupFn.call(this);
- this._handleResponseError(action, response, payload);
- };
- return fetchChangeUpdates(this.change, this.$.restAPI)
- .then(result => {
- if (!result.isLatest) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Cannot set label: a newer patch has been ' +
- 'uploaded to this change.',
- action: 'Reload',
- callback: () => {
- // Load the current change without any patch range.
- GerritNav.navigateToChange(this.change);
- },
- },
- composed: true, bubbles: true,
- }));
-
- // Because this is not a network error, call the cleanup function
- // but not the error handler.
- cleanupFn();
-
- return Promise.resolve();
- }
- const patchNum = revisionAction ? this.latestPatchNum : null;
- return this.$.restAPI.executeChangeAction(this.changeNum, method,
- actionEndpoint, patchNum, payload, handleError)
- .then(response => {
- cleanupFn.call(this);
- return response;
- });
- });
- }
-
- _handleAbandonTap() {
- this._showActionDialog(this.$.confirmAbandonDialog);
- }
-
- _handleCherrypickTap() {
- this.$.confirmCherrypick.branch = '';
- const query = `topic: "${this.change.topic}"`;
- const options =
- listChangesOptionsToHex(ListChangesOption.MESSAGES,
- ListChangesOption.ALL_REVISIONS);
- this.$.restAPI.getChanges('', query, undefined, options)
- .then(changes => {
- this.$.confirmCherrypick.updateChanges(changes);
- this._showActionDialog(this.$.confirmCherrypick);
- });
- }
-
- _handleMoveTap() {
- this.$.confirmMove.branch = '';
- this.$.confirmMove.message = '';
- this._showActionDialog(this.$.confirmMove);
- }
-
- _handleDownloadTap() {
- this.dispatchEvent(new CustomEvent('download-tap', {
- composed: true, bubbles: false,
- }));
- }
-
- _handleDeleteTap() {
- this._showActionDialog(this.$.confirmDeleteDialog);
- }
-
- _handleDeleteEditTap() {
- this._showActionDialog(this.$.confirmDeleteEditDialog);
- }
-
- _handleFollowUpTap() {
- this._showActionDialog(this.$.createFollowUpDialog);
- }
-
- _handleWipTap() {
- this._fireAction('/wip', this.actions.wip, false);
- }
-
- _handlePublishEditTap() {
- this._fireAction('/edit:publish', this.actions.publishEdit, false);
- }
-
- _handleRebaseEditTap() {
- this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
- }
-
- _handleHideBackgroundContent() {
- this.$.mainContent.classList.add('overlayOpen');
- }
-
- _handleShowBackgroundContent() {
- this.$.mainContent.classList.remove('overlayOpen');
- }
-
- /**
- * Merge sources of change actions into a single ordered array of action
- * values.
- *
- * @param {!Array} changeActionsRecord
- * @param {!Array} revisionActionsRecord
- * @param {!Array} primariesRecord
- * @param {!Array} additionalActionsRecord
- * @param {!Object} change The change object.
- * @param {!Object} config server configuration info
- * @return {!Array}
- */
- _computeAllActions(changeActionsRecord, revisionActionsRecord,
- primariesRecord, additionalActionsRecord, change, config) {
- // Polymer 2: check for undefined
- if ([
- changeActionsRecord,
- revisionActionsRecord,
- primariesRecord,
- additionalActionsRecord,
- change,
- ].includes(undefined)) {
- return [];
- }
-
- const revisionActionValues = this._getActionValues(revisionActionsRecord,
- primariesRecord, additionalActionsRecord, ActionType.REVISION);
- const changeActionValues = this._getActionValues(changeActionsRecord,
- primariesRecord, additionalActionsRecord, ActionType.CHANGE);
- const quickApprove = this._getQuickApproveAction();
- if (quickApprove) {
- changeActionValues.unshift(quickApprove);
- }
-
- return revisionActionValues
- .concat(changeActionValues)
- .sort(this._actionComparator.bind(this))
- .map(action => {
- if (ACTIONS_WITH_ICONS.has(action.__key)) {
- action.icon = action.__key;
- }
- // TODO(brohlfs): Temporary hack until change 269573 is live in all
- // backends.
- if (action.__key === ChangeActions.READY) {
- action.label = 'Mark as Active';
- }
- // End of hack
- return action;
- })
- .filter(action => !this._shouldSkipAction(action, config));
- }
-
- _getActionPriority(action) {
- if (action.__type && action.__key) {
- const overrideAction = this._actionPriorityOverrides
- .find(i => i.type === action.__type && i.key === action.__key);
-
- if (overrideAction !== undefined) {
- return overrideAction.priority;
- }
- }
- if (action.__key === 'review') {
- return ActionPriority.REVIEW;
- } else if (action.__primary) {
- return ActionPriority.PRIMARY;
- } else if (action.__type === ActionType.CHANGE) {
- return ActionPriority.CHANGE;
- } else if (action.__type === ActionType.REVISION) {
- return ActionPriority.REVISION;
- }
- return ActionPriority.DEFAULT;
- }
-
- /**
- * Sort comparator to define the order of change actions.
- */
- _actionComparator(actionA, actionB) {
- const priorityDelta = this._getActionPriority(actionA) -
- this._getActionPriority(actionB);
- // Sort by the button label if same priority.
- if (priorityDelta === 0) {
- return actionA.label > actionB.label ? 1 : -1;
- } else {
- return priorityDelta;
- }
- }
-
- _shouldSkipAction(action, config) {
- const skipActionKeys = [...SKIP_ACTION_KEYS];
- const isAttentionSetEnabled = !!config && !!config.change
- && config.change.enable_attention_set;
- if (isAttentionSetEnabled) {
- skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
- }
- return skipActionKeys.includes(action.__key);
- }
-
- _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
- const hiddenActions = hiddenActionsRecord.base || [];
- return actionRecord.base.filter(a => {
- const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
- return !(overflow || hiddenActions.includes(a.__key));
- });
- }
-
- _filterPrimaryActions(_topLevelActions) {
- this._topLevelPrimaryActions = _topLevelActions.filter(action =>
- action.__primary);
- this._topLevelSecondaryActions = _topLevelActions.filter(action =>
- !action.__primary);
- }
-
- _computeMenuActions(actionRecord, hiddenActionsRecord) {
- const hiddenActions = hiddenActionsRecord.base || [];
- return actionRecord.base.filter(a => {
- const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
- return overflow && !hiddenActions.includes(a.__key);
- }).map(action => {
- let key = action.__key;
- if (key === '/') { key = 'delete'; }
- return {
- name: action.label,
- id: `${key}-${action.__type}`,
- action,
- tooltip: action.title,
- };
- });
- }
-
- _computeRebaseOnCurrent(revisionRebaseAction) {
- if (revisionRebaseAction) {
- return !!revisionRebaseAction.enabled;
- }
- return null;
- }
-
- /**
- * Occasionally, a change created by a change action is not yet knwon to the
- * API for a brief time. Wait for the given change number to be recognized.
- *
- * Returns a promise that resolves with true if a request is recognized, or
- * false if the change was never recognized after all attempts.
- *
- * @param {number} changeNum
- * @return {Promise<boolean>}
- */
- _waitForChangeReachable(changeNum) {
- let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
- return new Promise(resolve => {
- const check = () => {
- attempsRemaining--;
- // Pass a no-op error handler to avoid the "not found" error toast.
- this.$.restAPI.getChange(changeNum, () => {}).then(response => {
- // If the response is 404, the response will be undefined.
- if (response) {
- resolve(true);
- return;
- }
-
- if (attempsRemaining) {
- this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
- } else {
- resolve(false);
- }
- });
- };
- check();
- });
- }
-
- _handleEditTap() {
- this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
- }
-
- _handleStopEditTap() {
- this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
- }
-
- _computeHasTooltip(title) {
- return !!title;
- }
-
- _computeHasIcon(action) {
- return action.icon ? '' : 'hidden';
- }
-}
-
-customElements.define(GrChangeActions.is, GrChangeActions);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
new file mode 100644
index 0000000..3f6dd23
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -0,0 +1,2102 @@
+/**
+ * @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 '../../admin/gr-create-change-dialog/gr-create-change-dialog';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
+import '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
+import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-actions_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {
+ fetchChangeUpdates,
+ patchNumEquals,
+} from '../../../utils/patch-set-util';
+import {
+ changeIsOpen,
+ ListChangesOption,
+ listChangesOptionsToHex,
+} from '../../../utils/change-util';
+import {
+ ChangeStatus,
+ DraftsAction,
+ HttpMethod,
+ NotifyType,
+} from '../../../constants/constants';
+import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {customElement, observe, property} from '@polymer/decorators';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {
+ ActionPriority,
+ ActionType,
+ ErrorCallback,
+ RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ ActionInfo,
+ ActionNameToActionInfoMap,
+ BranchName,
+ ChangeInfo,
+ ChangeViewChangeInfo,
+ CherryPickInput,
+ CommitId,
+ InheritedBooleanInfo,
+ isDetailedLabelInfo,
+ isQuickLabelInfo,
+ LabelInfo,
+ NumericChangeId,
+ PatchSetNum,
+ PropertyType,
+ RequestPayload,
+ RevertSubmissionInfo,
+ ReviewInput,
+ ServerInfo,
+} from '../../../types/common';
+import {GrConfirmAbandonDialog} from '../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
+import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
+import {GrConfirmRevertSubmissionDialog} from '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
+import {
+ ConfirmRevertEventDetail,
+ GrConfirmRevertDialog,
+ RevertType,
+} from '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
+import {GrConfirmMoveDialog} from '../gr-confirm-move-dialog/gr-confirm-move-dialog';
+import {GrConfirmCherrypickDialog} from '../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog';
+import {GrConfirmCherrypickConflictDialog} from '../gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog';
+import {
+ ConfirmRebaseEventDetail,
+ GrConfirmRebaseDialog,
+} from '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {
+ ChangeActions,
+ GrChangeActionsElement,
+ PrimaryActionKey,
+ RevisionActions,
+ UIActionInfo,
+} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+
+const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
+const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
+const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
+
+enum LabelStatus {
+ /**
+ * This label provides what is necessary for submission.
+ */
+ OK = 'OK',
+ /**
+ * This label prevents the change from being submitted.
+ */
+ REJECT = 'REJECT',
+ /**
+ * The label may be set, but it's neither necessary for submission
+ * nor does it block submission if set.
+ */
+ MAY = 'MAY',
+ /**
+ * The label is required for submission, but has not been satisfied.
+ */
+ NEED = 'NEED',
+ /**
+ * The label is required for submission, but is impossible to complete.
+ * The likely cause is access has not been granted correctly by the
+ * project owner or site administrator.
+ */
+ IMPOSSIBLE = 'IMPOSSIBLE',
+ OPTIONAL = 'OPTIONAL',
+}
+
+const ActionLoadingLabels: {[actionKey: string]: string} = {
+ abandon: 'Abandoning...',
+ cherrypick: 'Cherry-picking...',
+ delete: 'Deleting...',
+ move: 'Moving..',
+ rebase: 'Rebasing...',
+ restore: 'Restoring...',
+ revert: 'Reverting...',
+ revert_submission: 'Reverting Submission...',
+ submit: 'Submitting...',
+};
+
+const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
+
+interface QuickApproveUIActionInfo extends UIActionInfo {
+ key: string;
+ payload?: RequestPayload;
+}
+
+const QUICK_APPROVE_ACTION: QuickApproveUIActionInfo = {
+ __key: 'review',
+ __type: ActionType.CHANGE,
+ enabled: true,
+ key: 'review',
+ label: 'Quick approve',
+ method: HttpMethod.POST,
+};
+
+function isQuckApproveAction(
+ action: UIActionInfo
+): action is QuickApproveUIActionInfo {
+ return (action as QuickApproveUIActionInfo).key === QUICK_APPROVE_ACTION.key;
+}
+
+const DOWNLOAD_ACTION: UIActionInfo = {
+ enabled: true,
+ label: 'Download patch',
+ title: 'Open download dialog',
+ __key: 'download',
+ __primary: false,
+ __type: ActionType.REVISION,
+};
+
+const REBASE_EDIT: UIActionInfo = {
+ enabled: true,
+ label: 'Rebase edit',
+ title: 'Rebase change edit',
+ __key: 'rebaseEdit',
+ __primary: false,
+ __type: ActionType.CHANGE,
+ method: HttpMethod.POST,
+};
+
+const PUBLISH_EDIT: UIActionInfo = {
+ enabled: true,
+ label: 'Publish edit',
+ title: 'Publish change edit',
+ __key: 'publishEdit',
+ __primary: false,
+ __type: ActionType.CHANGE,
+ method: HttpMethod.POST,
+};
+
+const DELETE_EDIT: UIActionInfo = {
+ enabled: true,
+ label: 'Delete edit',
+ title: 'Delete change edit',
+ __key: 'deleteEdit',
+ __primary: false,
+ __type: ActionType.CHANGE,
+ method: HttpMethod.DELETE,
+};
+
+const EDIT: UIActionInfo = {
+ enabled: true,
+ label: 'Edit',
+ title: 'Edit this change',
+ __key: 'edit',
+ __primary: false,
+ __type: ActionType.CHANGE,
+};
+
+const STOP_EDIT: UIActionInfo = {
+ enabled: true,
+ label: 'Stop editing',
+ title: 'Stop editing this change',
+ __key: 'stopEdit',
+ __primary: false,
+ __type: ActionType.CHANGE,
+};
+
+// Set of keys that have icons. As more icons are added to gr-icons.html, this
+// set should be expanded.
+const ACTIONS_WITH_ICONS = new Set([
+ ChangeActions.ABANDON,
+ ChangeActions.DELETE_EDIT,
+ ChangeActions.EDIT,
+ ChangeActions.PUBLISH_EDIT,
+ ChangeActions.READY,
+ ChangeActions.REBASE_EDIT,
+ ChangeActions.RESTORE,
+ ChangeActions.REVERT,
+ ChangeActions.REVERT_SUBMISSION,
+ ChangeActions.STOP_EDIT,
+ QUICK_APPROVE_ACTION.key,
+ RevisionActions.REBASE,
+ RevisionActions.SUBMIT,
+]);
+
+const EDIT_ACTIONS: Set<string> = new Set([
+ ChangeActions.DELETE_EDIT,
+ ChangeActions.EDIT,
+ ChangeActions.PUBLISH_EDIT,
+ ChangeActions.REBASE_EDIT,
+ ChangeActions.STOP_EDIT,
+]);
+
+const AWAIT_CHANGE_ATTEMPTS = 5;
+const AWAIT_CHANGE_TIMEOUT_MS = 1000;
+
+/* Revert submission is skipped as the normal revert dialog will now show
+the user a choice between reverting single change or an entire submission.
+Hence, a second button is not needed.
+*/
+const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
+
+const SKIP_ACTION_KEYS_ATTENTION_SET = [
+ ChangeActions.REVIEWED,
+ ChangeActions.UNREVIEWED,
+];
+
+function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
+ // TODO(TS): Remove this function. The gr-change-actions adds properties
+ // to existing ActionInfo objects instead of creating a new objects. This
+ // function checks, that 'action' has all property required by UIActionInfo.
+ // In the future, we should avoid updates of an existing ActionInfos and
+ // instead create a new object to make code cleaner. However, at the current
+ // state this is unsafe, because other code can expect these properties to be
+ // set in ActionInfo.
+ if (!action) {
+ throw new Error('action is undefined');
+ }
+ const result = action as UIActionInfo;
+ if (result.__key === undefined || result.__type === undefined) {
+ throw new Error('action is not an UIActionInfo');
+ }
+ return result;
+}
+
+interface MenuAction {
+ name: string;
+ id: string;
+ action: UIActionInfo;
+ tooltip?: string;
+}
+
+interface OverflowAction {
+ type: ActionType;
+ key: string;
+ overflow?: boolean;
+}
+
+interface ActionPriorityOverride {
+ type: ActionType.CHANGE | ActionType.REVISION;
+ key: string;
+ priority: ActionPriority;
+}
+
+interface ChangeActionDialog extends HTMLElement {
+ resetFocus?(): void;
+}
+
+export interface GrChangeActions {
+ $: {
+ jsAPI: GrJsApiInterface;
+ restAPI: RestApiService & Element;
+ mainContent: Element;
+ overlay: GrOverlay;
+ confirmRebase: GrConfirmRebaseDialog;
+ confirmCherrypick: GrConfirmCherrypickDialog;
+ confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
+ confirmMove: GrConfirmMoveDialog;
+ confirmRevertDialog: GrConfirmRevertDialog;
+ confirmRevertSubmissionDialog: GrConfirmRevertSubmissionDialog;
+ confirmAbandonDialog: GrConfirmAbandonDialog;
+ confirmSubmitDialog: GrConfirmSubmitDialog;
+ createFollowUpDialog: GrDialog;
+ createFollowUpChange: GrCreateChangeDialog;
+ confirmDeleteDialog: GrDialog;
+ confirmDeleteEditDialog: GrDialog;
+ };
+}
+
+@customElement('gr-change-actions')
+export class GrChangeActions
+ extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+ implements GrChangeActionsElement {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the change should be reloaded.
+ *
+ * @event reload
+ */
+
+ /**
+ * Fired when an action is tapped.
+ *
+ * @event custom-tap - naming pattern: <action key>-tap
+ */
+
+ /**
+ * Fires to show an alert when a send is attempted on the non-latest patch.
+ *
+ * @event show-alert
+ */
+
+ /**
+ * Fires when a change action fails.
+ *
+ * @event show-error
+ */
+
+ // TODO(TS): Ensure that ActionType, ChangeActions and RevisionActions
+ // properties are replaced with enums everywhere and remove them from
+ // the GrChangeActions class
+ ActionType = ActionType;
+
+ ChangeActions = ChangeActions;
+
+ RevisionActions = RevisionActions;
+
+ reporting = appContext.reportingService;
+
+ @property({type: Object})
+ change?: ChangeViewChangeInfo;
+
+ @property({type: Object})
+ actions: ActionNameToActionInfoMap = {};
+
+ @property({type: Array})
+ primaryActionKeys: PrimaryActionKey[] = [
+ ChangeActions.READY,
+ RevisionActions.SUBMIT,
+ ];
+
+ @property({type: Boolean})
+ disableEdit = false;
+
+ @property({type: Boolean})
+ _hasKnownChainState = false;
+
+ @property({type: Boolean})
+ _hideQuickApproveAction = false;
+
+ @property({type: String})
+ changeNum?: NumericChangeId;
+
+ @property({type: String})
+ changeStatus?: ChangeStatus;
+
+ @property({type: String})
+ commitNum?: CommitId;
+
+ @property({type: Boolean, observer: '_computeChainState'})
+ hasParent?: boolean;
+
+ @property({type: String})
+ latestPatchNum?: PatchSetNum;
+
+ @property({type: String})
+ commitMessage = '';
+
+ @property({type: Object, notify: true})
+ revisionActions: ActionNameToActionInfoMap = {};
+
+ @property({type: Object, computed: '_getSubmitAction(revisionActions)'})
+ _revisionSubmitAction?: ActionInfo | null;
+
+ @property({type: Object, computed: '_getRebaseAction(revisionActions)'})
+ _revisionRebaseAction?: ActionInfo | null;
+
+ @property({type: String})
+ privateByDefault?: InheritedBooleanInfo;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _actionLoadingMessage = '';
+
+ @property({
+ type: Array,
+ computed:
+ '_computeAllActions(actions.*, revisionActions.*,' +
+ 'primaryActionKeys.*, _additionalActions.*, change, ' +
+ '_config, _actionPriorityOverrides.*)',
+ })
+ _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
+
+ @property({
+ type: Array,
+ computed:
+ '_computeTopLevelActions(_allActionValues.*, ' +
+ '_hiddenActions.*, editMode, _overflowActions.*)',
+ observer: '_filterPrimaryActions',
+ })
+ _topLevelActions?: UIActionInfo[];
+
+ @property({type: Array})
+ _topLevelPrimaryActions?: UIActionInfo[];
+
+ @property({type: Array})
+ _topLevelSecondaryActions?: UIActionInfo[];
+
+ @property({
+ type: Array,
+ computed:
+ '_computeMenuActions(_allActionValues.*, ' +
+ '_hiddenActions.*, _overflowActions.*)',
+ })
+ _menuActions?: MenuAction[];
+
+ @property({type: Array})
+ _overflowActions: OverflowAction[] = [
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.WIP,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.DELETE,
+ },
+ {
+ type: ActionType.REVISION,
+ key: RevisionActions.CHERRYPICK,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.MOVE,
+ },
+ {
+ type: ActionType.REVISION,
+ key: RevisionActions.DOWNLOAD,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.IGNORE,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.UNIGNORE,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.REVIEWED,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.UNREVIEWED,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.PRIVATE,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.PRIVATE_DELETE,
+ },
+ {
+ type: ActionType.CHANGE,
+ key: ChangeActions.FOLLOW_UP,
+ },
+ ];
+
+ @property({type: Array})
+ _actionPriorityOverrides: ActionPriorityOverride[] = [];
+
+ @property({type: Array})
+ _additionalActions: UIActionInfo[] = [];
+
+ @property({type: Array})
+ _hiddenActions: string[] = [];
+
+ @property({type: Array})
+ _disabledMenuActions: string[] = [];
+
+ @property({type: Boolean})
+ editPatchsetLoaded = false;
+
+ @property({type: Boolean})
+ editMode = false;
+
+ @property({type: Boolean})
+ editBasedOnCurrentPatchSet = true;
+
+ @property({type: Object})
+ _config?: ServerInfo;
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('fullscreen-overlay-opened', () =>
+ this._handleHideBackgroundContent()
+ );
+ this.addEventListener('fullscreen-overlay-closed', () =>
+ this._handleShowBackgroundContent()
+ );
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this.$.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
+ this.$.restAPI.getConfig().then(config => {
+ this._config = config;
+ });
+ this._handleLoadingComplete();
+ }
+
+ _getSubmitAction(revisionActions: ActionNameToActionInfoMap) {
+ return this._getRevisionAction(revisionActions, 'submit');
+ }
+
+ _getRebaseAction(revisionActions: ActionNameToActionInfoMap) {
+ return this._getRevisionAction(revisionActions, 'rebase');
+ }
+
+ _getRevisionAction(
+ revisionActions: ActionNameToActionInfoMap,
+ actionName: string
+ ) {
+ if (!revisionActions) {
+ return undefined;
+ }
+ if (revisionActions[actionName] === undefined) {
+ // Return null to fire an event when reveisionActions was loaded
+ // but doesn't contain actionName. undefined doesn't fire an event
+ return null;
+ }
+ return revisionActions[actionName];
+ }
+
+ reload() {
+ if (!this.changeNum || !this.latestPatchNum || !this.change) {
+ return Promise.resolve();
+ }
+ const change = this.change;
+
+ this._loading = true;
+ return this.$.restAPI
+ .getChangeRevisionActions(this.changeNum, this.latestPatchNum)
+ .then(revisionActions => {
+ if (!revisionActions) {
+ return;
+ }
+
+ this.revisionActions = revisionActions;
+ this._sendShowRevisionActions({
+ change,
+ revisionActions,
+ });
+ this._handleLoadingComplete();
+ })
+ .catch(err => {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_REVISION_ACTIONS},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._loading = false;
+ throw err;
+ });
+ }
+
+ _handleLoadingComplete() {
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => (this._loading = false));
+ }
+
+ _sendShowRevisionActions(detail: {
+ change: ChangeInfo;
+ revisionActions: ActionNameToActionInfoMap;
+ }) {
+ this.$.jsAPI.handleEvent(EventType.SHOW_REVISION_ACTIONS, detail);
+ }
+
+ @observe('change')
+ _changeChanged() {
+ this.reload();
+ }
+
+ addActionButton(type: ActionType, label: string) {
+ if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+ throw Error(`Invalid action type: ${type}`);
+ }
+ const action: UIActionInfo = {
+ enabled: true,
+ label,
+ __type: type,
+ __key:
+ ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36).substr(2),
+ };
+ this.push('_additionalActions', action);
+ return action.__key;
+ }
+
+ removeActionButton(key: string) {
+ const idx = this._indexOfActionButtonWithKey(key);
+ if (idx === -1) {
+ return;
+ }
+ this.splice('_additionalActions', idx, 1);
+ }
+
+ setActionButtonProp<T extends keyof UIActionInfo>(
+ key: string,
+ prop: T,
+ value: UIActionInfo[T]
+ ) {
+ this.set(
+ ['_additionalActions', this._indexOfActionButtonWithKey(key), prop],
+ value
+ );
+ }
+
+ setActionOverflow(type: ActionType, key: string, overflow: boolean) {
+ if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+ throw Error(`Invalid action type given: ${type}`);
+ }
+ const index = this._getActionOverflowIndex(type, key);
+ const action: OverflowAction = {
+ type,
+ key,
+ overflow,
+ };
+ if (!overflow && index !== -1) {
+ this.splice('_overflowActions', index, 1);
+ } else if (overflow) {
+ this.push('_overflowActions', action);
+ }
+ }
+
+ setActionPriority(
+ type: ActionType.CHANGE | ActionType.REVISION,
+ key: string,
+ priority: ActionPriority
+ ) {
+ if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+ throw Error(`Invalid action type given: ${type}`);
+ }
+ const index = this._actionPriorityOverrides.findIndex(
+ action => action.type === type && action.key === key
+ );
+ const action: ActionPriorityOverride = {
+ type,
+ key,
+ priority,
+ };
+ if (index !== -1) {
+ this.set('_actionPriorityOverrides', index, action);
+ } else {
+ this.push('_actionPriorityOverrides', action);
+ }
+ }
+
+ setActionHidden(
+ type: ActionType.CHANGE | ActionType.REVISION,
+ key: string,
+ hidden: boolean
+ ) {
+ if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+ throw Error(`Invalid action type given: ${type}`);
+ }
+
+ const idx = this._hiddenActions.indexOf(key);
+ if (hidden && idx === -1) {
+ this.push('_hiddenActions', key);
+ } else if (!hidden && idx !== -1) {
+ this.splice('_hiddenActions', idx, 1);
+ }
+ }
+
+ getActionDetails(actionName: string) {
+ if (this.revisionActions[actionName]) {
+ return this.revisionActions[actionName];
+ } else if (this.actions[actionName]) {
+ return this.actions[actionName];
+ } else {
+ return undefined;
+ }
+ }
+
+ _indexOfActionButtonWithKey(key: string) {
+ for (let i = 0; i < this._additionalActions.length; i++) {
+ if (this._additionalActions[i].__key === key) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ _shouldHideActions(
+ actions?: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+ loading?: boolean
+ ) {
+ return loading || !actions || !actions.base || !actions.base.length;
+ }
+
+ _keyCount(
+ changeRecord?: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >
+ ) {
+ return Object.keys(changeRecord?.base || {}).length;
+ }
+
+ @observe('actions.*', 'revisionActions.*', '_additionalActions.*')
+ _actionsChanged(
+ actionsChangeRecord?: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ revisionActionsChangeRecord?: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ additionalActionsChangeRecord?: PolymerDeepPropertyChange<
+ UIActionInfo[],
+ UIActionInfo[]
+ >
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ actionsChangeRecord === undefined ||
+ revisionActionsChangeRecord === undefined ||
+ additionalActionsChangeRecord === undefined
+ ) {
+ return;
+ }
+
+ const additionalActions =
+ (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
+ [];
+ this.hidden =
+ this._keyCount(actionsChangeRecord) === 0 &&
+ this._keyCount(revisionActionsChangeRecord) === 0 &&
+ additionalActions.length === 0;
+ this._actionLoadingMessage = '';
+ this._actionLoadingMessage = '';
+ this._disabledMenuActions = [];
+
+ const revisionActions = revisionActionsChangeRecord.base || {};
+ if (Object.keys(revisionActions).length !== 0) {
+ if (!revisionActions.download) {
+ this.set('revisionActions.download', DOWNLOAD_ACTION);
+ }
+ }
+ }
+
+ _deleteAndNotify(actionName: string) {
+ if (this.actions && this.actions[actionName]) {
+ delete this.actions[actionName];
+ // We assign a fake value of 'false' to support Polymer 2
+ // see https://github.com/Polymer/polymer/issues/2631
+ this.notifyPath('actions.' + actionName, false);
+ }
+ }
+
+ @observe(
+ 'editMode',
+ 'editPatchsetLoaded',
+ 'editBasedOnCurrentPatchSet',
+ 'disableEdit',
+ 'actions.*',
+ 'change.*'
+ )
+ _editStatusChanged(
+ editMode: boolean,
+ editPatchsetLoaded: boolean,
+ editBasedOnCurrentPatchSet: boolean,
+ disableEdit: boolean,
+ actionsChangeRecord?: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+ ) {
+ if (actionsChangeRecord === undefined || changeChangeRecord === undefined) {
+ return;
+ }
+ if (disableEdit) {
+ this._deleteAndNotify('publishEdit');
+ this._deleteAndNotify('rebaseEdit');
+ this._deleteAndNotify('deleteEdit');
+ this._deleteAndNotify('stopEdit');
+ this._deleteAndNotify('edit');
+ return;
+ }
+ const actions = actionsChangeRecord.base;
+ const change = changeChangeRecord.base;
+ if (actions && editPatchsetLoaded) {
+ // Only show actions that mutate an edit if an actual edit patch set
+ // is loaded.
+ if (changeIsOpen(change)) {
+ if (editBasedOnCurrentPatchSet) {
+ if (!actions.publishEdit) {
+ this.set('actions.publishEdit', PUBLISH_EDIT);
+ }
+ this._deleteAndNotify('rebaseEdit');
+ } else {
+ if (!actions.rebaseEdit) {
+ this.set('actions.rebaseEdit', REBASE_EDIT);
+ }
+ this._deleteAndNotify('publishEdit');
+ }
+ }
+ if (!actions.deleteEdit) {
+ this.set('actions.deleteEdit', DELETE_EDIT);
+ }
+ } else {
+ this._deleteAndNotify('publishEdit');
+ this._deleteAndNotify('rebaseEdit');
+ this._deleteAndNotify('deleteEdit');
+ }
+
+ if (actions && changeIsOpen(change)) {
+ // Only show edit button if there is no edit patchset loaded and the
+ // file list is not in edit mode.
+ if (editPatchsetLoaded || editMode) {
+ this._deleteAndNotify('edit');
+ } else {
+ if (!actions.edit) {
+ this.set('actions.edit', EDIT);
+ }
+ }
+ // Only show STOP_EDIT if edit mode is enabled, but no edit patch set
+ // is loaded.
+ if (editMode && !editPatchsetLoaded) {
+ if (!actions.stopEdit) {
+ this.set('actions.stopEdit', STOP_EDIT);
+ }
+ } else {
+ this._deleteAndNotify('stopEdit');
+ }
+ } else {
+ // Remove edit button.
+ this._deleteAndNotify('edit');
+ }
+ }
+
+ _getValuesFor<T>(obj: {[key: string]: T}): T[] {
+ return Object.keys(obj).map(key => obj[key]);
+ }
+
+ _getLabelStatus(label: LabelInfo): LabelStatus {
+ if (isQuickLabelInfo(label)) {
+ if (label.approved) {
+ return LabelStatus.OK;
+ } else if (label.rejected) {
+ return LabelStatus.REJECT;
+ }
+ }
+ if (label.optional) {
+ return LabelStatus.OPTIONAL;
+ } else {
+ return LabelStatus.NEED;
+ }
+ }
+
+ /**
+ * Get highest score for last missing permitted label for current change.
+ * Returns null if no labels permitted or more than one label missing.
+ */
+ _getTopMissingApproval() {
+ if (!this.change || !this.change.labels || !this.change.permitted_labels) {
+ return null;
+ }
+ let result;
+ for (const label in this.change.labels) {
+ if (!(label in this.change.permitted_labels)) {
+ continue;
+ }
+ if (this.change.permitted_labels[label].length === 0) {
+ continue;
+ }
+ const status = this._getLabelStatus(this.change.labels[label]);
+ if (status === LabelStatus.NEED) {
+ if (result) {
+ // More than one label is missing, so it's unclear which to quick
+ // approve, return null;
+ return null;
+ }
+ result = label;
+ } else if (
+ status === LabelStatus.REJECT ||
+ status === LabelStatus.IMPOSSIBLE
+ ) {
+ return null;
+ }
+ }
+ if (result) {
+ const score = this.change.permitted_labels[result].slice(-1)[0];
+ const labelInfo = this.change.labels[result];
+ if (!isDetailedLabelInfo(labelInfo)) {
+ return null;
+ }
+ const maxScore = Object.keys(labelInfo.values).slice(-1)[0];
+ if (score === maxScore) {
+ // Allow quick approve only for maximal score.
+ return {
+ label: result,
+ score,
+ };
+ }
+ }
+ return null;
+ }
+
+ hideQuickApproveAction() {
+ if (!this._topLevelSecondaryActions) {
+ throw new Error('_topLevelSecondaryActions must be set');
+ }
+ this._topLevelSecondaryActions = this._topLevelSecondaryActions.filter(
+ sa => !isQuckApproveAction(sa)
+ );
+ this._hideQuickApproveAction = true;
+ }
+
+ _getQuickApproveAction(): QuickApproveUIActionInfo | null {
+ if (this._hideQuickApproveAction) {
+ return null;
+ }
+ const approval = this._getTopMissingApproval();
+ if (!approval) {
+ return null;
+ }
+ const action = {...QUICK_APPROVE_ACTION};
+ action.label = approval.label + approval.score;
+
+ const score = Number(approval.score);
+ if (isNaN(score)) {
+ return null;
+ }
+
+ const review: ReviewInput = {
+ drafts: DraftsAction.PUBLISH_ALL_REVISIONS,
+ labels: {
+ [approval.label]: score,
+ },
+ };
+ action.payload = review;
+ return action;
+ }
+
+ _getActionValues(
+ actionsChangeRecord: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ primariesChangeRecord: PolymerDeepPropertyChange<
+ PrimaryActionKey[],
+ PrimaryActionKey[]
+ >,
+ additionalActionsChangeRecord: PolymerDeepPropertyChange<
+ UIActionInfo[],
+ UIActionInfo[]
+ >,
+ type: ActionType
+ ): UIActionInfo[] {
+ if (!actionsChangeRecord || !primariesChangeRecord) {
+ return [];
+ }
+
+ const actions = actionsChangeRecord.base || {};
+ const primaryActionKeys = primariesChangeRecord.base || [];
+ const result: UIActionInfo[] = [];
+ const values: Array<ChangeActions | RevisionActions> =
+ type === ActionType.CHANGE
+ ? this._getValuesFor(ChangeActions)
+ : this._getValuesFor(RevisionActions);
+
+ const pluginActions: UIActionInfo[] = [];
+ Object.keys(actions).forEach(a => {
+ const action: UIActionInfo = actions[a] as UIActionInfo;
+ action.__key = a;
+ action.__type = type;
+ action.__primary = primaryActionKeys.includes(a as PrimaryActionKey);
+ // Plugin actions always contain ~ in the key.
+ if (a.indexOf('~') !== -1) {
+ this._populateActionUrl(action);
+ pluginActions.push(action);
+ // Add server-side provided plugin actions to overflow menu.
+ this._overflowActions.push({
+ type,
+ key: a,
+ });
+ return;
+ } else if (!values.includes(a as PrimaryActionKey)) {
+ return;
+ }
+ action.label = this._getActionLabel(action);
+
+ // Triggers a re-render by ensuring object inequality.
+ result.push({...action});
+ });
+
+ let additionalActions =
+ (additionalActionsChangeRecord && additionalActionsChangeRecord.base) ||
+ [];
+ additionalActions = additionalActions
+ .filter(a => a.__type === type)
+ .map(a => {
+ a.__primary = primaryActionKeys.includes(a.__key as PrimaryActionKey);
+ // Triggers a re-render by ensuring object inequality.
+ return {...a};
+ });
+ return result.concat(additionalActions).concat(pluginActions);
+ }
+
+ _populateActionUrl(action: UIActionInfo) {
+ const patchNum =
+ action.__type === ActionType.REVISION ? this.latestPatchNum : undefined;
+ if (!this.changeNum) {
+ return;
+ }
+ this.$.restAPI
+ .getChangeActionURL(this.changeNum, patchNum, '/' + action.__key)
+ .then(url => (action.__url = url));
+ }
+
+ /**
+ * Given a change action, return a display label that uses the appropriate
+ * casing or includes explanatory details.
+ */
+ _getActionLabel(action: UIActionInfo) {
+ if (action.label === 'Delete') {
+ // This label is common within change and revision actions. Make it more
+ // explicit to the user.
+ return 'Delete change';
+ } else if (action.label === 'WIP') {
+ return 'Mark as work in progress';
+ }
+ // Otherwise, just map the name to sentence case.
+ return this._toSentenceCase(action.label);
+ }
+
+ /**
+ * Capitalize the first letter and lowecase all others.
+ */
+ _toSentenceCase(s: string) {
+ if (!s.length) {
+ return '';
+ }
+ return s[0].toUpperCase() + s.slice(1).toLowerCase();
+ }
+
+ _computeLoadingLabel(action: string) {
+ return ActionLoadingLabels[action] || 'Working...';
+ }
+
+ _canSubmitChange() {
+ if (!this.change) {
+ return false;
+ }
+ return this.$.jsAPI.canSubmitChange(
+ this.change,
+ this._getRevision(this.change, this.latestPatchNum)
+ );
+ }
+
+ _getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNum) {
+ for (const rev of Object.values(change.revisions)) {
+ if (patchNumEquals(rev._number, patchNum)) {
+ return rev;
+ }
+ }
+ return null;
+ }
+
+ showRevertDialog() {
+ const change = this.change;
+ if (!change) return;
+ // The search is still broken if there is a " in the topic.
+ const query = `submissionid: "${change.submission_id}"`;
+ /* A chromium plugin expects that the modifyRevertMsg hook will only
+ be called after the revert button is pressed, hence we populate the
+ revert dialog after revert button is pressed. */
+ this.$.restAPI.getChanges(0, query).then(changes => {
+ if (!changes) {
+ console.error('changes is undefined');
+ return;
+ }
+ this.$.confirmRevertDialog.populate(change, this.commitMessage, changes);
+ this._showActionDialog(this.$.confirmRevertDialog);
+ });
+ }
+
+ showRevertSubmissionDialog() {
+ const change = this.change;
+ if (!change) return;
+ const query = `submissionid:${change.submission_id}`;
+ this.$.restAPI.getChanges(0, query).then(changes => {
+ if (!changes) {
+ console.error('changes is undefined');
+ return;
+ }
+ this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
+ change,
+ changes
+ );
+ this._showActionDialog(this.$.confirmRevertSubmissionDialog);
+ });
+ }
+
+ _handleActionTap(e: MouseEvent) {
+ e.preventDefault();
+ let el = (dom(e) as EventApi).localTarget as Element;
+ while (el.tagName.toLowerCase() !== 'gr-button') {
+ if (!el.parentElement) {
+ return;
+ }
+ el = el.parentElement;
+ }
+
+ const key = el.getAttribute('data-action-key');
+ if (!key) {
+ throw new Error("Button doesn't have data-action-key attribute");
+ }
+ if (
+ key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+ key.indexOf('~') !== -1
+ ) {
+ this.dispatchEvent(
+ new CustomEvent(`${key}-tap`, {
+ detail: {node: el},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ const type = el.getAttribute('data-action-type') as ActionType;
+ this._handleAction(type, key);
+ }
+
+ _handleOverflowItemTap(e: CustomEvent<MenuAction>) {
+ e.preventDefault();
+ const el = (dom(e) as EventApi).localTarget as Element;
+ const key = e.detail.action.__key;
+ if (
+ key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+ key.indexOf('~') !== -1
+ ) {
+ this.dispatchEvent(
+ new CustomEvent(`${key}-tap`, {
+ detail: {node: el},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ this._handleAction(e.detail.action.__type, e.detail.action.__key);
+ }
+
+ _handleAction(type: ActionType, key: string) {
+ this.reporting.reportInteraction(`${type}-${key}`);
+ switch (type) {
+ case ActionType.REVISION:
+ this._handleRevisionAction(key);
+ break;
+ case ActionType.CHANGE:
+ this._handleChangeAction(key);
+ break;
+ default:
+ this._fireAction(
+ this._prependSlash(key),
+ assertUIActionInfo(this.actions[key]),
+ false
+ );
+ }
+ }
+
+ _handleChangeAction(key: string) {
+ switch (key) {
+ case ChangeActions.REVERT:
+ this.showRevertDialog();
+ break;
+ case ChangeActions.REVERT_SUBMISSION:
+ this.showRevertSubmissionDialog();
+ break;
+ case ChangeActions.ABANDON:
+ this._showActionDialog(this.$.confirmAbandonDialog);
+ break;
+ case QUICK_APPROVE_ACTION.key: {
+ const action = this._allActionValues.find(isQuckApproveAction);
+ if (!action) {
+ return;
+ }
+ this._fireAction(this._prependSlash(key), action, true, action.payload);
+ break;
+ }
+ case ChangeActions.EDIT:
+ this._handleEditTap();
+ break;
+ case ChangeActions.STOP_EDIT:
+ this._handleStopEditTap();
+ break;
+ case ChangeActions.DELETE:
+ this._handleDeleteTap();
+ break;
+ case ChangeActions.DELETE_EDIT:
+ this._handleDeleteEditTap();
+ break;
+ case ChangeActions.FOLLOW_UP:
+ this._handleFollowUpTap();
+ break;
+ case ChangeActions.WIP:
+ this._handleWipTap();
+ break;
+ case ChangeActions.MOVE:
+ this._handleMoveTap();
+ break;
+ case ChangeActions.PUBLISH_EDIT:
+ this._handlePublishEditTap();
+ break;
+ case ChangeActions.REBASE_EDIT:
+ this._handleRebaseEditTap();
+ break;
+ default:
+ this._fireAction(
+ this._prependSlash(key),
+ assertUIActionInfo(this.actions[key]),
+ false
+ );
+ }
+ }
+
+ _handleRevisionAction(key: string) {
+ switch (key) {
+ case RevisionActions.REBASE:
+ this._showActionDialog(this.$.confirmRebase);
+ this.$.confirmRebase.fetchRecentChanges();
+ break;
+ case RevisionActions.CHERRYPICK:
+ this._handleCherrypickTap();
+ break;
+ case RevisionActions.DOWNLOAD:
+ this._handleDownloadTap();
+ break;
+ case RevisionActions.SUBMIT:
+ if (!this._canSubmitChange()) {
+ return;
+ }
+ this._showActionDialog(this.$.confirmSubmitDialog);
+ break;
+ default:
+ this._fireAction(
+ this._prependSlash(key),
+ assertUIActionInfo(this.revisionActions[key]),
+ true
+ );
+ }
+ }
+
+ _prependSlash(key: string) {
+ return key === '/' ? key : `/${key}`;
+ }
+
+ /**
+ * _hasKnownChainState set to true true if hasParent is defined (can be
+ * either true or false). set to false otherwise.
+ */
+ _computeChainState() {
+ this._hasKnownChainState = true;
+ }
+
+ _calculateDisabled(action: UIActionInfo, hasKnownChainState: boolean) {
+ if (action.__key === 'rebase') {
+ // Rebase button is only disabled when change has no parent(s).
+ return hasKnownChainState === false;
+ }
+ return !action.enabled;
+ }
+
+ _handleConfirmDialogCancel() {
+ this._hideAllDialogs();
+ }
+
+ _hideAllDialogs() {
+ const dialogEls = this.root!.querySelectorAll('.confirmDialog');
+ for (const dialogEl of dialogEls) {
+ (dialogEl as HTMLElement).hidden = true;
+ }
+ this.$.overlay.close();
+ }
+
+ _handleRebaseConfirm(e: CustomEvent<ConfirmRebaseEventDetail>) {
+ const el = this.$.confirmRebase;
+ const payload = {base: e.detail.base};
+ this.$.overlay.close();
+ el.hidden = true;
+ this._fireAction(
+ '/rebase',
+ assertUIActionInfo(this.revisionActions.rebase),
+ true,
+ payload
+ );
+ }
+
+ _handleCherrypickConfirm() {
+ this._handleCherryPickRestApi(false);
+ }
+
+ _handleCherrypickConflictConfirm() {
+ this._handleCherryPickRestApi(true);
+ }
+
+ _handleCherryPickRestApi(conflicts: boolean) {
+ const el = this.$.confirmCherrypick;
+ if (!el.branch) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_BRANCH_EMPTY},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ if (!el.message) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_COMMIT_EMPTY},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ this.$.overlay.close();
+ el.hidden = true;
+ this._fireAction(
+ '/cherrypick',
+ assertUIActionInfo(this.revisionActions.cherrypick),
+ true,
+ {
+ destination: el.branch,
+ base: el.baseCommit ? el.baseCommit : null,
+ message: el.message,
+ allow_conflicts: conflicts,
+ }
+ );
+ }
+
+ _handleMoveConfirm() {
+ const el = this.$.confirmMove;
+ if (!el.branch) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_BRANCH_EMPTY},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ this.$.overlay.close();
+ el.hidden = true;
+ this._fireAction('/move', assertUIActionInfo(this.actions.move), false, {
+ destination_branch: el.branch,
+ message: el.message,
+ });
+ }
+
+ _handleRevertDialogConfirm(e: CustomEvent<ConfirmRevertEventDetail>) {
+ const revertType = e.detail.revertType;
+ const message = e.detail.message;
+ const el = this.$.confirmRevertDialog;
+ this.$.overlay.close();
+ el.hidden = true;
+ switch (revertType) {
+ case RevertType.REVERT_SINGLE_CHANGE:
+ this._fireAction(
+ '/revert',
+ assertUIActionInfo(this.actions.revert),
+ false,
+ {message}
+ );
+ break;
+ case RevertType.REVERT_SUBMISSION:
+ this._fireAction(
+ '/revert_submission',
+ assertUIActionInfo(this.actions.revert_submission),
+ false,
+ {message}
+ );
+ break;
+ default:
+ console.error('invalid revert type');
+ }
+ }
+
+ _handleRevertSubmissionDialogConfirm() {
+ const el = this.$.confirmRevertSubmissionDialog;
+ this.$.overlay.close();
+ el.hidden = true;
+ this._fireAction(
+ '/revert_submission',
+ assertUIActionInfo(this.actions.revert_submission),
+ false,
+ {message: el.message}
+ );
+ }
+
+ _handleAbandonDialogConfirm() {
+ const el = this.$.confirmAbandonDialog;
+ this.$.overlay.close();
+ el.hidden = true;
+ this._fireAction(
+ '/abandon',
+ assertUIActionInfo(this.actions.abandon),
+ false,
+ {
+ message: el.message,
+ }
+ );
+ }
+
+ _handleCreateFollowUpChange() {
+ this.$.createFollowUpChange.handleCreateChange();
+ this._handleCloseCreateFollowUpChange();
+ }
+
+ _handleCloseCreateFollowUpChange() {
+ this.$.overlay.close();
+ }
+
+ _handleDeleteConfirm() {
+ this._fireAction(
+ '/',
+ assertUIActionInfo(this.actions[ChangeActions.DELETE]),
+ false
+ );
+ }
+
+ _handleDeleteEditConfirm() {
+ this._hideAllDialogs();
+
+ this._fireAction(
+ '/edit',
+ assertUIActionInfo(this.actions.deleteEdit),
+ false
+ );
+ }
+
+ _handleSubmitConfirm() {
+ if (!this._canSubmitChange()) {
+ return;
+ }
+ this._hideAllDialogs();
+ this._fireAction(
+ '/submit',
+ assertUIActionInfo(this.revisionActions.submit),
+ true
+ );
+ }
+
+ _getActionOverflowIndex(type: string, key: string) {
+ return this._overflowActions.findIndex(
+ action => action.type === type && action.key === key
+ );
+ }
+
+ _setLoadingOnButtonWithKey(type: string, key: string) {
+ this._actionLoadingMessage = this._computeLoadingLabel(key);
+ let buttonKey = key;
+ // TODO(dhruvsri): clean this up later
+ // If key is revert-submission, then button key should be 'revert'
+ if (buttonKey === ChangeActions.REVERT_SUBMISSION) {
+ // Revert submission button no longer exists
+ buttonKey = ChangeActions.REVERT;
+ }
+
+ // If the action appears in the overflow menu.
+ if (this._getActionOverflowIndex(type, buttonKey) !== -1) {
+ this.push(
+ '_disabledMenuActions',
+ buttonKey === '/' ? 'delete' : buttonKey
+ );
+ return () => {
+ this._actionLoadingMessage = '';
+ this._disabledMenuActions = [];
+ };
+ }
+
+ // Otherwise it's a top-level action.
+ const buttonEl = this.shadowRoot!.querySelector(
+ `[data-action-key="${buttonKey}"]`
+ ) as GrButton;
+ if (!buttonEl) {
+ throw new Error(`Can't find button by data-action-key '${buttonKey}'`);
+ }
+ buttonEl.setAttribute('loading', 'true');
+ buttonEl.disabled = true;
+ return () => {
+ this._actionLoadingMessage = '';
+ buttonEl.removeAttribute('loading');
+ buttonEl.disabled = false;
+ };
+ }
+
+ _fireAction(
+ endpoint: string,
+ action: UIActionInfo,
+ revAction: boolean,
+ payload?: RequestPayload
+ ) {
+ const cleanupFn = this._setLoadingOnButtonWithKey(
+ action.__type,
+ action.__key
+ );
+
+ this._send(
+ action.method,
+ payload,
+ endpoint,
+ revAction,
+ cleanupFn,
+ action
+ ).then(res => this._handleResponse(action, res));
+ }
+
+ _showActionDialog(dialog: ChangeActionDialog) {
+ this._hideAllDialogs();
+
+ dialog.hidden = false;
+ this.$.overlay.open().then(() => {
+ if (dialog.resetFocus) {
+ dialog.resetFocus();
+ }
+ });
+ }
+
+ // TODO(rmistry): Redo this after
+ // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
+ _setLabelValuesOnRevert(newChangeId: NumericChangeId) {
+ const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+ if (!labels) {
+ return Promise.resolve(undefined);
+ }
+ return this.$.restAPI.saveChangeReview(newChangeId, 'current', {labels});
+ }
+
+ _handleResponse(action: UIActionInfo, response?: Response) {
+ if (!response) {
+ return;
+ }
+ return this.$.restAPI.getResponseObject(response).then(obj => {
+ switch (action.__key) {
+ case ChangeActions.REVERT: {
+ const revertChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+ this._waitForChangeReachable(revertChangeInfo._number)
+ .then(() => this._setLabelValuesOnRevert(revertChangeInfo._number))
+ .then(() => {
+ GerritNav.navigateToChange(revertChangeInfo);
+ });
+ break;
+ }
+ case RevisionActions.CHERRYPICK: {
+ const cherrypickChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+ this._waitForChangeReachable(cherrypickChangeInfo._number).then(
+ () => {
+ GerritNav.navigateToChange(cherrypickChangeInfo);
+ }
+ );
+ break;
+ }
+ case ChangeActions.DELETE:
+ if (action.__type === ActionType.CHANGE) {
+ GerritNav.navigateToRelativeUrl(GerritNav.getUrlForRoot());
+ }
+ break;
+ case ChangeActions.WIP:
+ case ChangeActions.DELETE_EDIT:
+ case ChangeActions.PUBLISH_EDIT:
+ case ChangeActions.REBASE_EDIT:
+ case ChangeActions.REBASE:
+ case ChangeActions.SUBMIT:
+ this.dispatchEvent(
+ new CustomEvent('reload', {
+ detail: {clearPatchset: true},
+ bubbles: false,
+ composed: true,
+ })
+ );
+ break;
+ case ChangeActions.REVERT_SUBMISSION: {
+ const revertSubmistionInfo = (obj as unknown) as RevertSubmissionInfo;
+ if (
+ !revertSubmistionInfo.revert_changes ||
+ !revertSubmistionInfo.revert_changes.length
+ )
+ return;
+ /* If there is only 1 change then gerrit will automatically
+ redirect to that change */
+ GerritNav.navigateToSearchQuery(
+ `topic: ${revertSubmistionInfo.revert_changes[0].topic}`
+ );
+ break;
+ }
+ default:
+ this.dispatchEvent(
+ new CustomEvent('reload', {
+ detail: {action: action.__key, clearPatchset: true},
+ bubbles: false,
+ composed: true,
+ })
+ );
+ break;
+ }
+ });
+ }
+
+ _handleShowRevertSubmissionChangesConfirm() {
+ this._hideAllDialogs();
+ }
+
+ _handleResponseError(
+ action: UIActionInfo,
+ response: Response | undefined | null,
+ body?: RequestPayload
+ ) {
+ if (!response) {
+ return Promise.resolve(() => {
+ this.dispatchEvent(
+ new CustomEvent('show-error', {
+ detail: {message: `Could not perform action '${action.__key}'`},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }
+ if (action && action.__key === RevisionActions.CHERRYPICK) {
+ if (
+ response.status === 409 &&
+ body &&
+ !(body as CherryPickInput).allow_conflicts
+ ) {
+ return this._showActionDialog(this.$.confirmCherrypickConflict);
+ }
+ }
+ return response.text().then(errText => {
+ this.dispatchEvent(
+ new CustomEvent('show-error', {
+ detail: {message: `Could not perform action: ${errText}`},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ if (!errText.startsWith('Change is already up to date')) {
+ throw Error(errText);
+ }
+ });
+ }
+
+ _send(
+ method: HttpMethod | undefined,
+ payload: RequestPayload | undefined,
+ actionEndpoint: string,
+ revisionAction: boolean,
+ cleanupFn: () => void,
+ action: UIActionInfo
+ ): Promise<Response | undefined> {
+ const handleError: ErrorCallback = response => {
+ cleanupFn.call(this);
+ this._handleResponseError(action, response, payload);
+ };
+ const change = this.change;
+ const changeNum = this.changeNum;
+ if (!change || !changeNum) {
+ return Promise.reject(
+ new Error('Properties change and changeNum must be set.')
+ );
+ }
+ return fetchChangeUpdates(change, this.$.restAPI).then(result => {
+ if (!result.isLatest) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message:
+ 'Cannot set label: a newer patch has been ' +
+ 'uploaded to this change.',
+ action: 'Reload',
+ callback: () => {
+ this.dispatchEvent(
+ new CustomEvent('reload', {
+ detail: {clearPatchset: true},
+ bubbles: false,
+ composed: true,
+ })
+ );
+ },
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+
+ // Because this is not a network error, call the cleanup function
+ // but not the error handler.
+ cleanupFn();
+
+ return Promise.resolve(undefined);
+ }
+ const patchNum = revisionAction ? this.latestPatchNum : undefined;
+ return this.$.restAPI
+ .executeChangeAction(
+ changeNum,
+ method,
+ actionEndpoint,
+ patchNum,
+ payload,
+ handleError
+ )
+ .then(response => {
+ cleanupFn.call(this);
+ return response;
+ });
+ });
+ }
+
+ _handleAbandonTap() {
+ this._showActionDialog(this.$.confirmAbandonDialog);
+ }
+
+ _handleCherrypickTap() {
+ if (!this.change) {
+ throw new Error('The change property must be set');
+ }
+ this.$.confirmCherrypick.branch = '' as BranchName;
+ const query = `topic: "${this.change.topic}"`;
+ const options = listChangesOptionsToHex(
+ ListChangesOption.MESSAGES,
+ ListChangesOption.ALL_REVISIONS
+ );
+ this.$.restAPI.getChanges(0, query, undefined, options).then(changes => {
+ if (!changes) {
+ console.error('getChanges returns undefined');
+ return;
+ }
+ this.$.confirmCherrypick.updateChanges(changes);
+ this._showActionDialog(this.$.confirmCherrypick);
+ });
+ }
+
+ _handleMoveTap() {
+ this.$.confirmMove.branch = '' as BranchName;
+ this.$.confirmMove.message = '';
+ this._showActionDialog(this.$.confirmMove);
+ }
+
+ _handleDownloadTap() {
+ this.dispatchEvent(
+ new CustomEvent('download-tap', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleDeleteTap() {
+ this._showActionDialog(this.$.confirmDeleteDialog);
+ }
+
+ _handleDeleteEditTap() {
+ this._showActionDialog(this.$.confirmDeleteEditDialog);
+ }
+
+ _handleFollowUpTap() {
+ this._showActionDialog(this.$.createFollowUpDialog);
+ }
+
+ _handleWipTap() {
+ if (!this.actions.wip) {
+ return;
+ }
+ this._fireAction('/wip', assertUIActionInfo(this.actions.wip), false);
+ }
+
+ _handlePublishEditTap() {
+ if (!this.actions.publishEdit) {
+ return;
+ }
+ this._fireAction(
+ '/edit:publish',
+ assertUIActionInfo(this.actions.publishEdit),
+ false,
+ {notify: NotifyType.NONE}
+ );
+ }
+
+ _handleRebaseEditTap() {
+ if (!this.actions.rebaseEdit) {
+ return;
+ }
+ this._fireAction(
+ '/edit:rebase',
+ assertUIActionInfo(this.actions.rebaseEdit),
+ false
+ );
+ }
+
+ _handleHideBackgroundContent() {
+ this.$.mainContent.classList.add('overlayOpen');
+ }
+
+ _handleShowBackgroundContent() {
+ this.$.mainContent.classList.remove('overlayOpen');
+ }
+
+ /**
+ * Merge sources of change actions into a single ordered array of action
+ * values.
+ */
+ _computeAllActions(
+ changeActionsRecord: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ revisionActionsRecord: PolymerDeepPropertyChange<
+ ActionNameToActionInfoMap,
+ ActionNameToActionInfoMap
+ >,
+ primariesRecord: PolymerDeepPropertyChange<
+ PrimaryActionKey[],
+ PrimaryActionKey[]
+ >,
+ additionalActionsRecord: PolymerDeepPropertyChange<
+ UIActionInfo[],
+ UIActionInfo[]
+ >,
+ change?: ChangeInfo,
+ config?: ServerInfo
+ ): UIActionInfo[] {
+ // Polymer 2: check for undefined
+ if (
+ [
+ changeActionsRecord,
+ revisionActionsRecord,
+ primariesRecord,
+ additionalActionsRecord,
+ change,
+ ].includes(undefined)
+ ) {
+ return [];
+ }
+
+ const revisionActionValues = this._getActionValues(
+ revisionActionsRecord,
+ primariesRecord,
+ additionalActionsRecord,
+ ActionType.REVISION
+ );
+ const changeActionValues = this._getActionValues(
+ changeActionsRecord,
+ primariesRecord,
+ additionalActionsRecord,
+ ActionType.CHANGE
+ );
+ const quickApprove = this._getQuickApproveAction();
+ if (quickApprove) {
+ changeActionValues.unshift(quickApprove);
+ }
+
+ return revisionActionValues
+ .concat(changeActionValues)
+ .sort((a, b) => this._actionComparator(a, b))
+ .map(action => {
+ if (ACTIONS_WITH_ICONS.has(action.__key)) {
+ action.icon = action.__key;
+ }
+ // TODO(brohlfs): Temporary hack until change 269573 is live in all
+ // backends.
+ if (action.__key === ChangeActions.READY) {
+ action.label = 'Mark as Active';
+ }
+ // End of hack
+ return action;
+ })
+ .filter(action => !this._shouldSkipAction(action, config));
+ }
+
+ _getActionPriority(action: UIActionInfo) {
+ if (action.__type && action.__key) {
+ const overrideAction = this._actionPriorityOverrides.find(
+ i => i.type === action.__type && i.key === action.__key
+ );
+
+ if (overrideAction !== undefined) {
+ return overrideAction.priority;
+ }
+ }
+ if (action.__key === 'review') {
+ return ActionPriority.REVIEW;
+ } else if (action.__primary) {
+ return ActionPriority.PRIMARY;
+ } else if (action.__type === ActionType.CHANGE) {
+ return ActionPriority.CHANGE;
+ } else if (action.__type === ActionType.REVISION) {
+ return ActionPriority.REVISION;
+ }
+ return ActionPriority.DEFAULT;
+ }
+
+ /**
+ * Sort comparator to define the order of change actions.
+ */
+ _actionComparator(actionA: UIActionInfo, actionB: UIActionInfo) {
+ const priorityDelta =
+ this._getActionPriority(actionA) - this._getActionPriority(actionB);
+ // Sort by the button label if same priority.
+ if (priorityDelta === 0) {
+ return actionA.label > actionB.label ? 1 : -1;
+ } else {
+ return priorityDelta;
+ }
+ }
+
+ _shouldSkipAction(action: UIActionInfo, config?: ServerInfo) {
+ const skipActionKeys: string[] = [...SKIP_ACTION_KEYS];
+ const isAttentionSetEnabled =
+ !!config && !!config.change && config.change.enable_attention_set;
+ if (isAttentionSetEnabled) {
+ skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
+ }
+ return skipActionKeys.includes(action.__key);
+ }
+
+ _computeTopLevelActions(
+ actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+ hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>,
+ editMode: boolean
+ ): UIActionInfo[] {
+ const hiddenActions = hiddenActionsRecord.base || [];
+ return actionRecord.base.filter(a => {
+ if (hiddenActions.includes(a.__key)) return false;
+ if (editMode) return EDIT_ACTIONS.has(a.__key);
+ return this._getActionOverflowIndex(a.__type, a.__key) === -1;
+ });
+ }
+
+ _filterPrimaryActions(_topLevelActions: UIActionInfo[]) {
+ this._topLevelPrimaryActions = _topLevelActions.filter(
+ action => action.__primary
+ );
+ this._topLevelSecondaryActions = _topLevelActions.filter(
+ action => !action.__primary
+ );
+ }
+
+ _computeMenuActions(
+ actionRecord: PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+ hiddenActionsRecord: PolymerDeepPropertyChange<string[], string[]>
+ ): MenuAction[] {
+ const hiddenActions = hiddenActionsRecord.base || [];
+ return actionRecord.base
+ .filter(a => {
+ const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+ return overflow && !hiddenActions.includes(a.__key);
+ })
+ .map(action => {
+ let key = action.__key;
+ if (key === '/') {
+ key = 'delete';
+ }
+ return {
+ name: action.label,
+ id: `${key}-${action.__type}`,
+ action,
+ tooltip: action.title,
+ };
+ });
+ }
+
+ _computeRebaseOnCurrent(
+ revisionRebaseAction: PropertyType<GrChangeActions, '_revisionRebaseAction'>
+ ) {
+ if (revisionRebaseAction) {
+ return !!revisionRebaseAction.enabled;
+ }
+ return null;
+ }
+
+ /**
+ * Occasionally, a change created by a change action is not yet known to the
+ * API for a brief time. Wait for the given change number to be recognized.
+ *
+ * Returns a promise that resolves with true if a request is recognized, or
+ * false if the change was never recognized after all attempts.
+ *
+ */
+ _waitForChangeReachable(changeNum: NumericChangeId) {
+ let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+ return new Promise(resolve => {
+ const check = () => {
+ attempsRemaining--;
+ // Pass a no-op error handler to avoid the "not found" error toast.
+ this.$.restAPI
+ .getChange(changeNum, () => {})
+ .then(response => {
+ // If the response is 404, the response will be undefined.
+ if (response) {
+ resolve(true);
+ return;
+ }
+
+ if (attempsRemaining) {
+ this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
+ } else {
+ resolve(false);
+ }
+ });
+ };
+ check();
+ });
+ }
+
+ _handleEditTap() {
+ this.dispatchEvent(new CustomEvent('edit-tap', {bubbles: false}));
+ }
+
+ _handleStopEditTap() {
+ this.dispatchEvent(new CustomEvent('stop-edit-tap', {bubbles: false}));
+ }
+
+ _computeHasTooltip(title?: string) {
+ return !!title;
+ }
+
+ _computeHasIcon(action: UIActionInfo) {
+ return action.icon ? '' : 'hidden';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-actions': GrChangeActions;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
index a2aa8d7..ae49a57 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
@@ -19,8 +19,12 @@
import './gr-change-actions.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {generateChange} from '../../../test/test-utils.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {
+ createChange,
+ createChangeMessages,
+ createRevisions,
+} from '../../../test/test-data-generators.js';
const basicFixture = fixtureFromElement('gr-change-actions');
@@ -85,7 +89,7 @@
getProjectConfig() { return Promise.resolve({}); },
});
- sinon.stub(pluginLoader, 'awaitPluginsLoaded')
+ sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
.returns(Promise.resolve());
element = basicFixture.instantiate();
@@ -153,19 +157,17 @@
});
});
- test('plugin change actions', done => {
+ test('plugin change actions', async () => {
sinon.stub(element.$.restAPI, 'getChangeActionURL').returns(
Promise.resolve('the-url'));
element.actions = {
'plugin~action': {},
};
assert.isOk(element.actions['plugin~action']);
- flush(() => {
- assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
- element.changeNum, null, '/plugin~action'));
- assert.equal(element.actions['plugin~action'].__url, 'the-url');
- done();
- });
+ await flush();
+ assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+ element.changeNum, undefined, '/plugin~action'));
+ assert.equal(element.actions['plugin~action'].__url, 'the-url');
});
test('not supported actions are filtered out', () => {
@@ -175,9 +177,10 @@
});
test('getActionDetails', () => {
- element.revisionActions = Object.assign({
+ element.revisionActions = {
'plugin~action': {},
- }, element.revisionActions);
+ ...element.revisionActions,
+ };
assert.isUndefined(element.getActionDetails('rubbish'));
assert.strictEqual(element.revisionActions['plugin~action'],
element.getActionDetails('plugin~action'));
@@ -286,7 +289,7 @@
assert.ok(submitButton);
MockInteractions.tap(submitButton);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
});
@@ -394,13 +397,14 @@
});
});
- test('rebase change calls navigateToChange', done => {
- const navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+ test('rebase change fires reload event', done => {
+ const eventStub = sinon.stub(element, 'dispatchEvent');
sinon.stub(element.$.restAPI, 'getResponseObject').returns(
Promise.resolve({}));
element._handleResponse({__key: 'rebase'}, {});
flush(() => {
- assert.isTrue(navigateToChangeStub.called);
+ assert.isTrue(eventStub.called);
+ assert.equal(eventStub.lastCall.args[0].type, 'reload');
done();
});
});
@@ -432,7 +436,7 @@
.querySelector('gr-button[data-action-key="rebase"]');
assert.ok(rebaseButton);
MockInteractions.tap(rebaseButton);
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.confirmRebase.hidden);
sinon.stub(element.$.restAPI, 'getChanges')
.returns(Promise.resolve([]));
@@ -484,7 +488,7 @@
element.set('editPatchsetLoaded', false);
element.change = {status: 'NEW'};
element.set('disableEdit', true);
- flushAsynchronousOperations();
+ flush();
assert.isNotOk(element.shadowRoot
.querySelector('gr-button[data-action-key="publishEdit"]'));
@@ -510,7 +514,7 @@
.querySelector('#confirmDeleteEditDialog')
.shadowRoot
.querySelector('gr-button[primary]'));
- flushAsynchronousOperations();
+ flush();
assert.equal(fireActionStub.lastCall.args[0], '/edit');
});
@@ -519,7 +523,7 @@
element.set('editMode', true);
element.set('editPatchsetLoaded', true);
element.change = {status: 'MERGED'};
- flushAsynchronousOperations();
+ flush();
assert.isNotOk(element.shadowRoot
.querySelector('gr-button[data-action-key="publishEdit"]'));
@@ -536,7 +540,7 @@
element.set('editPatchsetLoaded', true);
element.change = {status: 'NEW'};
element.editBasedOnCurrentPatchSet = false;
- flushAsynchronousOperations();
+ flush();
assert.isNotOk(element.shadowRoot
.querySelector('gr-button[data-action-key="publishEdit"]'));
@@ -555,7 +559,7 @@
element.set('editPatchsetLoaded', true);
element.change = {status: 'NEW'};
element.editBasedOnCurrentPatchSet = true;
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('gr-button[data-action-key="publishEdit"]'));
@@ -573,7 +577,7 @@
element.set('editMode', true);
element.set('editPatchsetLoaded', false);
element.change = {status: 'NEW'};
- flushAsynchronousOperations();
+ flush();
assert.isNotOk(element.shadowRoot
.querySelector('gr-button[data-action-key="publishEdit"]'));
@@ -591,7 +595,7 @@
element.set('editMode', false);
element.set('editPatchsetLoaded', false);
element.change = {status: 'NEW'};
- flushAsynchronousOperations();
+ flush();
assert.isNotOk(element.shadowRoot
.querySelector('gr-button[data-action-key="publishEdit"]'));
@@ -609,20 +613,20 @@
element.addEventListener('edit-tap', () => { done(); });
element.set('editMode', true);
element.change = {status: 'NEW'};
- flushAsynchronousOperations();
+ flush();
assert.isNotOk(element.shadowRoot
.querySelector('gr-button[data-action-key="edit"]'));
assert.isOk(element.shadowRoot
.querySelector('gr-button[data-action-key="stopEdit"]'));
element.change = {status: 'MERGED'};
- flushAsynchronousOperations();
+ flush();
assert.isNotOk(element.shadowRoot
.querySelector('gr-button[data-action-key="edit"]'));
element.change = {status: 'NEW'};
element.set('editMode', false);
- flushAsynchronousOperations();
+ flush();
const editButton = element.shadowRoot
.querySelector('gr-button[data-action-key="edit"]');
@@ -811,6 +815,14 @@
setup(() => {
fireActionStub = sinon.stub(element, '_fireAction');
sinon.stub(window, 'alert');
+ element.actions = {
+ move: {
+ method: 'POST',
+ label: 'Move',
+ title: 'Move the change',
+ enabled: true,
+ },
+ };
});
test('works', () => {
@@ -1212,7 +1224,7 @@
element.$.moreActions.shadowRoot
.querySelector('span[data-id="private-change"]'));
element.setActionOverflow('change', 'private', false);
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('[data-action-key="private"]'));
assert.isNotOk(
@@ -1263,7 +1275,7 @@
.querySelector('span[data-id="private.delete-change"]')
);
element.setActionOverflow('change', 'private.delete', false);
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('[data-action-key="private.delete"]'));
assert.isNotOk(
@@ -1309,7 +1321,7 @@
.querySelector('#confirmDeleteDialog')
.shadowRoot
.querySelector('gr-button[primary]'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
});
@@ -1320,7 +1332,7 @@
.querySelector('#confirmDeleteDialog')
.shadowRoot
.querySelector('gr-button:not([primary])'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.shadowRoot
.querySelector('#confirmDeleteDialog').hidden);
assert.isFalse(fireActionStub.called);
@@ -1361,7 +1373,7 @@
assert.isOk(element.$.moreActions.shadowRoot
.querySelector('span[data-id="ignore-change"]'));
element.setActionOverflow('change', 'ignore', false);
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('[data-action-key="ignore"]'));
assert.isNotOk(
@@ -1404,7 +1416,7 @@
element.$.moreActions.shadowRoot
.querySelector('span[data-id="unignore-change"]'));
element.setActionOverflow('change', 'unignore', false);
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('[data-action-key="unignore"]'));
assert.isNotOk(
@@ -1461,7 +1473,7 @@
element.$.moreActions.shadowRoot
.querySelector('span[data-id="reviewed-change"]'));
element.setActionOverflow('change', 'reviewed', false);
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('[data-action-key="reviewed"]'));
assert.isNotOk(
@@ -1504,7 +1516,7 @@
element.$.moreActions.shadowRoot
.querySelector('span[data-id="unreviewed-change"]'));
element.setActionOverflow('change', 'unreviewed', false);
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('[data-action-key="unreviewed"]'));
assert.isNotOk(
@@ -1533,7 +1545,7 @@
foo: ['-1', ' 0', '+1'],
},
};
- flushAsynchronousOperations();
+ flush();
});
test('added when can approve', () => {
@@ -1552,7 +1564,7 @@
// Assert approve button gets removed from list of buttons.
element.hideQuickApproveAction();
- flushAsynchronousOperations();
+ flush();
const approveButtonUpdated =
element.shadowRoot
.querySelector('gr-button[data-action-key=\'review\']');
@@ -1579,7 +1591,7 @@
foo: [' 0', '+1'],
},
};
- flushAsynchronousOperations();
+ flush();
const approveButton =
element.shadowRoot
.querySelector('gr-button[data-action-key=\'review\']');
@@ -1596,7 +1608,7 @@
bar: [],
},
};
- flushAsynchronousOperations();
+ flush();
const approveButton =
element.shadowRoot
.querySelector('gr-button[data-action-key=\'review\']');
@@ -1608,11 +1620,11 @@
MockInteractions.tap(
element.shadowRoot
.querySelector('gr-button[data-action-key=\'review\']'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(fireActionStub.called);
assert.isTrue(fireActionStub.calledWith('/review'));
const payload = fireActionStub.lastCall.args[3];
- assert.deepEqual(payload.labels, {foo: '+1'});
+ assert.deepEqual(payload.labels, {foo: 1});
});
test('not added when multiple labels are required', () => {
@@ -1627,7 +1639,7 @@
bar: [' 0', '+1', '+2'],
},
};
- flushAsynchronousOperations();
+ flush();
const approveButton =
element.shadowRoot
.querySelector('gr-button[data-action-key=\'review\']');
@@ -1651,7 +1663,7 @@
bar: [' 0', '+1', '+2'],
},
};
- flushAsynchronousOperations();
+ flush();
const approveButton =
element.shadowRoot
.querySelector('gr-button[data-action-key=\'review\']');
@@ -1675,7 +1687,7 @@
bar: [' 0', '+1'],
},
};
- flushAsynchronousOperations();
+ flush();
const approveButton =
element.shadowRoot
.querySelector('gr-button[data-action-key=\'review\']');
@@ -1699,7 +1711,7 @@
bar: [' 0', '+1', '+2'],
},
};
- flushAsynchronousOperations();
+ flush();
const approveButton =
element.shadowRoot
.querySelector('gr-button[data-action-key=\'review\']');
@@ -1712,7 +1724,7 @@
element.addEventListener('download-tap', handler);
assert.ok(element.revisionActions.download);
element._handleDownloadTap();
- flushAsynchronousOperations();
+ flush();
assert.isTrue(handler.called);
});
@@ -1740,7 +1752,7 @@
assert.strictEqual(
element.$.moreActions.items[0].id, 'cherrypick-revision');
element.setActionOverflow('revision', 'cherrypick', false);
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('[data-action-key="cherrypick"]'));
assert.notEqual(
@@ -1751,7 +1763,7 @@
assert.isOk(element.shadowRoot
.querySelector('[data-action-key="submit"]'));
element.setActionOverflow('revision', 'submit', true);
- flushAsynchronousOperations();
+ flush();
assert.isNotOk(element.shadowRoot
.querySelector('[data-action-key="submit"]'));
assert.strictEqual(
@@ -1802,10 +1814,11 @@
element.changeNum = 42;
element.change._number = 42;
element.latestPatchNum = 12;
- element.change = generateChange({
- revisionsCount: element.latestPatchNum,
- messagesCount: 1,
- });
+ element.change = {
+ ...createChange(),
+ revisions: createRevisions(element.latestPatchNum),
+ messages: createChangeMessages(1),
+ };
payload = {foo: 'bar'};
onShowError = sinon.stub();
@@ -1818,12 +1831,12 @@
let sendStub;
setup(() => {
sinon.stub(element.$.restAPI, 'getChangeDetail')
- .returns(Promise.resolve(
- generateChange({
- // element has latest info
- revisionsCount: element.latestPatchNum,
- messagesCount: 1,
- })));
+ .returns(Promise.resolve({
+ ...createChange(),
+ // element has latest info
+ revisions: createRevisions(element.latestPatchNum),
+ messages: createChangeMessages(1),
+ }));
sendStub = sinon.stub(element.$.restAPI, 'executeChangeAction')
.returns(Promise.resolve({}));
getResponseObjectStub = sinon.stub(element.$.restAPI,
@@ -1832,16 +1845,12 @@
'navigateToChange').returns(Promise.resolve(true));
});
- test('change action', done => {
- element
- ._send('DELETE', payload, '/endpoint', false, cleanup)
- .then(() => {
- assert.isFalse(onShowError.called);
- assert.isTrue(cleanup.calledOnce);
- assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
- null, payload));
- done();
- });
+ test('change action', async () => {
+ await element._send('DELETE', payload, '/endpoint', false, cleanup);
+ assert.isFalse(onShowError.called);
+ assert.isTrue(cleanup.calledOnce);
+ assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
+ undefined, payload));
});
suite('show revert submission dialog', () => {
@@ -1941,12 +1950,12 @@
suite('failure modes', () => {
test('non-latest', () => {
sinon.stub(element.$.restAPI, 'getChangeDetail')
- .returns(Promise.resolve(
- generateChange({
- // new patchset was uploaded
- revisionsCount: element.latestPatchNum + 1,
- messagesCount: 1,
- })));
+ .returns(Promise.resolve({
+ ...createChange(),
+ // new patchset was uploaded
+ revisions: createRevisions(element.latestPatchNum + 1),
+ messages: createChangeMessages(1),
+ }));
const sendStub = sinon.stub(element.$.restAPI,
'executeChangeAction');
@@ -1961,12 +1970,12 @@
test('send fails', () => {
sinon.stub(element.$.restAPI, 'getChangeDetail')
- .returns(Promise.resolve(
- generateChange({
- // element has latest info
- revisionsCount: element.latestPatchNum,
- messagesCount: 1,
- })));
+ .returns(Promise.resolve({
+ ...createChange(),
+ // element has latest info
+ revisions: createRevisions(element.latestPatchNum),
+ messages: createChangeMessages(1),
+ }));
const sendStub = sinon.stub(element.$.restAPI,
'executeChangeAction').callsFake(
(num, method, patchNum, endpoint, payload, onErr) => {
@@ -1988,6 +1997,13 @@
test('_handleAction reports', () => {
sinon.stub(element, '_fireAction');
+ element.actions = {
+ key: {
+ __key: 'key',
+ __type: 'type',
+ },
+ };
+
const reportStub = sinon.stub(element.reporting, 'reportInteraction');
element._handleAction('type', 'key');
assert.isTrue(reportStub.called);
@@ -2011,7 +2027,7 @@
getProjectConfig() { return Promise.resolve({}); },
});
- sinon.stub(pluginLoader, 'awaitPluginsLoaded')
+ sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
.returns(Promise.resolve());
element = basicFixture.instantiate();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
index 87e100c..e812657 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.js
@@ -18,9 +18,8 @@
import '../../../test/common-test-setup-karma.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import './gr-change-metadata.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {resetPlugins} from '../../../test/test-utils.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
const testHtmlPlugin = document.createElement('dom-module');
@@ -76,7 +75,7 @@
const getStyle = function(selector, name) {
return window.getComputedStyle(
- dom(element.root).querySelector(selector))[name];
+ element.root.querySelector(selector))[name];
};
function createElement() {
@@ -118,10 +117,11 @@
plugin.registerStyleModule('change-metadata', 'my-plugin-style');
}, undefined, 'http://test.com/plugins/style.js');
element = createElement();
- pluginLoader.loadPlugins([]);
- pluginLoader.awaitPluginsLoaded().then(() => {
- flush(done);
- });
+ getPluginLoader().loadPlugins([]);
+ getPluginLoader().awaitPluginsLoaded()
+ .then(() => {
+ flush(done);
+ });
});
teardown(() => {
@@ -144,8 +144,8 @@
plugin = p;
plugin.registerStyleModule('change-metadata', 'my-plugin-style');
}, undefined, 'http://test.com/plugins/style.js');
- sinon.stub(pluginLoader, 'arePluginsLoaded').returns(true);
- pluginLoader.loadPlugins([]);
+ sinon.stub(getPluginLoader(), 'arePluginsLoaded').returns(true);
+ getPluginLoader().loadPlugins([]);
element = createElement();
});
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
deleted file mode 100644
index bee7721..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ /dev/null
@@ -1,537 +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 '../../../styles/shared-styles.js';
-import '../../../styles/gr-change-metadata-shared-styles.js';
-import '../../../styles/gr-change-view-integration-shared-styles.js';
-import '../../../styles/gr-voting-styles.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../plugins/gr-external-style/gr-external-style.js';
-import '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-editable-label/gr-editable-label.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-limited-text/gr-limited-text.js';
-import '../../shared/gr-linked-chip/gr-linked-chip.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-change-requirements/gr-change-requirements.js';
-import '../gr-commit-info/gr-commit-info.js';
-import '../gr-reviewer-list/gr-reviewer-list.js';
-import '../../shared/gr-account-list/gr-account-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-metadata_html.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
-
-const SubmitTypeLabel = {
- FAST_FORWARD_ONLY: 'Fast Forward Only',
- MERGE_IF_NECESSARY: 'Merge if Necessary',
- REBASE_IF_NECESSARY: 'Rebase if Necessary',
- MERGE_ALWAYS: 'Always Merge',
- REBASE_ALWAYS: 'Rebase Always',
- CHERRY_PICK: 'Cherry Pick',
-};
-
-const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
-
-/**
- * @enum {string}
- */
-const CertificateStatus = {
- /**
- * This certificate status is bad.
- */
- BAD: 'BAD',
- /**
- * This certificate status is OK.
- */
- OK: 'OK',
- /**
- * This certificate status is TRUSTED.
- */
- TRUSTED: 'TRUSTED',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrChangeMetadata extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-metadata'; }
- /**
- * Fired when the change topic is changed.
- *
- * @event topic-changed
- */
-
- static get properties() {
- return {
- /** @type {?} */
- change: Object,
- labels: {
- type: Object,
- notify: true,
- },
- account: Object,
- /** @type {?} */
- revision: Object,
- commitInfo: Object,
- _mutable: {
- type: Boolean,
- computed: '_computeIsMutable(account)',
- },
- /** @type {?} */
- serverConfig: Object,
- parentIsCurrent: Boolean,
- _notCurrentMessage: {
- type: String,
- value: NOT_CURRENT_MESSAGE,
- readOnly: true,
- },
- _topicReadOnly: {
- type: Boolean,
- computed: '_computeTopicReadOnly(_mutable, change)',
- },
- _hashtagReadOnly: {
- type: Boolean,
- computed: '_computeHashtagReadOnly(_mutable, change)',
- },
- /**
- * @type {Gerrit.PushCertificateValidation}
- */
- _pushCertificateValidation: {
- type: Object,
- computed: '_computePushCertificateValidation(serverConfig, change)',
- },
- _showRequirements: {
- type: Boolean,
- computed: '_computeShowRequirements(change)',
- },
-
- _assignee: Array,
- _isWip: {
- type: Boolean,
- computed: '_computeIsWip(change)',
- },
- _newHashtag: String,
-
- _settingTopic: {
- type: Boolean,
- value: false,
- },
-
- _currentParents: {
- type: Array,
- computed: '_computeParents(change, revision)',
- },
-
- /** @type {?} */
- _CHANGE_ROLE: {
- type: Object,
- readOnly: true,
- value: {
- OWNER: 'owner',
- UPLOADER: 'uploader',
- AUTHOR: 'author',
- COMMITTER: 'committer',
- },
- },
- };
- }
-
- static get observers() {
- return [
- '_changeChanged(change)',
- '_labelsChanged(change.labels)',
- '_assigneeChanged(_assignee.*)',
- ];
- }
-
- _labelsChanged(labels) {
- this.labels = Object.assign({}, labels) || null;
- }
-
- _changeChanged(change) {
- this._assignee = change.assignee ? [change.assignee] : [];
- }
-
- _assigneeChanged(assigneeRecord) {
- if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
- return;
- }
- const assignee = assigneeRecord.base;
- if (assignee.length) {
- const acct = assignee[0];
- if (this.change.assignee &&
- acct._account_id === this.change.assignee._account_id) { return; }
- this.set(['change', 'assignee'], acct);
- this.$.restAPI.setAssignee(this.change._number, acct._account_id);
- } else {
- if (!this.change.assignee) { return; }
- this.set(['change', 'assignee'], undefined);
- this.$.restAPI.deleteAssignee(this.change._number);
- }
- }
-
- _computeHideStrategy(change) {
- return !changeIsOpen(change);
- }
-
- /**
- * @param {Object} commitInfo
- * @return {?Array} If array is empty, returns null instead so
- * an existential check can be used to hide or show the webLinks
- * section.
- */
- _computeWebLinks(commitInfo, serverConfig) {
- if (!commitInfo) { return null; }
- const weblinks = GerritNav.getChangeWeblinks(
- this.change ? this.change.repo : '',
- commitInfo.commit,
- {
- weblinks: commitInfo.web_links,
- config: serverConfig,
- });
- return weblinks.length ? weblinks : null;
- }
-
- _isAssigneeEnabled(serverConfig) {
- return serverConfig && serverConfig.change
- && !!serverConfig.change.enable_assignee;
- }
-
- _computeStrategy(change) {
- return SubmitTypeLabel[change.submit_type];
- }
-
- _computeLabelNames(labels) {
- return Object.keys(labels).sort();
- }
-
- _handleTopicChanged(e, topic) {
- const lastTopic = this.change.topic;
- if (!topic.length) { topic = null; }
- this._settingTopic = true;
- this.$.restAPI.setChangeTopic(this.change._number, topic)
- .then(newTopic => {
- this._settingTopic = false;
- this.set(['change', 'topic'], newTopic);
- if (newTopic !== lastTopic) {
- this.dispatchEvent(new CustomEvent(
- 'topic-changed', {bubbles: true, composed: true}));
- }
- });
- }
-
- _showAddTopic(changeRecord, settingTopic) {
- const hasTopic = !!changeRecord &&
- !!changeRecord.base && !!changeRecord.base.topic;
- return !hasTopic && !settingTopic;
- }
-
- _showTopicChip(changeRecord, settingTopic) {
- const hasTopic = !!changeRecord &&
- !!changeRecord.base && !!changeRecord.base.topic;
- return hasTopic && !settingTopic;
- }
-
- _showCherryPickOf(changeRecord) {
- const hasCherryPickOf = !!changeRecord &&
- !!changeRecord.base && !!changeRecord.base.cherry_pick_of_change &&
- !!changeRecord.base.cherry_pick_of_patch_set;
- return hasCherryPickOf;
- }
-
- _handleHashtagChanged(e) {
- const lastHashtag = this.change.hashtag;
- if (!this._newHashtag.length) { return; }
- const newHashtag = this._newHashtag;
- this._newHashtag = '';
- this.$.restAPI.setChangeHashtag(
- this.change._number, {add: [newHashtag]}).then(newHashtag => {
- this.set(['change', 'hashtags'], newHashtag);
- if (newHashtag !== lastHashtag) {
- this.dispatchEvent(
- new CustomEvent('hashtag-changed', {
- bubbles: true, composed: true}));
- }
- });
- }
-
- _computeTopicReadOnly(mutable, change) {
- return !mutable ||
- !change ||
- !change.actions ||
- !change.actions.topic ||
- !change.actions.topic.enabled;
- }
-
- _computeHashtagReadOnly(mutable, change) {
- return !mutable ||
- !change ||
- !change.actions ||
- !change.actions.hashtags ||
- !change.actions.hashtags.enabled;
- }
-
- _computeAssigneeReadOnly(mutable, change) {
- return !mutable ||
- !change ||
- !change.actions ||
- !change.actions.assignee ||
- !change.actions.assignee.enabled;
- }
-
- _computeTopicPlaceholder(_topicReadOnly) {
- // Action items in Material Design are uppercase -- placeholder label text
- // is sentence case.
- return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
- }
-
- _computeHashtagPlaceholder(_hashtagReadOnly) {
- return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
- }
-
- _computeShowRequirements(change) {
- if (change.status !== ChangeStatus.NEW) {
- // TODO(maximeg) change this to display the stored
- // requirements, once it is implemented server-side.
- return false;
- }
- const hasRequirements = !!change.requirements &&
- Object.keys(change.requirements).length > 0;
- const hasLabels = !!change.labels &&
- Object.keys(change.labels).length > 0;
- return hasRequirements || hasLabels || !!change.work_in_progress;
- }
-
- /**
- * @return {?Gerrit.PushCertificateValidation} object representing data for
- * the push validation.
- */
- _computePushCertificateValidation(serverConfig, change) {
- if (!change || !serverConfig || !serverConfig.receive ||
- !serverConfig.receive.enable_signed_push) {
- return null;
- }
- const rev = change.revisions[change.current_revision];
- if (!rev.push_certificate || !rev.push_certificate.key) {
- return {
- class: 'help',
- icon: 'gr-icons:help',
- message: 'This patch set was created without a push certificate',
- };
- }
-
- const key = rev.push_certificate.key;
- switch (key.status) {
- case CertificateStatus.BAD:
- return {
- class: 'invalid',
- icon: 'gr-icons:close',
- message: this._problems('Push certificate is invalid', key),
- };
- case CertificateStatus.OK:
- return {
- class: 'notTrusted',
- icon: 'gr-icons:info',
- message: this._problems(
- 'Push certificate is valid, but key is not trusted', key),
- };
- case CertificateStatus.TRUSTED:
- return {
- class: 'trusted',
- icon: 'gr-icons:check',
- message: this._problems(
- 'Push certificate is valid and key is trusted', key),
- };
- default:
- throw new Error(`unknown certificate status: ${key.status}`);
- }
- }
-
- _problems(msg, key) {
- if (!key || !key.problems || key.problems.length === 0) {
- return msg;
- }
-
- return [msg + ':'].concat(key.problems).join('\n');
- }
-
- _computeShowRepoBranchTogether(repo, branch) {
- return !!repo && !!branch && repo.length + branch.length < 40;
- }
-
- _computeProjectUrl(project) {
- return GerritNav.getUrlForProjectChanges(project);
- }
-
- _computeBranchUrl(project, branch) {
- if (!this.change || !this.change.status) return '';
- return GerritNav.getUrlForBranch(branch, project,
- this.change.status == ChangeStatus.NEW ? 'open' :
- this.change.status.toLowerCase());
- }
-
- _computeCherryPickOfUrl(change, patchset, project) {
- return GerritNav.getUrlForChangeById(change, project, patchset);
- }
-
- _computeTopicUrl(topic) {
- return GerritNav.getUrlForTopic(topic);
- }
-
- _computeHashtagUrl(hashtag) {
- return GerritNav.getUrlForHashtag(hashtag);
- }
-
- _handleTopicRemoved(e) {
- const target = dom(e).rootTarget;
- target.disabled = true;
- this.$.restAPI.setChangeTopic(this.change._number, null)
- .then(() => {
- target.disabled = false;
- this.set(['change', 'topic'], '');
- this.dispatchEvent(
- new CustomEvent('topic-changed',
- {bubbles: true, composed: true}));
- })
- .catch(err => {
- target.disabled = false;
- return;
- });
- }
-
- _handleHashtagRemoved(e) {
- e.preventDefault();
- const target = dom(e).rootTarget;
- target.disabled = true;
- this.$.restAPI.setChangeHashtag(this.change._number,
- {remove: [target.text]})
- .then(newHashtag => {
- target.disabled = false;
- this.set(['change', 'hashtags'], newHashtag);
- })
- .catch(err => {
- target.disabled = false;
- return;
- });
- }
-
- _computeIsWip(change) {
- return !!change.work_in_progress;
- }
-
- _computeShowRoleClass(change, role) {
- return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
- }
-
- /**
- * Get the user with the specified role on the change. Returns null if the
- * user with that role is the same as the owner.
- *
- * @param {!Object} change
- * @param {string} role One of the values from _CHANGE_ROLE
- * @return {Object|null} either an account or null.
- */
- _getNonOwnerRole(change, role) {
- if (!change || !change.current_revision ||
- !change.revisions[change.current_revision]) {
- return null;
- }
-
- const rev = change.revisions[change.current_revision];
- if (!rev) { return null; }
-
- if (role === this._CHANGE_ROLE.UPLOADER &&
- rev.uploader &&
- change.owner._account_id !== rev.uploader._account_id) {
- return rev.uploader;
- }
-
- if (role === this._CHANGE_ROLE.AUTHOR &&
- rev.commit && rev.commit.author &&
- change.owner.email !== rev.commit.author.email) {
- return rev.commit.author;
- }
-
- if (role === this._CHANGE_ROLE.COMMITTER &&
- rev.commit && rev.commit.committer &&
- change.owner.email !== rev.commit.committer.email) {
- return rev.commit.committer;
- }
-
- return null;
- }
-
- _computeParents(change, revision) {
- if (!revision || !revision.commit) {
- if (!change || !change.current_revision) { return []; }
- revision = change.revisions[change.current_revision];
- if (!revision || !revision.commit) { return []; }
- }
- return revision.commit.parents;
- }
-
- _computeParentsLabel(parents) {
- return parents && parents.length > 1 ? 'Parents' : 'Parent';
- }
-
- _computeParentListClass(parents, parentIsCurrent) {
- // Undefined check for polymer 2
- if (parents === undefined || parentIsCurrent === undefined) {
- return '';
- }
-
- return [
- 'parentList',
- parents && parents.length > 1 ? 'merge' : 'nonMerge',
- parentIsCurrent ? 'current' : 'notCurrent',
- ].join(' ');
- }
-
- _computeIsMutable(account) {
- return !!Object.keys(account).length;
- }
-
- editTopic() {
- if (this._topicReadOnly || this.change.topic) { return; }
- // Cannot use `this.$.ID` syntax because the element exists inside of a
- // dom-if.
- this.shadowRoot.querySelector('.topicEditableLabel').open();
- }
-
- _getReviewerSuggestionsProvider(change) {
- const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
- change._number, SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
- provider.init();
- return provider;
- }
-}
-
-customElements.define(GrChangeMetadata.is, GrChangeMetadata);
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
new file mode 100644
index 0000000..f8a5940
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -0,0 +1,686 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../../styles/gr-change-metadata-shared-styles';
+import '../../../styles/gr-change-view-integration-shared-styles';
+import '../../../styles/gr-voting-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../plugins/gr-external-style/gr-external-style';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-editable-label/gr-editable-label';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-limited-text/gr-limited-text';
+import '../../shared/gr-linked-chip/gr-linked-chip';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-change-requirements/gr-change-requirements';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-reviewer-list/gr-reviewer-list';
+import '../../shared/gr-account-list/gr-account-list';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-metadata_html';
+import {
+ GrReviewerSuggestionsProvider,
+ SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+ ChangeStatus,
+ GpgKeyInfoStatus,
+ SubmitType,
+} from '../../../constants/constants';
+import {changeIsOpen} from '../../../utils/change-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+ EditRevisionInfo,
+ ParsedChangeInfo,
+} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+ AccountDetailInfo,
+ AccountInfo,
+ BranchName,
+ CommitId,
+ CommitInfo,
+ ElementPropertyDeepChange,
+ GpgKeyInfo,
+ Hashtag,
+ LabelNameToInfoMap,
+ NumericChangeId,
+ ParentCommitInfo,
+ PatchSetNum,
+ RepoName,
+ RevisionInfo,
+ ServerInfo,
+ TopicName,
+} from '../../../types/common';
+import {assertNever} from '../../../utils/common-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrEditableLabel} from '../../shared/gr-editable-label/gr-editable-label';
+import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
+
+const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
+
+enum ChangeRole {
+ OWNER = 'owner',
+ UPLOADER = 'uploader',
+ AUTHOR = 'author',
+ COMMITTER = 'committer',
+}
+
+export interface CommitInfoWithRequiredCommit extends CommitInfo {
+ // gr-change-view always assigns commit to CommitInfo
+ commit: CommitId;
+}
+
+const SubmitTypeLabel = new Map<SubmitType, string>([
+ [SubmitType.FAST_FORWARD_ONLY, 'Fast Forward Only'],
+ [SubmitType.MERGE_IF_NECESSARY, 'Merge if Necessary'],
+ [SubmitType.REBASE_IF_NECESSARY, 'Rebase if Necessary'],
+ [SubmitType.MERGE_ALWAYS, 'Always Merge'],
+ [SubmitType.REBASE_ALWAYS, 'Rebase Always'],
+ [SubmitType.CHERRY_PICK, 'Cherry Pick'],
+]);
+
+const NOT_CURRENT_MESSAGE = 'Not current - rebase possible';
+
+interface PushCertifacteValidationInfo {
+ class: string;
+ icon: string;
+ message: string;
+}
+
+export interface GrChangeMetadata {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-change-metadata')
+export class GrChangeMetadata extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the change topic is changed.
+ *
+ * @event topic-changed
+ */
+
+ @property({type: Object})
+ change?: ParsedChangeInfo;
+
+ @property({type: Object, notify: true})
+ labels?: LabelNameToInfoMap;
+
+ @property({type: Object})
+ account?: AccountDetailInfo;
+
+ @property({type: Object})
+ revision?: RevisionInfo | EditRevisionInfo;
+
+ @property({type: Object})
+ commitInfo?: CommitInfoWithRequiredCommit;
+
+ @property({type: Boolean, computed: '_computeIsMutable(account)'})
+ _mutable = false;
+
+ @property({type: Object})
+ serverConfig?: ServerInfo;
+
+ @property({type: Boolean})
+ parentIsCurrent?: boolean;
+
+ @property({type: String})
+ readonly _notCurrentMessage = NOT_CURRENT_MESSAGE;
+
+ @property({
+ type: Boolean,
+ computed: '_computeTopicReadOnly(_mutable, change)',
+ })
+ _topicReadOnly = true;
+
+ @property({
+ type: Boolean,
+ computed: '_computeHashtagReadOnly(_mutable, change)',
+ })
+ _hashtagReadOnly = true;
+
+ @property({
+ type: Object,
+ computed: '_computePushCertificateValidation(serverConfig, change)',
+ })
+ _pushCertificateValidation: PushCertifacteValidationInfo | null = null;
+
+ @property({type: Boolean, computed: '_computeShowRequirements(change)'})
+ _showRequirements = false;
+
+ @property({type: Array})
+ _assignee?: AccountInfo[];
+
+ @property({type: Boolean, computed: '_computeIsWip(change)'})
+ _isWip = false;
+
+ @property({type: String})
+ _newHashtag?: Hashtag;
+
+ @property({type: Boolean})
+ _settingTopic = false;
+
+ @property({type: Array, computed: '_computeParents(change, revision)'})
+ _currentParents: ParentCommitInfo[] = [];
+
+ @property({type: Object})
+ _CHANGE_ROLE = ChangeRole;
+
+ @observe('change.labels')
+ _labelsChanged(labels?: LabelNameToInfoMap) {
+ this.labels = {...labels} || null;
+ }
+
+ @observe('change')
+ _changeChanged(change?: ParsedChangeInfo) {
+ this._assignee = change?.assignee ? [change.assignee] : [];
+ this._settingTopic = false;
+ }
+
+ @observe('_assignee.*')
+ _assigneeChanged(
+ assigneeRecord: ElementPropertyDeepChange<GrChangeMetadata, '_assignee'>
+ ) {
+ if (!this.change || !this._isAssigneeEnabled(this.serverConfig)) {
+ return;
+ }
+ const assignee = assigneeRecord.base;
+ if (assignee?.length) {
+ const acct = assignee[0];
+ if (
+ !acct._account_id ||
+ (this.change.assignee &&
+ acct._account_id === this.change.assignee._account_id)
+ ) {
+ return;
+ }
+ this.set(['change', 'assignee'], acct);
+ this.$.restAPI.setAssignee(this.change._number, acct._account_id);
+ } else {
+ if (!this.change.assignee) {
+ return;
+ }
+ this.set(['change', 'assignee'], undefined);
+ this.$.restAPI.deleteAssignee(this.change._number);
+ }
+ }
+
+ _computeHideStrategy(change?: ParsedChangeInfo) {
+ return !changeIsOpen(change);
+ }
+
+ /**
+ * @return If array is empty, returns null instead so
+ * an existential check can be used to hide or show the webLinks
+ * section.
+ */
+ _computeWebLinks(
+ commitInfo?: CommitInfoWithRequiredCommit,
+ serverConfig?: ServerInfo
+ ) {
+ if (!commitInfo) {
+ return null;
+ }
+ const weblinks = GerritNav.getChangeWeblinks(
+ this.change ? this.change.project : ('' as RepoName),
+ commitInfo.commit,
+ {
+ weblinks: commitInfo.web_links,
+ config: serverConfig,
+ }
+ );
+ return weblinks.length ? weblinks : null;
+ }
+
+ _isAssigneeEnabled(serverConfig?: ServerInfo) {
+ return (
+ serverConfig &&
+ serverConfig.change &&
+ !!serverConfig.change.enable_assignee
+ );
+ }
+
+ _computeStrategy(change?: ParsedChangeInfo) {
+ if (!change?.submit_type) {
+ return '';
+ }
+
+ return SubmitTypeLabel.get(change.submit_type);
+ }
+
+ _computeLabelNames(labels?: LabelNameToInfoMap) {
+ return labels ? Object.keys(labels).sort() : [];
+ }
+
+ _handleTopicChanged(e: CustomEvent<string>) {
+ if (!this.change) {
+ throw new Error('change must be set');
+ }
+ const lastTopic = this.change.topic;
+ const topic = e.detail.length ? e.detail : null;
+ this._settingTopic = true;
+ const topicChangedForChangeNumber = this.change._number;
+ this.$.restAPI
+ .setChangeTopic(topicChangedForChangeNumber, topic)
+ .then(newTopic => {
+ if (
+ !this.change ||
+ this.change._number !== topicChangedForChangeNumber
+ ) {
+ return;
+ }
+ this._settingTopic = false;
+ this.set(['change', 'topic'], newTopic);
+ if (newTopic !== lastTopic) {
+ this.dispatchEvent(
+ new CustomEvent('topic-changed', {bubbles: true, composed: true})
+ );
+ }
+ });
+ }
+
+ _showAddTopic(
+ changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+ settingTopic?: boolean
+ ) {
+ const hasTopic =
+ !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+ return !hasTopic && !settingTopic;
+ }
+
+ _showTopicChip(
+ changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>,
+ settingTopic?: boolean
+ ) {
+ const hasTopic =
+ !!changeRecord && !!changeRecord.base && !!changeRecord.base.topic;
+ return hasTopic && !settingTopic;
+ }
+
+ _showCherryPickOf(
+ changeRecord: ElementPropertyDeepChange<GrChangeMetadata, 'change'>
+ ) {
+ const hasCherryPickOf =
+ !!changeRecord &&
+ !!changeRecord.base &&
+ !!changeRecord.base.cherry_pick_of_change &&
+ !!changeRecord.base.cherry_pick_of_patch_set;
+ return hasCherryPickOf;
+ }
+
+ _handleHashtagChanged() {
+ if (!this.change) {
+ throw new Error('change must be set');
+ }
+ if (!this._newHashtag?.length) {
+ return;
+ }
+ const newHashtag = this._newHashtag;
+ this._newHashtag = '' as Hashtag;
+ this.$.restAPI
+ .setChangeHashtag(this.change._number, {add: [newHashtag]})
+ .then(newHashtag => {
+ this.set(['change', 'hashtags'], newHashtag);
+ this.dispatchEvent(
+ new CustomEvent('hashtag-changed', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ });
+ }
+
+ _computeTopicReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+ return (
+ !mutable ||
+ !change ||
+ !change.actions ||
+ !change.actions.topic ||
+ !change.actions.topic.enabled
+ );
+ }
+
+ _computeHashtagReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+ return (
+ !mutable ||
+ !change ||
+ !change.actions ||
+ !change.actions.hashtags ||
+ !change.actions.hashtags.enabled
+ );
+ }
+
+ _computeAssigneeReadOnly(mutable?: boolean, change?: ParsedChangeInfo) {
+ return (
+ !mutable ||
+ !change ||
+ !change.actions ||
+ !change.actions.assignee ||
+ !change.actions.assignee.enabled
+ );
+ }
+
+ _computeTopicPlaceholder(_topicReadOnly?: boolean) {
+ // Action items in Material Design are uppercase -- placeholder label text
+ // is sentence case.
+ return _topicReadOnly ? 'No topic' : 'ADD TOPIC';
+ }
+
+ _computeHashtagPlaceholder(_hashtagReadOnly?: boolean) {
+ return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
+ }
+
+ _computeShowRequirements(change?: ParsedChangeInfo) {
+ if (!change) {
+ return false;
+ }
+ if (change.status !== ChangeStatus.NEW) {
+ // TODO(maximeg) change this to display the stored
+ // requirements, once it is implemented server-side.
+ return false;
+ }
+ const hasRequirements =
+ !!change.requirements && Object.keys(change.requirements).length > 0;
+ const hasLabels = !!change.labels && Object.keys(change.labels).length > 0;
+ return hasRequirements || hasLabels || !!change.work_in_progress;
+ }
+
+ /**
+ * @return object representing data for the push validation.
+ */
+ _computePushCertificateValidation(
+ serverConfig?: ServerInfo,
+ change?: ParsedChangeInfo
+ ): PushCertifacteValidationInfo | null {
+ if (
+ !change ||
+ !serverConfig ||
+ !serverConfig.receive ||
+ !serverConfig.receive.enable_signed_push
+ ) {
+ return null;
+ }
+ const rev = change.revisions[change.current_revision];
+ if (!rev.push_certificate || !rev.push_certificate.key) {
+ return {
+ class: 'help',
+ icon: 'gr-icons:help',
+ message: 'This patch set was created without a push certificate',
+ };
+ }
+
+ const key = rev.push_certificate.key;
+ switch (key.status) {
+ case GpgKeyInfoStatus.BAD:
+ return {
+ class: 'invalid',
+ icon: 'gr-icons:close',
+ message: this._problems('Push certificate is invalid', key),
+ };
+ case GpgKeyInfoStatus.OK:
+ return {
+ class: 'notTrusted',
+ icon: 'gr-icons:info',
+ message: this._problems(
+ 'Push certificate is valid, but key is not trusted',
+ key
+ ),
+ };
+ case GpgKeyInfoStatus.TRUSTED:
+ return {
+ class: 'trusted',
+ icon: 'gr-icons:check',
+ message: this._problems(
+ 'Push certificate is valid and key is trusted',
+ key
+ ),
+ };
+ case undefined:
+ // TODO(TS): Process it correctly
+ throw new Error('deleted certificate');
+ default:
+ assertNever(key.status, `unknown certificate status: ${key.status}`);
+ }
+ }
+
+ _problems(msg: string, key: GpgKeyInfo) {
+ if (!key || !key.problems || key.problems.length === 0) {
+ return msg;
+ }
+
+ return [msg + ':'].concat(key.problems).join('\n');
+ }
+
+ _computeShowRepoBranchTogether(repo?: RepoName, branch?: BranchName) {
+ return !!repo && !!branch && repo.length + branch.length < 40;
+ }
+
+ _computeProjectUrl(project?: RepoName) {
+ if (!project) return '';
+ return GerritNav.getUrlForProjectChanges(project);
+ }
+
+ _computeBranchUrl(project?: RepoName, branch?: BranchName) {
+ if (!project || !branch || !this.change || !this.change.status) return '';
+ return GerritNav.getUrlForBranch(
+ branch,
+ project,
+ this.change.status === ChangeStatus.NEW
+ ? 'open'
+ : this.change.status.toLowerCase()
+ );
+ }
+
+ _computeCherryPickOfUrl(
+ change?: NumericChangeId,
+ patchset?: PatchSetNum,
+ project?: RepoName
+ ) {
+ if (!change || !project) {
+ return '';
+ }
+ return GerritNav.getUrlForChangeById(change, project, patchset);
+ }
+
+ _computeTopicUrl(topic: TopicName) {
+ return GerritNav.getUrlForTopic(topic);
+ }
+
+ _computeHashtagUrl(hashtag: Hashtag) {
+ return GerritNav.getUrlForHashtag(hashtag);
+ }
+
+ _handleTopicRemoved(e: CustomEvent) {
+ if (!this.change) {
+ throw new Error('change must be set');
+ }
+ const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+ target.disabled = true;
+ this.$.restAPI
+ .setChangeTopic(this.change._number, null)
+ .then(() => {
+ target.disabled = false;
+ this.set(['change', 'topic'], '');
+ this.dispatchEvent(
+ new CustomEvent('topic-changed', {bubbles: true, composed: true})
+ );
+ })
+ .catch(() => {
+ target.disabled = false;
+ return;
+ });
+ }
+
+ _handleHashtagRemoved(e: CustomEvent) {
+ e.preventDefault();
+ if (!this.change) {
+ throw new Error('change must be set');
+ }
+ const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+ target.disabled = true;
+ this.$.restAPI
+ .setChangeHashtag(this.change._number, {remove: [target.text as Hashtag]})
+ .then(newHashtags => {
+ target.disabled = false;
+ this.set(['change', 'hashtags'], newHashtags);
+ })
+ .catch(() => {
+ target.disabled = false;
+ return;
+ });
+ }
+
+ _computeIsWip(change?: ParsedChangeInfo) {
+ return change && !!change.work_in_progress;
+ }
+
+ _computeShowRoleClass(change?: ParsedChangeInfo, role?: ChangeRole) {
+ return this._getNonOwnerRole(change, role) ? '' : 'hideDisplay';
+ }
+
+ /**
+ * Get the user with the specified role on the change. Returns null if the
+ * user with that role is the same as the owner.
+ */
+ _getNonOwnerRole(change?: ParsedChangeInfo, role?: ChangeRole) {
+ if (
+ !change ||
+ !change.current_revision ||
+ !change.revisions[change.current_revision]
+ ) {
+ return null;
+ }
+
+ const rev = change.revisions[change.current_revision];
+ if (!rev) {
+ return null;
+ }
+
+ if (
+ role === ChangeRole.UPLOADER &&
+ rev.uploader &&
+ change.owner._account_id !== rev.uploader._account_id
+ ) {
+ return rev.uploader;
+ }
+
+ if (
+ role === ChangeRole.AUTHOR &&
+ rev.commit?.author &&
+ change.owner.email !== rev.commit.author.email
+ ) {
+ return rev.commit.author;
+ }
+
+ if (
+ role === ChangeRole.COMMITTER &&
+ rev.commit?.committer &&
+ change.owner.email !== rev.commit.committer.email &&
+ !(
+ rev.uploader?.email && rev.uploader.email === rev.commit.committer.email
+ )
+ ) {
+ return rev.commit.committer;
+ }
+
+ return null;
+ }
+
+ _computeParents(
+ change?: ParsedChangeInfo,
+ revision?: RevisionInfo | EditRevisionInfo
+ ): ParentCommitInfo[] {
+ if (!revision || !revision.commit) {
+ if (!change || !change.current_revision) {
+ return [];
+ }
+ revision = change.revisions[change.current_revision];
+ if (!revision || !revision.commit) {
+ return [];
+ }
+ }
+ return revision.commit.parents;
+ }
+
+ _computeParentsLabel(parents?: ParentCommitInfo[]) {
+ return parents && parents.length > 1 ? 'Parents' : 'Parent';
+ }
+
+ _computeParentListClass(
+ parents?: ParentCommitInfo[],
+ parentIsCurrent?: boolean
+ ) {
+ // Undefined check for polymer 2
+ if (parents === undefined || parentIsCurrent === undefined) {
+ return '';
+ }
+
+ return [
+ 'parentList',
+ parents && parents.length > 1 ? 'merge' : 'nonMerge',
+ parentIsCurrent ? 'current' : 'notCurrent',
+ ].join(' ');
+ }
+
+ _computeIsMutable(account?: AccountDetailInfo) {
+ return account && !!Object.keys(account).length;
+ }
+
+ editTopic() {
+ if (this._topicReadOnly || !this.change || this.change.topic) {
+ return;
+ }
+ // Cannot use `this.$.ID` syntax because the element exists inside of a
+ // dom-if.
+ (this.shadowRoot!.querySelector(
+ '.topicEditableLabel'
+ ) as GrEditableLabel).open();
+ }
+
+ _getReviewerSuggestionsProvider(change?: ParsedChangeInfo) {
+ if (!change) {
+ return undefined;
+ }
+ const provider = GrReviewerSuggestionsProvider.create(
+ this.$.restAPI,
+ change._number,
+ SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY
+ );
+ provider.init();
+ return provider;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-metadata': GrChangeMetadata;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
index e96912d..f1d1127 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_html.ts
@@ -23,7 +23,6 @@
<style include="shared-styles">
:host {
display: table;
- --account-max-length: 20ch;
}
gr-change-requirements {
--requirements-horizontal-padding: var(--metadata-horizontal-padding);
@@ -92,7 +91,8 @@
--linked-chip-text-color: var(--link-color);
}
gr-reviewer-list {
- max-width: 200px;
+ --account-max-length: 120px;
+ max-width: 285px;
}
</style>
<gr-external-style id="externalStyle" name="change-metadata">
@@ -108,11 +108,11 @@
<section>
<span class="title">Owner</span>
<span class="value">
- <gr-account-link
+ <gr-account-chip
account="[[change.owner]]"
change="[[change]]"
highlight-attention
- ></gr-account-link>
+ ></gr-account-chip>
<template is="dom-if" if="[[_pushCertificateValidation]]">
<gr-tooltip-content
has-tooltip=""
@@ -130,31 +130,29 @@
<section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
<span class="title">Uploader</span>
<span class="value">
- <gr-account-link
+ <gr-account-chip
account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
change="[[change]]"
highlight-attention
- ></gr-account-link>
+ ></gr-account-chip>
</span>
</section>
<section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
<span class="title">Author</span>
<span class="value">
- <gr-account-link
+ <gr-account-chip
account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
change="[[change]]"
- highlight-attention
- ></gr-account-link>
+ ></gr-account-chip>
</span>
</section>
<section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
<span class="title">Committer</span>
<span class="value">
- <gr-account-link
+ <gr-account-chip
account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
change="[[change]]"
- highlight-attention
- ></gr-account-link>
+ ></gr-account-chip>
</span>
</section>
<template is="dom-if" if="[[_isAssigneeEnabled(serverConfig)]]">
@@ -323,6 +321,7 @@
href="[[_computeHashtagUrl(item)]]"
removable="[[!_hashtagReadOnly]]"
on-remove="_handleHashtagRemoved"
+ limit="40"
>
</gr-linked-chip>
</template>
@@ -339,6 +338,7 @@
</span>
</section>
<div class="separatedSection">
+ <h3 class="assistive-tech-only">Label Scores</h3>
<gr-change-requirements
change="{{change}}"
account="[[account]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
index 8ec3121..5bdf105 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.js
@@ -19,7 +19,7 @@
import '../../core/gr-router/gr-router.js';
import './gr-change-metadata.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
const basicFixture = fixtureFromElement('gr-change-metadata');
@@ -87,7 +87,7 @@
test('show strategy for open change', () => {
element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
- flushAsynchronousOperations();
+ flush();
const strategy = element.shadowRoot
.querySelector('.strategy');
assert.ok(strategy);
@@ -97,7 +97,7 @@
test('hide strategy for closed change', () => {
element.change = {status: 'MERGED', labels: {}};
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.shadowRoot
.querySelector('.strategy').hasAttribute('hidden'));
});
@@ -107,7 +107,7 @@
.returns([{name: 'stubb', url: '#s'}]);
element.commitInfo = {};
element.serverConfig = {};
- flushAsynchronousOperations();
+ flush();
const webLinks = element.$.webLinks;
assert.isTrue(weblinksStub.called);
assert.isFalse(webLinks.hasAttribute('hidden'));
@@ -117,7 +117,7 @@
test('weblinks hidden when no weblinks', () => {
element.commitInfo = {};
element.serverConfig = {};
- flushAsynchronousOperations();
+ flush();
const webLinks = element.$.webLinks;
assert.isTrue(webLinks.hasAttribute('hidden'));
});
@@ -125,7 +125,7 @@
test('weblinks hidden when only gitiles weblink', () => {
element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
element.serverConfig = {};
- flushAsynchronousOperations();
+ flush();
const webLinks = element.$.webLinks;
assert.isTrue(webLinks.hasAttribute('hidden'));
assert.equal(element._computeWebLinks(element.commitInfo), null);
@@ -139,7 +139,7 @@
primary_weblink_name: browser,
},
};
- flushAsynchronousOperations();
+ flush();
const webLinks = element.$.webLinks;
assert.isTrue(webLinks.hasAttribute('hidden'));
});
@@ -150,7 +150,7 @@
router._generateWeblinks.bind(router));
element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
- flushAsynchronousOperations();
+ flush();
const webLinks = element.$.webLinks;
assert.isFalse(webLinks.hasAttribute('hidden'));
assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
@@ -167,7 +167,7 @@
element.commitInfo = {
web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
- flushAsynchronousOperations();
+ flush();
const webLinks = element.$.webLinks;
assert.isFalse(webLinks.hasAttribute('hidden'));
// Only the non-gitiles weblink is returned.
@@ -210,13 +210,13 @@
test('_getNonOwnerRole that it does not return uploader', () => {
// Set the uploader email to be the same as the owner.
change.revisions.rev1.uploader._account_id = 1019328;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.UPLOADER));
});
test('_getNonOwnerRole null for uploader with no current rev', () => {
delete change.current_revision;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.UPLOADER));
});
@@ -235,33 +235,39 @@
suite('role=committer', () => {
test('_getNonOwnerRole for committer', () => {
+ change.revisions.rev1.uploader.email = 'ghh@def';
assert.deepEqual(
element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
{email: 'ghi@def'});
});
+ test('_getNonOwnerRole is null if committer is same as uploader', () => {
+ assert.isNotOk(element._getNonOwnerRole(change,
+ element._CHANGE_ROLE.COMMITTER));
+ });
+
test('_getNonOwnerRole that it does not return committer', () => {
// Set the committer email to be the same as the owner.
change.revisions.rev1.commit.committer.email = 'abc@def';
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.COMMITTER));
});
test('_getNonOwnerRole null for committer with no current rev', () => {
delete change.current_revision;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.COMMITTER));
});
test('_getNonOwnerRole null for committer with no commit', () => {
delete change.revisions.rev1.commit;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.COMMITTER));
});
test('_getNonOwnerRole null for committer with no committer', () => {
delete change.revisions.rev1.commit.committer;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.COMMITTER));
});
});
@@ -276,25 +282,25 @@
test('_getNonOwnerRole that it does not return author', () => {
// Set the author email to be the same as the owner.
change.revisions.rev1.commit.author.email = 'abc@def';
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.AUTHOR));
});
test('_getNonOwnerRole null for author with no current rev', () => {
delete change.current_revision;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.AUTHOR));
});
test('_getNonOwnerRole null for author with no commit', () => {
delete change.revisions.rev1.commit;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.AUTHOR));
});
test('_getNonOwnerRole null for author with no author', () => {
delete change.revisions.rev1.commit.author;
- assert.isNull(element._getNonOwnerRole(change,
+ assert.isNotOk(element._getNonOwnerRole(change,
element._CHANGE_ROLE.AUTHOR));
});
});
@@ -425,6 +431,7 @@
element.change = {
current_revision: '456',
revisions: {456: revision('111')},
+ owner: {},
};
element.revision = revision('222');
assert.equal(element._currentParents[0].commit, '222');
@@ -524,7 +531,7 @@
test('topic read only hides delete button', () => {
element.account = {};
element.change = change;
- flushAsynchronousOperations();
+ flush();
const button = element.shadowRoot
.querySelector('gr-linked-chip').shadowRoot
.querySelector('gr-button');
@@ -535,7 +542,7 @@
element.account = {test: true};
change.actions.topic.enabled = true;
element.change = change;
- flushAsynchronousOperations();
+ flush();
const button = element.shadowRoot
.querySelector('gr-linked-chip').shadowRoot
.querySelector('gr-button');
@@ -567,7 +574,7 @@
});
test('_computeHashtagReadOnly', () => {
- flushAsynchronousOperations();
+ flush();
let mutable = false;
assert.isTrue(element._computeHashtagReadOnly(mutable, change));
mutable = true;
@@ -579,10 +586,10 @@
});
test('hashtag read only hides delete button', () => {
- flushAsynchronousOperations();
+ flush();
element.account = {};
element.change = change;
- flushAsynchronousOperations();
+ flush();
const button = element.shadowRoot
.querySelector('gr-linked-chip').shadowRoot
.querySelector('gr-button');
@@ -590,11 +597,11 @@
});
test('hashtag not read only does not hide delete button', () => {
- flushAsynchronousOperations();
+ flush();
element.account = {test: true};
change.actions.hashtags.enabled = true;
element.change = change;
- flushAsynchronousOperations();
+ flush();
const button = element.shadowRoot
.querySelector('gr-linked-chip').shadowRoot
.querySelector('gr-button');
@@ -621,7 +628,7 @@
},
removable_reviewers: [],
};
- flushAsynchronousOperations();
+ flush();
});
suite('assignee field', () => {
@@ -686,7 +693,7 @@
const newTopic = 'the new topic';
sinon.stub(element.$.restAPI, 'setChangeTopic').returns(
Promise.resolve(newTopic));
- element._handleTopicChanged({}, newTopic);
+ element._handleTopicChanged({detail: newTopic});
const topicChangedSpy = sinon.spy();
element.addEventListener('topic-changed', topicChangedSpy);
assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
@@ -719,7 +726,7 @@
});
test('changing hashtag', () => {
- flushAsynchronousOperations();
+ flush();
element._newHashtag = 'new hashtag';
const newHashtag = ['new hashtag'];
sinon.stub(element.$.restAPI, 'setChangeHashtag').returns(
@@ -737,14 +744,14 @@
test('editTopic', () => {
element.account = {test: true};
element.change = {actions: {topic: {enabled: true}}};
- flushAsynchronousOperations();
+ flush();
const label = element.shadowRoot
.querySelector('.topicEditableLabel');
assert.ok(label);
sinon.stub(label, 'open');
element.editTopic();
- flushAsynchronousOperations();
+ flush();
assert.isTrue(label.open.called);
});
@@ -763,7 +770,7 @@
},
'0.1',
'http://some/plugins/url.html');
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
flush(() => {
assert.strictEqual(hookEl.plugin, plugin);
assert.strictEqual(hookEl.change, element.change);
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
deleted file mode 100644
index 8dfee81..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.js
+++ /dev/null
@@ -1,170 +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 '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-label/gr-label.js';
-import '../../shared/gr-label-info/gr-label-info.js';
-import '../../shared/gr-limited-text/gr-limited-text.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-requirements_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrChangeRequirements extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-requirements'; }
-
- static get properties() {
- return {
- /** @type {?} */
- change: Object,
- account: Object,
- mutable: Boolean,
- _requirements: {
- type: Array,
- computed: '_computeRequirements(change)',
- },
- _requiredLabels: {
- type: Array,
- value: () => [],
- },
- _optionalLabels: {
- type: Array,
- value: () => [],
- },
- _showWip: {
- type: Boolean,
- computed: '_computeShowWip(change)',
- },
- _showOptionalLabels: {
- type: Boolean,
- value: true,
- },
- };
- }
-
- static get observers() {
- return [
- '_computeLabels(change.labels.*)',
- ];
- }
-
- _computeShowWip(change) {
- return change.work_in_progress;
- }
-
- _computeRequirements(change) {
- const _requirements = [];
-
- if (change.requirements) {
- for (const requirement of change.requirements) {
- requirement.satisfied = requirement.status === 'OK';
- requirement.style =
- this._computeRequirementClass(requirement.satisfied);
- _requirements.push(requirement);
- }
- }
- if (change.work_in_progress) {
- _requirements.push({
- type: 'wip',
- fallback_text: 'Work-in-progress',
- tooltip: 'Change must not be in \'Work in Progress\' state.',
- });
- }
-
- return _requirements;
- }
-
- _computeRequirementClass(requirementStatus) {
- return requirementStatus ? 'approved' : '';
- }
-
- _computeRequirementIcon(requirementStatus) {
- return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
- }
-
- _computeLabels(labelsRecord) {
- const labels = labelsRecord.base;
- this._optionalLabels = [];
- this._requiredLabels = [];
-
- for (const label in labels) {
- if (!labels.hasOwnProperty(label)) { continue; }
-
- const labelInfo = labels[label];
- const icon = this._computeLabelIcon(labelInfo);
- const style = this._computeLabelClass(labelInfo);
- const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
-
- this.push(path, {label, icon, style, labelInfo});
- }
- }
-
- /**
- * @param {Object} labelInfo
- * @return {string} The icon name, or undefined if no icon should
- * be used.
- */
- _computeLabelIcon(labelInfo) {
- if (labelInfo.approved) { return 'gr-icons:check'; }
- if (labelInfo.rejected) { return 'gr-icons:close'; }
- return 'gr-icons:schedule';
- }
-
- /**
- * @param {Object} labelInfo
- */
- _computeLabelClass(labelInfo) {
- if (labelInfo.approved) { return 'approved'; }
- if (labelInfo.rejected) { return 'rejected'; }
- return '';
- }
-
- _computeShowOptional(optionalFieldsRecord) {
- return optionalFieldsRecord.base.length ? '' : 'hidden';
- }
-
- _computeLabelValue(value) {
- return (value > 0 ? '+' : '') + value;
- }
-
- _computeShowHideIcon(showOptionalLabels) {
- return showOptionalLabels ?
- 'gr-icons:expand-less' :
- 'gr-icons:expand-more';
- }
-
- _computeSectionClass(show) {
- return show ? '' : 'hidden';
- }
-
- _handleShowHide(e) {
- this._showOptionalLabels = !this._showOptionalLabels;
- }
-
- _computeSubmitRequirementEndpoint(item) {
- return `submit-requirement-item-${item.type}`;
- }
-}
-
-customElements.define(GrChangeRequirements.is, GrChangeRequirements);
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
new file mode 100644
index 0000000..cdac00a
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements.ts
@@ -0,0 +1,202 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-label/gr-label';
+import '../../shared/gr-label-info/gr-label-info';
+import '../../shared/gr-limited-text/gr-limited-text';
+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-requirements_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+ ChangeInfo,
+ AccountInfo,
+ QuickLabelInfo,
+ Requirement,
+ RequirementType,
+ LabelNameToInfoMap,
+ LabelInfo,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+
+interface ChangeRequirement extends Requirement {
+ satisfied: boolean;
+ style: string;
+}
+
+interface ChangeWIP {
+ type: RequirementType;
+ fallback_text: string;
+ tooltip: string;
+}
+
+interface Label {
+ labelInfo: LabelInfo;
+ icon: string;
+ style: string;
+}
+
+@customElement('gr-change-requirements')
+class GrChangeRequirements extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Object})
+ account?: AccountInfo;
+
+ @property({type: Boolean})
+ mutable?: boolean;
+
+ @property({type: Array, computed: '_computeRequirements(change)'})
+ _requirements?: Array<ChangeRequirement | ChangeWIP>;
+
+ @property({type: Array})
+ _requiredLabels: Label[] = [];
+
+ @property({type: Array})
+ _optionalLabels: Label[] = [];
+
+ @property({type: Boolean, computed: '_computeShowWip(change)'})
+ _showWip?: boolean;
+
+ @property({type: Boolean})
+ _showOptionalLabels = true;
+
+ _computeShowWip(change: ChangeInfo) {
+ return change.work_in_progress;
+ }
+
+ _computeRequirements(change: ChangeInfo) {
+ const _requirements: Array<ChangeRequirement | ChangeWIP> = [];
+
+ if (change.requirements) {
+ for (const requirement of change.requirements) {
+ const satisfied = requirement.status === 'OK';
+ const style = this._computeRequirementClass(satisfied);
+ _requirements.push({...requirement, satisfied, style});
+ }
+ }
+ if (change.work_in_progress) {
+ _requirements.push({
+ type: 'wip' as RequirementType,
+ fallback_text: 'Work-in-progress',
+ tooltip: "Change must not be in 'Work in Progress' state.",
+ });
+ }
+
+ return _requirements;
+ }
+
+ _computeRequirementClass(requirementStatus: boolean) {
+ return requirementStatus ? 'approved' : '';
+ }
+
+ _computeRequirementIcon(requirementStatus: boolean) {
+ return requirementStatus ? 'gr-icons:check' : 'gr-icons:schedule';
+ }
+
+ @observe('change.labels.*')
+ _computeLabels(
+ labelsRecord: PolymerDeepPropertyChange<
+ LabelNameToInfoMap,
+ LabelNameToInfoMap
+ >
+ ) {
+ const labels = labelsRecord.base;
+ this._optionalLabels = [];
+ this._requiredLabels = [];
+
+ for (const label of Object.keys(labels || {}).sort()) {
+ if (!hasOwnProperty(labels, label)) {
+ continue;
+ }
+
+ const labelInfo = labels[label];
+ const icon = this._computeLabelIcon(labelInfo);
+ const style = this._computeLabelClass(labelInfo);
+ const path = labelInfo.optional ? '_optionalLabels' : '_requiredLabels';
+
+ this.push(path, {label, icon, style, labelInfo});
+ }
+ }
+
+ /**
+ * @return The icon name, or undefined if no icon should
+ * be used.
+ */
+ _computeLabelIcon(labelInfo: QuickLabelInfo) {
+ if (labelInfo.approved) {
+ return 'gr-icons:check';
+ }
+ if (labelInfo.rejected) {
+ return 'gr-icons:close';
+ }
+ return 'gr-icons:schedule';
+ }
+
+ _computeLabelClass(labelInfo: QuickLabelInfo) {
+ if (labelInfo.approved) {
+ return 'approved';
+ }
+ if (labelInfo.rejected) {
+ return 'rejected';
+ }
+ return '';
+ }
+
+ _computeShowOptional(
+ optionalFieldsRecord: PolymerDeepPropertyChange<Label[], Label[]>
+ ) {
+ return optionalFieldsRecord.base.length ? '' : 'hidden';
+ }
+
+ _computeLabelValue(value: number) {
+ return `${value > 0 ? '+' : ''}${value}`;
+ }
+
+ _computeShowHideIcon(showOptionalLabels: boolean) {
+ return showOptionalLabels ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+ }
+
+ _computeSectionClass(show: boolean) {
+ return show ? '' : 'hidden';
+ }
+
+ _handleShowHide() {
+ this._showOptionalLabels = !this._showOptionalLabels;
+ }
+
+ _computeSubmitRequirementEndpoint(item: ChangeRequirement | ChangeWIP) {
+ return `submit-requirement-item-${item.type}`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-requirements': GrChangeRequirements;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
index fc2346a..ef71314 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_html.ts
@@ -26,6 +26,7 @@
color: #ffa62f;
display: inline-block;
text-align: center;
+ vertical-align: top;
font-family: var(--monospace-font-family);
font-size: var(--font-size-mono);
line-height: var(--line-height-mono);
@@ -104,7 +105,7 @@
</span>
<gr-limited-text
class="name"
- limit="40"
+ limit="25"
tooltip="[[item.tooltip]]"
text="[[item.fallback_text]]"
></gr-limited-text>
@@ -122,7 +123,7 @@
</span>
<gr-limited-text
class="name"
- limit="40"
+ limit="25"
text="[[item.label]]"
></gr-limited-text>
</div>
@@ -168,7 +169,7 @@
</span>
<gr-limited-text
class="name"
- limit="40"
+ limit="25"
text="[[item.label]]"
></gr-limited-text>
</div>
diff --git a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
index d8a90fc..c2fc72d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
+++ b/polygerrit-ui/app/elements/change/gr-change-requirements/gr-change-requirements_test.js
@@ -83,13 +83,13 @@
test('optional show/hide', () => {
element._optionalLabels = [{label: 'test'}];
- flushAsynchronousOperations();
+ flush();
assert.ok(element.shadowRoot
.querySelector('section.optional'));
MockInteractions.tap(element.shadowRoot
.querySelector('.showHide'));
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element._showOptionalLabels);
assert.isTrue(isHidden(element.shadowRoot
@@ -106,7 +106,7 @@
},
requirements: [],
};
- flushAsynchronousOperations();
+ flush();
assert.ok(element.shadowRoot
.querySelector('.approved'));
@@ -125,7 +125,7 @@
},
},
};
- flushAsynchronousOperations();
+ flush();
const name = element.shadowRoot
.querySelector('.name');
@@ -141,7 +141,7 @@
requirements: [],
work_in_progress: true,
};
- flushAsynchronousOperations();
+ flush();
const changeIsWip = element.shadowRoot
.querySelector('.title');
@@ -157,7 +157,7 @@
status: 'OK',
}],
};
- flushAsynchronousOperations();
+ flush();
const requirement = element.shadowRoot
.querySelector('.requirement');
@@ -177,7 +177,7 @@
status: 'OK',
}],
};
- flushAsynchronousOperations();
+ flush();
const requirement = element.shadowRoot
.querySelector('.requirement');
@@ -194,7 +194,7 @@
status: 'NOT_READY',
}],
};
- flushAsynchronousOperations();
+ flush();
const requirement = element.shadowRoot
.querySelector('.requirement');
@@ -211,7 +211,7 @@
status: 'RULE_ERROR',
}],
};
- flushAsynchronousOperations();
+ flush();
const requirement = element.shadowRoot
.querySelector('.requirement');
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
deleted file mode 100644
index 1cd9f3f..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ /dev/null
@@ -1,2316 +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 '@polymer/paper-tabs/paper-tabs.js';
-import '../../../styles/shared-styles.js';
-import '../../diff/gr-comment-api/gr-comment-api.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-account-link/gr-account-link.js';
-import '../../shared/gr-button/gr-button.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-editable-content/gr-editable-content.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-linked-text/gr-linked-text.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/revision-info/revision-info.js';
-import '../gr-change-actions/gr-change-actions.js';
-import '../gr-change-metadata/gr-change-metadata.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-commit-info/gr-commit-info.js';
-import '../gr-download-dialog/gr-download-dialog.js';
-import '../gr-file-list-header/gr-file-list-header.js';
-import '../gr-file-list/gr-file-list.js';
-import '../gr-included-in-dialog/gr-included-in-dialog.js';
-import '../gr-messages-list/gr-messages-list.js';
-import '../gr-related-changes-list/gr-related-changes-list.js';
-import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js';
-import '../gr-reply-dialog/gr-reply-dialog.js';
-import '../gr-thread-list/gr-thread-list.js';
-import '../gr-upload-help-dialog/gr-upload-help-dialog.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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-view_html.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrEditConstants} from '../../edit/gr-edit-constants.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-
-import {PrimaryTab, SecondaryTab} from '../../../constants/constants.js';
-import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages.js';
-import {appContext} from '../../../services/app-context.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {
- computeAllPatchSets,
- computeLatestPatchNum,
- fetchChangeUpdates,
- hasEditBasedOnCurrentPatchSet,
- hasEditPatchsetLoaded,
- patchNumEquals,
- SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-import {changeStatuses, changeStatusString} from '../../../utils/change-util.js';
-
-const CHANGE_ID_ERROR = {
- MISMATCH: 'mismatch',
- MISSING: 'missing',
-};
-const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
-
-const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
-const DEFAULT_NUM_FILES_SHOWN = 200;
-
-const REVIEWERS_REGEX = /^(R|CC)=/gm;
-const MIN_CHECK_INTERVAL_SECS = 0;
-
-// These are the same as the breakpoint set in CSS. Make sure both are changed
-// together.
-const BREAKPOINT_RELATED_SMALL = '50em';
-const BREAKPOINT_RELATED_MED = '75em';
-
-// In the event that the related changes medium width calculation is too close
-// to zero, provide some height.
-const MINIMUM_RELATED_MAX_HEIGHT = 100;
-
-const SMALL_RELATED_HEIGHT = 400;
-
-const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
-
-const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
-
-const MSG_PREFIX = '#message-';
-
-const ReloadToastMessage = {
- NEWER_REVISION: 'A newer patch set has been uploaded',
- RESTORED: 'This change has been restored',
- ABANDONED: 'This change has been abandoned',
- MERGED: 'This change has been merged',
- NEW_MESSAGE: 'There are new messages on this change',
-};
-
-const DiffViewMode = {
- SIDE_BY_SIDE: 'SIDE_BY_SIDE',
- UNIFIED: 'UNIFIED_DIFF',
-};
-
-const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
-const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
-const SEND_REPLY_TIMING_LABEL = 'SendReply';
-// Making the tab names more unique in case a plugin adds one with same name
-const ROBOT_COMMENTS_LIMIT = 10;
-
-// types used in this file
-/**
- * Type for the custom event to switch tab.
- *
- * @typedef {Object} SwitchTabEventDetail
- * @property {?string} tab - name of the tab to set as active, from custom event
- * @property {?boolean} scrollIntoView - scroll into the tab afterwards, from custom event
- * @property {?number} value - index of tab to set as active, from paper-tabs event
- */
-
-/**
- * @extends PolymerElement
- */
-class GrChangeView extends KeyboardShortcutMixin(
- GestureEventListeners(LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
- * Fired if an error occurs when fetching the change data.
- *
- * @event page-error
- */
-
- /**
- * Fired if being logged in is required.
- *
- * @event show-auth-required
- */
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
- /** @type {?} */
- viewState: {
- type: Object,
- notify: true,
- value() { return {}; },
- observer: '_viewStateChanged',
- },
- backPage: String,
- hasParent: Boolean,
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- disableEdit: {
- type: Boolean,
- value: false,
- },
- disableDiffPrefs: {
- type: Boolean,
- value: false,
- },
- _diffPrefsDisabled: {
- type: Boolean,
- computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
- },
- _commentThreads: Array,
- // TODO(taoalpha): Consider replacing diffDrafts
- // with _draftCommentThreads everywhere, currently only
- // replaced in reply-dialoig
- _draftCommentThreads: {
- type: Array,
- },
- _robotCommentThreads: {
- type: Array,
- computed: '_computeRobotCommentThreads(_commentThreads,'
- + ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
- },
- /** @type {?} */
- _serverConfig: {
- type: Object,
- observer: '_startUpdateCheckTimer',
- },
- _diffPrefs: Object,
- _numFilesShown: {
- type: Number,
- value: DEFAULT_NUM_FILES_SHOWN,
- observer: '_numFilesShownChanged',
- },
- _account: {
- type: Object,
- value: {},
- },
- _prefs: Object,
- /** @type {?} */
- _changeComments: Object,
- _canStartReview: {
- type: Boolean,
- computed: '_computeCanStartReview(_change)',
- },
- /** @type {?} */
- _change: {
- type: Object,
- observer: '_changeChanged',
- },
- _revisionInfo: {
- type: Object,
- computed: '_getRevisionInfo(_change)',
- },
- /** @type {?} */
- _commitInfo: Object,
- _currentRevision: {
- type: Object,
- computed: '_computeCurrentRevision(_change.current_revision, ' +
- '_change.revisions)',
- observer: '_handleCurrentRevisionUpdate',
- },
- _files: Object,
- _changeNum: String,
- _diffDrafts: {
- type: Object,
- value() { return {}; },
- },
- _editingCommitMessage: {
- type: Boolean,
- value: false,
- },
- _hideEditCommitMessage: {
- type: Boolean,
- computed: '_computeHideEditCommitMessage(_loggedIn, ' +
- '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
- '_commitCollapsible)',
- },
- _diffAgainst: String,
- /** @type {?string} */
- _latestCommitMessage: {
- type: String,
- value: '',
- },
- _constants: {
- type: Object,
- value: {
- SecondaryTab,
- PrimaryTab,
- },
- },
- _messages: {
- type: Object,
- value: {
- NO_ROBOT_COMMENTS_THREADS_MSG,
- },
- },
- _lineHeight: Number,
- _changeIdCommitMessageError: {
- type: String,
- computed:
- '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
- },
- /** @type {?} */
- _patchRange: {
- type: Object,
- },
- _filesExpanded: String,
- _basePatchNum: String,
- _selectedRevision: Object,
- _currentRevisionActions: Object,
- _allPatchSets: {
- type: Array,
- computed: '_computeAllPatchSets(_change, _change.revisions.*)',
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- },
- _loading: Boolean,
- /** @type {?} */
- _projectConfig: Object,
- _replyButtonLabel: {
- type: String,
- value: 'Reply',
- computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
- },
- _selectedPatchSet: String,
- _shownFileCount: Number,
- _initialLoadComplete: {
- type: Boolean,
- value: false,
- },
- _replyDisabled: {
- type: Boolean,
- value: true,
- computed: '_computeReplyDisabled(_serverConfig)',
- },
- _changeStatus: {
- type: String,
- computed: '_changeStatusString(_change)',
- },
- _changeStatuses: {
- type: String,
- computed:
- '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
- },
- /** If false, then the "Show more" button was used to expand. */
- _commitCollapsed: {
- type: Boolean,
- value: true,
- },
- /** Is the "Show more/less" button visible? */
- _commitCollapsible: {
- type: Boolean,
- computed: '_computeCommitCollapsible(_latestCommitMessage)',
- },
- _relatedChangesCollapsed: {
- type: Boolean,
- value: true,
- },
- /** @type {?number} */
- _updateCheckTimerHandle: Number,
- _editMode: {
- type: Boolean,
- computed: '_computeEditMode(_patchRange.*, params.*)',
- },
- _showRelatedToggle: {
- type: Boolean,
- value: false,
- observer: '_updateToggleContainerClass',
- },
- _parentIsCurrent: {
- type: Boolean,
- computed: '_isParentCurrent(_currentRevisionActions)',
- },
- _submitEnabled: {
- type: Boolean,
- computed: '_isSubmitEnabled(_currentRevisionActions)',
- },
-
- /** @type {?} */
- _mergeable: {
- type: Boolean,
- value: undefined,
- },
- _showFileTabContent: {
- type: Boolean,
- value: true,
- },
- /** @type {Array<string>} */
- _dynamicTabHeaderEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicTabContentEndpoints: {
- type: Array,
- },
- // The dynamic content of the plugin added tab
- _selectedTabPluginEndpoint: {
- type: String,
- },
- // The dynamic heading of the plugin added tab
- _selectedTabPluginHeader: {
- type: String,
- },
- _robotCommentsPatchSetDropdownItems: {
- type: Array,
- value() { return []; },
- computed: '_computeRobotCommentsPatchSetDropdownItems(_change, ' +
- '_commentThreads)',
- },
- _currentRobotCommentsPatchSet: {
- type: Number,
- },
-
- /**
- * @type {Array<string>} this is a two-element tuple to always
- * hold the current active tab for both primary and secondary tabs
- */
- _activeTabs: {
- type: Array,
- value: [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG],
- },
- _showAllRobotComments: {
- type: Boolean,
- value: false,
- },
- _showRobotCommentsButton: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_labelsChanged(_change.labels.*)',
- '_paramsAndChangeChanged(params, _change)',
- '_patchNumChanged(_patchRange.patchNum)',
- ];
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
- [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
- [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
- [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
- [Shortcut.OPEN_DOWNLOAD_DIALOG]:
- '_handleOpenDownloadDialogShortcut',
- [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
- [Shortcut.TOGGLE_CHANGE_STAR]: '_handleToggleChangeStar',
- [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
- [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
- [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
- [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
- [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
- [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
- [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
- [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
- [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
- '_handleDiffRightAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LATEST]:
- '_handleDiffBaseAgainstLatest',
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- created() {
- super.created();
-
- this.addEventListener('topic-changed',
- () => this._handleTopicChanged());
-
- this.addEventListener(
- // When an overlay is opened in a mobile viewport, the overlay has a full
- // screen view. When it has a full screen view, we do not want the
- // background to be scrollable. This will eliminate background scroll by
- // hiding most of the contents on the screen upon opening, and showing
- // again upon closing.
- 'fullscreen-overlay-opened',
- () => this._handleHideBackgroundContent());
-
- this.addEventListener('fullscreen-overlay-closed',
- () => this._handleShowBackgroundContent());
-
- this.addEventListener('diff-comments-modified',
- () => this._handleReloadCommentThreads());
-
- this.addEventListener('open-reply-dialog',
- e => this._openReplyDialog());
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getServerConfig().then(config => {
- this._serverConfig = config;
- });
-
- this._getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- if (loggedIn) {
- this.$.restAPI.getAccount().then(acct => {
- this._account = acct;
- });
- }
- this._setDiffViewMode();
- });
-
- pluginLoader.awaitPluginsLoaded()
- .then(() => {
- this._dynamicTabHeaderEndpoints =
- pluginEndpoints.getDynamicEndpoints('change-view-tab-header');
- this._dynamicTabContentEndpoints =
- pluginEndpoints.getDynamicEndpoints('change-view-tab-content');
- if (this._dynamicTabContentEndpoints.length !==
- this._dynamicTabHeaderEndpoints.length) {
- console.warn('Different number of tab headers and tab content.');
- }
- })
- .then(() => this._initActiveTabs(this.params));
-
- this.addEventListener('comment-save', this._handleCommentSave.bind(this));
- this.addEventListener('comment-refresh', this._reloadDrafts.bind(this));
- this.addEventListener('comment-discard',
- this._handleCommentDiscard.bind(this));
- this.addEventListener('change-message-deleted',
- () => this._reload());
- this.addEventListener('editable-content-save',
- this._handleCommitMessageSave.bind(this));
- this.addEventListener('editable-content-cancel',
- this._handleCommitMessageCancel.bind(this));
- this.addEventListener('open-fix-preview',
- this._onOpenFixPreview.bind(this));
- this.addEventListener('close-fix-preview',
- this._onCloseFixPreview.bind(this));
- this.listen(window, 'scroll', '_handleScroll');
- this.listen(document, 'visibilitychange', '_handleVisibilityChange');
-
- this.addEventListener('show-primary-tab',
- e => this._setActivePrimaryTab(e));
- this.addEventListener('show-secondary-tab',
- e => this._setActiveSecondaryTab(e));
- }
-
- /** @override */
- detached() {
- super.detached();
- this.unlisten(window, 'scroll', '_handleScroll');
- this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
-
- if (this._updateCheckTimerHandle) {
- this._cancelUpdateCheckTimer();
- }
- }
-
- get messagesList() {
- return this.shadowRoot.querySelector('gr-messages-list');
- }
-
- get threadList() {
- return this.shadowRoot.querySelector('gr-thread-list');
- }
-
- _changeStatusString(change) {
- return changeStatusString(change);
- }
-
- /**
- * @param {boolean=} opt_reset
- */
- _setDiffViewMode(opt_reset) {
- if (!opt_reset && this.viewState.diffViewMode) { return; }
-
- return this._getPreferences()
- .then( prefs => {
- if (!this.viewState.diffMode) {
- this.set('viewState.diffMode', prefs.default_diff_view);
- }
- })
- .then(() => {
- if (!this.viewState.diffMode) {
- this.set('viewState.diffMode', 'SIDE_BY_SIDE');
- }
- });
- }
-
- _onOpenFixPreview(e) {
- this.$.applyFixDialog.open(e);
- }
-
- _onCloseFixPreview(e) {
- this._reload();
- }
-
- _handleToggleDiffMode(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
- this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
- } else {
- this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
- }
- }
-
- _isTabActive(tab, activeTabs) {
- return activeTabs.includes(tab);
- }
-
- /**
- * Actual implementation of switching a tab
- *
- * @param {!HTMLElement} paperTabs - the parent tabs container
- * @param {!SwitchTabEventDetail} activeDetails
- */
- _setActiveTab(paperTabs, activeDetails) {
- const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
- const tabs = paperTabs.querySelectorAll('paper-tab');
- let activeIndex = -1;
- if (activeTabIndex !== undefined) {
- activeIndex = activeTabIndex;
- } else {
- for (let i = 0; i <= tabs.length; i++) {
- const tab = tabs[i];
- if (tab.dataset.name === activeTabName) {
- activeIndex = i;
- break;
- }
- }
- }
- if (activeIndex === -1) {
- console.warn('tab not found with given info', activeDetails);
- return;
- }
- const tabName = tabs[activeIndex].dataset.name;
- if (scrollIntoView) {
- paperTabs.scrollIntoView();
- }
- if (paperTabs.selected !== activeIndex) {
- paperTabs.selected = activeIndex;
- this.reporting.reportInteraction('show-tab', {tabName});
- }
- return tabName;
- }
-
- /**
- * Changes active primary tab.
- *
- * @param {CustomEvent<SwitchTabEventDetail>} e
- */
- _setActivePrimaryTab(e) {
- const primaryTabs = this.shadowRoot.querySelector('#primaryTabs');
- const activeTabName = this._setActiveTab(primaryTabs, {
- activeTabName: e.detail.tab,
- activeTabIndex: e.detail.value,
- scrollIntoView: e.detail.scrollIntoView,
- });
- if (activeTabName) {
- this._activeTabs = [activeTabName, this._activeTabs[1]];
-
- // update plugin endpoint if its a plugin tab
- const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
- activeTabName);
- if (pluginIndex !== -1) {
- this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
- pluginIndex];
- this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
- pluginIndex];
- } else {
- this._selectedTabPluginEndpoint = '';
- this._selectedTabPluginHeader = '';
- }
- }
- }
-
- /**
- * Changes active secondary tab.
- *
- * @param {CustomEvent<SwitchTabEventDetail>} e
- */
- _setActiveSecondaryTab(e) {
- const secondaryTabs = this.shadowRoot.querySelector('#secondaryTabs');
- const activeTabName = this._setActiveTab(secondaryTabs, {
- activeTabName: e.detail.tab,
- activeTabIndex: e.detail.value,
- scrollIntoView: e.detail.scrollIntoView,
- });
- if (activeTabName) {
- this._activeTabs = [this._activeTabs[0], activeTabName];
- }
- }
-
- _handleEditCommitMessage() {
- this._editingCommitMessage = true;
- this.$.commitMessageEditor.focusTextarea();
- }
-
- _handleCommitMessageSave(e) {
- // Trim trailing whitespace from each line.
- const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
-
- this.$.jsAPI.handleCommitMessage(this._change, message);
-
- this.$.commitMessageEditor.disabled = true;
- this.$.restAPI.putChangeCommitMessage(
- this._changeNum, message)
- .then(resp => {
- this.$.commitMessageEditor.disabled = false;
- if (!resp.ok) { return; }
-
- this._latestCommitMessage = this._prepareCommitMsgForLinkify(
- message);
- this._editingCommitMessage = false;
- this._reloadWindow();
- })
- .catch(err => {
- this.$.commitMessageEditor.disabled = false;
- });
- }
-
- _reloadWindow() {
- window.location.reload();
- }
-
- _handleCommitMessageCancel(e) {
- this._editingCommitMessage = false;
- }
-
- _computeChangeStatusChips(change, mergeable, submitEnabled) {
- // Polymer 2: check for undefined
- if ([
- change,
- mergeable,
- ].includes(undefined)) {
- // To keep consistent with Polymer 1, we are returning undefined
- // if not all dependencies are defined
- return undefined;
- }
-
- // Show no chips until mergeability is loaded.
- if (mergeable === null) {
- return [];
- }
-
- const options = {
- includeDerived: true,
- mergeable: !!mergeable,
- submitEnabled: !!submitEnabled,
- };
- return changeStatuses(change, options);
- }
-
- _computeHideEditCommitMessage(
- loggedIn, editing, change, editMode, collapsed, collapsible) {
- if (!loggedIn || editing ||
- (change && change.status === ChangeStatus.MERGED) ||
- editMode ||
- (collapsed && collapsible)) {
- return true;
- }
-
- return false;
- }
-
- _robotCommentCountPerPatchSet(threads) {
- return threads.reduce((robotCommentCountMap, thread) => {
- const comments = thread.comments;
- const robotCommentsCount = comments.reduce((acc, comment) =>
- (comment.robot_id ? acc + 1 : acc), 0);
- robotCommentCountMap[comments[0].patch_set] =
- (robotCommentCountMap[comments[0].patch_set] || 0) +
- robotCommentsCount;
- return robotCommentCountMap;
- }, {});
- }
-
- _computeText(patch, commentThreads) {
- const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
- const commentCnt = commentCount[patch._number] || 0;
- if (commentCnt === 0) return `Patchset ${patch._number}`;
- const findingsText = commentCnt === 1 ? 'finding' : 'findings';
- return `Patchset ${patch._number}`
- + ` (${commentCnt} ${findingsText})`;
- }
-
- _computeRobotCommentsPatchSetDropdownItems(change, commentThreads) {
- if (!change || !commentThreads || !change.revisions) return [];
-
- return Object.values(change.revisions)
- .filter(patch => patch._number !== 'edit')
- .map(patch => {
- return {
- text: this._computeText(patch, commentThreads),
- value: patch._number,
- };
- })
- .sort((a, b) => b.value - a.value);
- }
-
- _handleCurrentRevisionUpdate(currentRevision) {
- this._currentRobotCommentsPatchSet = currentRevision._number;
- }
-
- _handleRobotCommentPatchSetChanged(e) {
- const patchSet = parseInt(e.detail.value);
- if (patchSet === this._currentRobotCommentsPatchSet) return;
- this._currentRobotCommentsPatchSet = patchSet;
- }
-
- _computeShowText(showAllRobotComments) {
- return showAllRobotComments ? 'Show Less' : 'Show more';
- }
-
- _toggleShowRobotComments() {
- this._showAllRobotComments = !this._showAllRobotComments;
- }
-
- _computeRobotCommentThreads(commentThreads, currentRobotCommentsPatchSet,
- showAllRobotComments) {
- if (!commentThreads || !currentRobotCommentsPatchSet) return [];
- const threads = commentThreads.filter(thread => {
- const comments = thread.comments || [];
- return comments.length && comments[0].robot_id && (comments[0].patch_set
- === currentRobotCommentsPatchSet);
- });
- this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
- return threads.slice(0, showAllRobotComments ? undefined :
- ROBOT_COMMENTS_LIMIT);
- }
-
- _handleReloadCommentThreads() {
- // Get any new drafts that have been saved in the diff view and show
- // in the comment thread view.
- this._reloadDrafts().then(() => {
- this._commentThreads = this._changeComments.getAllThreadsForChange();
- flush();
- });
- }
-
- _handleReloadDiffComments(e) {
- // Keeps the file list counts updated.
- this._reloadDrafts().then(() => {
- // Get any new drafts that have been saved in the thread view and show
- // in the diff view.
- this.$.fileList.reloadCommentsForThreadWithRootId(e.detail.rootId,
- e.detail.path);
- flush();
- });
- }
-
- _computeTotalCommentCounts(unresolvedCount, changeComments) {
- if (!changeComments) return undefined;
- const draftCount = changeComments.computeDraftCount();
- const unresolvedString = GrCountStringFormatter.computeString(
- unresolvedCount, 'unresolved');
- const draftString = GrCountStringFormatter.computePluralString(
- draftCount, 'draft');
-
- return unresolvedString +
- // Add a comma and space if both unresolved and draft comments exist.
- (unresolvedString && draftString ? ', ' : '') +
- draftString;
- }
-
- _handleCommentSave(e) {
- const draft = e.detail.comment;
- if (!draft.__draft) { return; }
-
- draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
- // The use of path-based notification helpers (set, push) can’t be used
- // because the paths could contain dots in them. A new object must be
- // created to satisfy Polymer’s dirty checking.
- // https://github.com/Polymer/polymer/issues/3127
- const diffDrafts = Object.assign({}, this._diffDrafts);
- if (!diffDrafts[draft.path]) {
- diffDrafts[draft.path] = [draft];
- this._diffDrafts = diffDrafts;
- return;
- }
- for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
- if (this._diffDrafts[draft.path][i].id === draft.id) {
- diffDrafts[draft.path][i] = draft;
- this._diffDrafts = diffDrafts;
- return;
- }
- }
- diffDrafts[draft.path].push(draft);
- diffDrafts[draft.path].sort((c1, c2) =>
- // No line number means that it’s a file comment. Sort it above the
- // others.
- (c1.line || -1) - (c2.line || -1)
- );
- this._diffDrafts = diffDrafts;
- }
-
- _handleCommentDiscard(e) {
- const draft = e.detail.comment;
- if (!draft.__draft) { return; }
-
- if (!this._diffDrafts[draft.path]) {
- return;
- }
- let index = -1;
- for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
- if (this._diffDrafts[draft.path][i].id === draft.id) {
- index = i;
- break;
- }
- }
- if (index === -1) {
- // It may be a draft that hasn’t been added to _diffDrafts since it was
- // never saved.
- return;
- }
-
- draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
- // The use of path-based notification helpers (set, push) can’t be used
- // because the paths could contain dots in them. A new object must be
- // created to satisfy Polymer’s dirty checking.
- // https://github.com/Polymer/polymer/issues/3127
- const diffDrafts = Object.assign({}, this._diffDrafts);
- diffDrafts[draft.path].splice(index, 1);
- if (diffDrafts[draft.path].length === 0) {
- delete diffDrafts[draft.path];
- }
- this._diffDrafts = diffDrafts;
- }
-
- _handleReplyTap(e) {
- e.preventDefault();
- this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
- }
-
- _handleOpenDiffPrefs() {
- this.$.fileList.openDiffPrefs();
- }
-
- _handleOpenIncludedInDialog() {
- this.$.includedInDialog.loadData().then(() => {
- flush();
- this.$.includedInOverlay.refit();
- });
- this.$.includedInOverlay.open();
- }
-
- _handleIncludedInDialogClose(e) {
- this.$.includedInOverlay.close();
- }
-
- _handleOpenDownloadDialog() {
- this.$.downloadOverlay.open().then(() => {
- this.$.downloadOverlay
- .setFocusStops(this.$.downloadDialog.getFocusStops());
- this.$.downloadDialog.focus();
- });
- }
-
- _handleDownloadDialogClose(e) {
- this.$.downloadOverlay.close();
- }
-
- _handleOpenUploadHelpDialog(e) {
- this.$.uploadHelpOverlay.open();
- }
-
- _handleCloseUploadHelpDialog(e) {
- this.$.uploadHelpOverlay.close();
- }
-
- _handleMessageReply(e) {
- const msg = e.detail.message.message;
- const quoteStr = msg.split('\n').map(
- line => '> ' + line)
- .join('\n') + '\n\n';
- this.$.replyDialog.quote = quoteStr;
- this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
- }
-
- _handleHideBackgroundContent() {
- this.$.mainContent.classList.add('overlayOpen');
- }
-
- _handleShowBackgroundContent() {
- this.$.mainContent.classList.remove('overlayOpen');
- }
-
- _handleReplySent(e) {
- this.addEventListener('change-details-loaded',
- () => {
- this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
- }, {once: true});
- this.$.replyOverlay.close();
- this._reload();
- }
-
- _handleReplyCancel(e) {
- this.$.replyOverlay.close();
- }
-
- _handleReplyAutogrow(e) {
- // If the textarea resizes, we need to re-fit the overlay.
- this.debounce('reply-overlay-refit', () => {
- this.$.replyOverlay.refit();
- }, REPLY_REFIT_DEBOUNCE_INTERVAL_MS);
- }
-
- _handleShowReplyDialog(e) {
- let target = this.$.replyDialog.FocusTarget.REVIEWERS;
- if (e.detail.value && e.detail.value.ccsOnly) {
- target = this.$.replyDialog.FocusTarget.CCS;
- }
- this._openReplyDialog(target);
- }
-
- _handleScroll() {
- this.debounce('scroll', () => {
- this.viewState.scrollTop = document.body.scrollTop;
- }, 150);
- }
-
- _setShownFiles(e) {
- this._shownFileCount = e.detail.length;
- }
-
- _expandAllDiffs() {
- this.$.fileList.expandAllDiffs();
- }
-
- _collapseAllDiffs() {
- this.$.fileList.collapseAllDiffs();
- }
-
- _paramsChanged(value) {
- if (value.view !== GerritNav.View.CHANGE) {
- this._initialLoadComplete = false;
- return;
- }
-
- if (value.changeNum && value.project) {
- this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
- }
-
- const patchChanged = this._patchRange &&
- (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
- (this._patchRange.patchNum !== value.patchNum ||
- this._patchRange.basePatchNum !== value.basePatchNum);
- const changeChanged = this._changeNum !== value.changeNum;
-
- const patchRange = {
- patchNum: value.patchNum,
- basePatchNum: value.basePatchNum || 'PARENT',
- };
-
- this.$.fileList.collapseAllDiffs();
- this._patchRange = patchRange;
-
- // If the change has already been loaded and the parameter change is only
- // in the patch range, then don't do a full reload.
- if (!changeChanged && patchChanged) {
- if (patchRange.patchNum == null) {
- patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
- }
- this._reloadPatchNumDependentResources().then(() => {
- this._sendShowChangeEvent();
- });
- return;
- }
-
- this._initialLoadComplete = false;
- this._changeNum = value.changeNum;
- this.$.relatedChanges.clear();
-
- this._reload(true).then(() => {
- this._performPostLoadTasks();
- });
-
- pluginLoader.awaitPluginsLoaded().then(() => {
- this._initActiveTabs(value);
- });
- }
-
- _initActiveTabs(params = {}) {
- let primaryTab = PrimaryTab.FILES;
- if (params.queryMap && params.queryMap.has('tab')) {
- primaryTab = params.queryMap.get('tab');
- }
- this._setActivePrimaryTab({
- detail: {
- tab: primaryTab,
- },
- });
- this._setActiveSecondaryTab({
- detail: {
- tab: SecondaryTab.CHANGE_LOG,
- },
- });
- }
-
- _sendShowChangeEvent() {
- this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
- change: this._change,
- patchNum: this._patchRange.patchNum,
- info: {mergeable: this._mergeable},
- });
- }
-
- _performPostLoadTasks() {
- this._maybeShowReplyDialog();
- this._maybeShowRevertDialog();
-
- this._sendShowChangeEvent();
-
- this.async(() => {
- if (this.viewState.scrollTop) {
- document.documentElement.scrollTop =
- document.body.scrollTop = this.viewState.scrollTop;
- } else {
- this._maybeScrollToMessage(window.location.hash);
- }
- this._initialLoadComplete = true;
- });
- }
-
- _paramsAndChangeChanged(value, change) {
- // Polymer 2: check for undefined
- if ([value, change].includes(undefined)) {
- return;
- }
-
- // If the change number or patch range is different, then reset the
- // selected file index.
- const patchRangeState = this.viewState.patchRange;
- if (this.viewState.changeNum !== this._changeNum ||
- patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
- patchRangeState.patchNum !== this._patchRange.patchNum) {
- this._resetFileListViewState();
- }
- }
-
- _viewStateChanged(viewState) {
- this._numFilesShown = viewState.numFilesShown ?
- viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
- }
-
- _numFilesShownChanged(numFilesShown) {
- this.viewState.numFilesShown = numFilesShown;
- }
-
- _handleMessageAnchorTap(e) {
- const hash = MSG_PREFIX + e.detail.id;
- const url = GerritNav.getUrlForChange(this._change,
- this._patchRange.patchNum, this._patchRange.basePatchNum,
- this._editMode, hash);
- history.replaceState(null, '', url);
- }
-
- _maybeScrollToMessage(hash) {
- if (hash.startsWith(MSG_PREFIX)) {
- this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
- }
- }
-
- _getLocationSearch() {
- // Not inlining to make it easier to test.
- return window.location.search;
- }
-
- _getUrlParameter(param) {
- const pageURL = this._getLocationSearch().substring(1);
- const vars = pageURL.split('&');
- for (let i = 0; i < vars.length; i++) {
- const name = vars[i].split('=');
- if (name[0] == param) {
- return name[0];
- }
- }
- return null;
- }
-
- _maybeShowRevertDialog() {
- pluginLoader.awaitPluginsLoaded()
- .then(this._getLoggedIn.bind(this))
- .then(loggedIn => {
- if (!loggedIn || !this._change ||
- this._change.status !== ChangeStatus.MERGED) {
- // Do not display dialog if not logged-in or the change is not
- // merged.
- return;
- }
- if (this._getUrlParameter('revert')) {
- this.$.actions.showRevertDialog();
- }
- });
- }
-
- _maybeShowReplyDialog() {
- this._getLoggedIn().then(loggedIn => {
- if (!loggedIn) { return; }
-
- if (this.viewState.showReplyDialog) {
- this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
- // TODO(kaspern@): Find a better signal for when to call center.
- this.async(() => { this.$.replyOverlay.center(); }, 100);
- this.async(() => { this.$.replyOverlay.center(); }, 1000);
- this.set('viewState.showReplyDialog', false);
- }
- });
- }
-
- _resetFileListViewState() {
- this.set('viewState.selectedFileIndex', 0);
- this.set('viewState.scrollTop', 0);
- if (!!this.viewState.changeNum &&
- this.viewState.changeNum !== this._changeNum) {
- // Reset the diff mode to null when navigating from one change to
- // another, so that the user's preference is restored.
- this._setDiffViewMode(true);
- this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
- }
- this.set('viewState.changeNum', this._changeNum);
- this.set('viewState.patchRange', this._patchRange);
- }
-
- _changeChanged(change) {
- if (!change || !this._patchRange || !this._allPatchSets) { return; }
-
- // We get the parent first so we keep the original value for basePatchNum
- // and not the updated value.
- const parent = this._getBasePatchNum(change, this._patchRange);
-
- this.set('_patchRange.patchNum', this._patchRange.patchNum ||
- computeLatestPatchNum(this._allPatchSets));
-
- this.set('_patchRange.basePatchNum', parent);
-
- const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title},
- composed: true, bubbles: true,
- }));
- }
-
- /**
- * Gets base patch number, if it is a parent try and decide from
- * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
- *
- * @param {Object} change
- * @param {Object} patchRange
- * @return {number|string}
- */
- _getBasePatchNum(change, patchRange) {
- if (patchRange.basePatchNum &&
- patchRange.basePatchNum !== 'PARENT') {
- return patchRange.basePatchNum;
- }
-
- const revisionInfo = this._getRevisionInfo(change);
- if (!revisionInfo) return 'PARENT';
-
- const parentCounts = revisionInfo.getParentCountMap();
- // check that there is at least 2 parents otherwise fall back to 1,
- // which means there is only one parent.
- const parentCount = parentCounts.hasOwnProperty(1) ?
- parentCounts[1] : 1;
-
- const preferFirst = this._prefs &&
- this._prefs.default_base_for_merges === 'FIRST_PARENT';
-
- if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
- return -1;
- }
-
- return 'PARENT';
- }
-
- _computeChangeUrl(change) {
- return GerritNav.getUrlForChange(change);
- }
-
- _computeShowCommitInfo(changeStatus, current_revision) {
- return changeStatus === 'Merged' && current_revision;
- }
-
- _computeMergedCommitInfo(current_revision, revisions) {
- const rev = revisions[current_revision];
- if (!rev || !rev.commit) { return {}; }
- // CommitInfo.commit is optional. Set commit in all cases to avoid error
- // in <gr-commit-info>. @see Issue 5337
- if (!rev.commit.commit) { rev.commit.commit = current_revision; }
- return rev.commit;
- }
-
- _computeChangeIdClass(displayChangeId) {
- return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
- }
-
- _computeTitleAttributeWarning(displayChangeId) {
- if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
- return 'Change-Id mismatch';
- } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
- return 'No Change-Id in commit message';
- }
- }
-
- _computeChangeIdCommitMessageError(commitMessage, change) {
- // Polymer 2: check for undefined
- if ([commitMessage, change].includes(undefined)) {
- return undefined;
- }
-
- if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
-
- // Find the last match in the commit message:
- let changeId;
- let changeIdArr;
-
- while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
- changeId = changeIdArr[1];
- }
-
- if (changeId) {
- // A change-id is detected in the commit message.
-
- if (changeId === change.change_id) {
- // The change-id found matches the real change-id.
- return null;
- }
- // The change-id found does not match the change-id.
- return CHANGE_ID_ERROR.MISMATCH;
- }
- // There is no change-id in the commit message.
- return CHANGE_ID_ERROR.MISSING;
- }
-
- _computeLabelNames(labels) {
- return Object.keys(labels).sort();
- }
-
- _computeLabelValues(labelName, labels) {
- const result = [];
- const t = labels[labelName];
- if (!t) { return result; }
- const approvals = t.all || [];
- for (const label of approvals) {
- if (label.value && label.value != labels[labelName].default_value) {
- let labelClassName;
- let labelValPrefix = '';
- if (label.value > 0) {
- labelValPrefix = '+';
- labelClassName = 'approved';
- } else if (label.value < 0) {
- labelClassName = 'notApproved';
- }
- result.push({
- value: labelValPrefix + label.value,
- className: labelClassName,
- account: label,
- });
- }
- }
- return result;
- }
-
- _computeReplyButtonLabel(changeRecord, canStartReview) {
- // Polymer 2: check for undefined
- if ([changeRecord, canStartReview].includes(undefined)) {
- return 'Reply';
- }
-
- const drafts = (changeRecord && changeRecord.base) || {};
- const draftCount = Object.keys(drafts)
- .reduce((count, file) => count + drafts[file].length, 0);
-
- let label = canStartReview ? 'Start Review' : 'Reply';
- if (draftCount > 0) {
- label += ' (' + draftCount + ')';
- }
- return label;
- }
-
- _handleOpenReplyDialog(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) {
- return;
- }
- this._getLoggedIn().then(isLoggedIn => {
- if (!isLoggedIn) {
- this.dispatchEvent(new CustomEvent('show-auth-required', {
- composed: true, bubbles: true,
- }));
- return;
- }
-
- e.preventDefault();
- this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
- });
- }
-
- _handleOpenDownloadDialogShortcut(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._handleOpenDownloadDialog();
- }
-
- _handleEditTopic(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.metadata.editTopic();
- }
-
- _handleDiffAgainstBase(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Base is already selected.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
- }
-
- _handleDiffBaseAgainstLeft(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Left is already base.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
- }
-
- _handleDiffAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Latest is already selected.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, latestPatchNum,
- this._patchRange.basePatchNum);
- }
-
- _handleDiffRightAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Right is already latest.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, latestPatchNum,
- this._patchRange.patchNum);
- }
-
- _handleDiffBaseAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
- patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Already diffing base against latest.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToChange(this._change, latestPatchNum);
- }
-
- _handleRefreshChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- e.preventDefault();
- GerritNav.navigateToChange(this._change);
- }
-
- _handleToggleChangeStar(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.changeStar.toggleStar();
- }
-
- _handleUpToDashboard(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._determinePageBack();
- }
-
- _handleExpandAllMessages(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.messagesList.handleExpandCollapse(true);
- }
-
- _handleCollapseAllMessages(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.messagesList.handleExpandCollapse(false);
- }
-
- _handleOpenDiffPrefsShortcut(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- if (this._diffPrefsDisabled) { return; }
-
- e.preventDefault();
- this.$.fileList.openDiffPrefs();
- }
-
- _determinePageBack() {
- // Default backPage to root if user came to change view page
- // via an email link, etc.
- GerritNav.navigateToRelativeUrl(this.backPage ||
- GerritNav.getUrlForRoot());
- }
-
- _handleLabelRemoved(splices, path) {
- for (const splice of splices) {
- for (const removed of splice.removed) {
- const changePath = path.split('.');
- const labelPath = changePath.splice(0, changePath.length - 2);
- const labelDict = this.get(labelPath);
- if (labelDict.approved &&
- labelDict.approved._account_id === removed._account_id) {
- this._reload();
- return;
- }
- }
- }
- }
-
- _labelsChanged(changeRecord) {
- if (!changeRecord) { return; }
- if (changeRecord.value && changeRecord.value.indexSplices) {
- this._handleLabelRemoved(changeRecord.value.indexSplices,
- changeRecord.path);
- }
- this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
- change: this._change,
- });
- }
-
- /**
- * @param {string=} opt_section
- */
- _openReplyDialog(opt_section) {
- this.$.replyOverlay.open().finally(() => {
- // the following code should be executed no matter open succeed or not
- this._resetReplyOverlayFocusStops();
- this.$.replyDialog.open(opt_section);
- flush();
- this.$.replyOverlay.center();
- });
- }
-
- _handleReloadChange() {
- return this._reload();
- }
-
- _handleGetChangeDetailError(response) {
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _getServerConfig() {
- return this.$.restAPI.getConfig();
- }
-
- _getProjectConfig() {
- if (!this._change) return;
- return this.$.restAPI.getProjectConfig(this._change.project).then(
- config => {
- this._projectConfig = config;
- });
- }
-
- _getPreferences() {
- return this.$.restAPI.getPreferences();
- }
-
- _prepareCommitMsgForLinkify(msg) {
- // TODO(wyatta) switch linkify sequence, see issue 5526.
- // This is a zero-with space. It is added to prevent the linkify library
- // from including R= or CC= as part of the email address.
- return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
- }
-
- /**
- * Utility function to make the necessary modifications to a change in the
- * case an edit exists.
- *
- * @param {!Object} change
- * @param {?Object} edit
- */
- _processEdit(change, edit) {
- if (!edit) { return; }
- change.revisions[edit.commit.commit] = {
- _number: SPECIAL_PATCH_SET_NUM.EDIT,
- basePatchNum: edit.base_patch_set_number,
- commit: edit.commit,
- fetch: edit.fetch,
- };
- // If the edit is based on the most recent patchset, load it by
- // default, unless another patch set to load was specified in the URL.
- if (!this._patchRange.patchNum &&
- change.current_revision === edit.base_revision) {
- change.current_revision = edit.commit.commit;
- this.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
- // Because edits are fibbed as revisions and added to the revisions
- // array, and revision actions are always derived from the 'latest'
- // patch set, we must copy over actions from the patch set base.
- // Context: Issue 7243
- change.revisions[edit.commit.commit].actions =
- change.revisions[edit.base_revision].actions;
- }
- }
-
- _getChangeDetail() {
- const detailCompletes = this.$.restAPI.getChangeDetail(
- this._changeNum, this._handleGetChangeDetailError.bind(this));
- const editCompletes = this._getEdit();
- const prefCompletes = this._getPreferences();
-
- return Promise.all([detailCompletes, editCompletes, prefCompletes])
- .then(([change, edit, prefs]) => {
- this._prefs = prefs;
-
- if (!change) {
- return '';
- }
- this._processEdit(change, edit);
- // Issue 4190: Coalesce missing topics to null.
- if (!change.topic) { change.topic = null; }
- if (!change.reviewer_updates) {
- change.reviewer_updates = null;
- }
- const latestRevisionSha = this._getLatestRevisionSHA(change);
- const currentRevision = change.revisions[latestRevisionSha];
- if (currentRevision.commit && currentRevision.commit.message) {
- this._latestCommitMessage = this._prepareCommitMsgForLinkify(
- currentRevision.commit.message);
- } else {
- this._latestCommitMessage = null;
- }
-
- const lineHeight = getComputedStyle(this).lineHeight;
-
- // Slice returns a number as a string, convert to an int.
- this._lineHeight =
- parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
-
- this._change = change;
- if (!this._patchRange || !this._patchRange.patchNum ||
- patchNumEquals(this._patchRange.patchNum,
- currentRevision._number)) {
- // CommitInfo.commit is optional, and may need patching.
- if (!currentRevision.commit.commit) {
- currentRevision.commit.commit = latestRevisionSha;
- }
- this._commitInfo = currentRevision.commit;
- this._selectedRevision = currentRevision;
- // TODO: Fetch and process files.
- } else {
- this._selectedRevision =
- Object.values(this._change.revisions).find(
- revision => {
- // edit patchset is a special one
- const thePatchNum = this._patchRange.patchNum;
- if (thePatchNum === 'edit') {
- return revision._number === thePatchNum;
- }
- return revision._number === parseInt(thePatchNum, 10);
- });
- }
- });
- }
-
- _isSubmitEnabled(revisionActions) {
- return !!(revisionActions && revisionActions.submit &&
- revisionActions.submit.enabled);
- }
-
- _isParentCurrent(revisionActions) {
- if (revisionActions && revisionActions.rebase) {
- return !revisionActions.rebase.enabled;
- } else {
- return true;
- }
- }
-
- _getEdit() {
- return this.$.restAPI.getChangeEdit(this._changeNum, true);
- }
-
- _getLatestCommitMessage() {
- return this.$.restAPI.getChangeCommitInfo(this._changeNum,
- computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
- if (!commitInfo) return Promise.resolve();
- this._latestCommitMessage =
- this._prepareCommitMsgForLinkify(commitInfo.message);
- });
- }
-
- _getLatestRevisionSHA(change) {
- if (change.current_revision) {
- return change.current_revision;
- }
- // current_revision may not be present in the case where the latest rev is
- // a draft and the user doesn’t have permission to view that rev.
- let latestRev = null;
- let latestPatchNum = -1;
- for (const rev in change.revisions) {
- if (!change.revisions.hasOwnProperty(rev)) { continue; }
-
- if (change.revisions[rev]._number > latestPatchNum) {
- latestRev = rev;
- latestPatchNum = change.revisions[rev]._number;
- }
- }
- return latestRev;
- }
-
- _getCommitInfo() {
- return this.$.restAPI.getChangeCommitInfo(
- this._changeNum, this._patchRange.patchNum).then(
- commitInfo => {
- this._commitInfo = commitInfo;
- });
- }
-
- _reloadDraftsWithCallback(e) {
- return this._reloadDrafts().then(() => e.detail.resolve());
- }
-
- /**
- * Fetches a new changeComment object, and data for all types of comments
- * (comments, robot comments, draft comments) is requested.
- */
- _reloadComments() {
- // We are resetting all comment related properties, because we want to avoid
- // a new change being loaded and then paired with outdated comments.
- this._changeComments = undefined;
- this._commentThreads = undefined;
- this._diffDrafts = undefined;
- this._draftCommentThreads = undefined;
- this._robotCommentThreads = undefined;
- return this.$.commentAPI.loadAll(this._changeNum)
- .then(comments => this._recomputeComments(comments));
- }
-
- /**
- * Fetches a new changeComment object, but only updated data for drafts is
- * requested.
- *
- * TODO(taoalpha): clean up this and _reloadComments, as single comment
- * can be a thread so it does not make sense to only update drafts
- * without updating threads
- */
- _reloadDrafts() {
- return this.$.commentAPI.reloadDrafts(this._changeNum)
- .then(comments => this._recomputeComments(comments));
- }
-
- _recomputeComments(comments) {
- this._changeComments = comments;
- this._diffDrafts = {...this._changeComments.drafts};
- this._commentThreads = this._changeComments.getAllThreadsForChange();
- this._draftCommentThreads = this._commentThreads
- .filter(thread => thread.comments[thread.comments.length - 1].__draft)
- .map(thread => {
- const copiedThread = {...thread};
- // Make a hardcopy of all comments and collapse all but last one
- const commentsInThread = copiedThread.comments = thread.comments
- .map(comment => { return {...comment, collapsed: true}; });
- commentsInThread[commentsInThread.length - 1].collapsed = false;
- return copiedThread;
- });
- }
-
- /**
- * Reload the change.
- *
- * @param {boolean=} opt_isLocationChange Reloads the related changes
- * when true and ends reporting events that started on location change.
- * @return {Promise} A promise that resolves when the core data has loaded.
- * Some non-core data loading may still be in-flight when the core data
- * promise resolves.
- */
- _reload(opt_isLocationChange) {
- this._loading = true;
- this._relatedChangesCollapsed = true;
- this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
- this.reporting.time(CHANGE_DATA_TIMING_LABEL);
-
- // Array to house all promises related to data requests.
- const allDataPromises = [];
-
- // Resolves when the change detail and the edit patch set (if available)
- // are loaded.
- const detailCompletes = this._getChangeDetail();
- allDataPromises.push(detailCompletes);
-
- // Resolves when the loading flag is set to false, meaning that some
- // change content may start appearing.
- const loadingFlagSet = detailCompletes
- .then(() => {
- this._loading = false;
- this.dispatchEvent(new CustomEvent('change-details-loaded',
- {bubbles: true, composed: true}));
- })
- .then(() => {
- this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
- if (opt_isLocationChange) {
- this.reporting.changeDisplayed();
- }
- });
-
- // Resolves when the project config has loaded.
- const projectConfigLoaded = detailCompletes
- .then(() => this._getProjectConfig());
- allDataPromises.push(projectConfigLoaded);
-
- // Resolves when change comments have loaded (comments, drafts and robot
- // comments).
- const commentsLoaded = this._reloadComments();
- allDataPromises.push(commentsLoaded);
-
- let coreDataPromise;
-
- // If the patch number is specified
- if (this._patchRange && this._patchRange.patchNum) {
- // Because a specific patchset is specified, reload the resources that
- // are keyed by patch number or patch range.
- const patchResourcesLoaded = this._reloadPatchNumDependentResources();
- allDataPromises.push(patchResourcesLoaded);
-
- // Promise resolves when the change detail and patch dependent resources
- // have loaded.
- const detailAndPatchResourcesLoaded =
- Promise.all([patchResourcesLoaded, loadingFlagSet]);
-
- // Promise resolves when mergeability information has loaded.
- const mergeabilityLoaded = detailAndPatchResourcesLoaded
- .then(() => this._getMergeability());
- allDataPromises.push(mergeabilityLoaded);
-
- // Promise resovles when the change actions have loaded.
- const actionsLoaded = detailAndPatchResourcesLoaded
- .then(() => this.$.actions.reload());
- allDataPromises.push(actionsLoaded);
-
- // The core data is loaded when both mergeability and actions are known.
- coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
- } else {
- // Resolves when the file list has loaded.
- const fileListReload = loadingFlagSet
- .then(() => this.$.fileList.reload());
- allDataPromises.push(fileListReload);
-
- const latestCommitMessageLoaded = loadingFlagSet.then(() => {
- // If the latest commit message is known, there is nothing to do.
- if (this._latestCommitMessage) { return Promise.resolve(); }
- return this._getLatestCommitMessage();
- });
- allDataPromises.push(latestCommitMessageLoaded);
-
- // Promise resolves when mergeability information has loaded.
- const mergeabilityLoaded = loadingFlagSet
- .then(() => this._getMergeability());
- allDataPromises.push(mergeabilityLoaded);
-
- // Core data is loaded when mergeability has been loaded.
- coreDataPromise = mergeabilityLoaded;
- }
-
- if (opt_isLocationChange) {
- this._editingCommitMessage = false;
- const relatedChangesLoaded = coreDataPromise
- .then(() => this.$.relatedChanges.reload());
- allDataPromises.push(relatedChangesLoaded);
- }
-
- Promise.all(allDataPromises).then(() => {
- this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
- if (opt_isLocationChange) {
- this.reporting.changeFullyLoaded();
- }
- });
-
- return coreDataPromise;
- }
-
- /**
- * Kicks off requests for resources that rely on the patch range
- * (`this._patchRange`) being defined.
- */
- _reloadPatchNumDependentResources() {
- return Promise.all([
- this._getCommitInfo(),
- this.$.fileList.reload(),
- ]);
- }
-
- _getMergeability() {
- if (!this._change) {
- this._mergeable = null;
- return Promise.resolve();
- }
- // If the change is closed, it is not mergeable. Note: already merged
- // changes are obviously not mergeable, but the mergeability API will not
- // answer for abandoned changes.
- if (this._change.status === ChangeStatus.MERGED ||
- this._change.status === ChangeStatus.ABANDONED) {
- this._mergeable = false;
- return Promise.resolve();
- }
-
- this._mergeable = null;
- return this.$.restAPI.getMergeable(this._changeNum).then(m => {
- this._mergeable = m.mergeable;
- });
- }
-
- _computeCanStartReview(change) {
- return !!(change.actions && change.actions.ready &&
- change.actions.ready.enabled);
- }
-
- _computeReplyDisabled() { return false; }
-
- _computeChangePermalinkAriaLabel(changeNum) {
- return 'Change ' + changeNum;
- }
-
- _computeCommitMessageCollapsed(collapsed, collapsible) {
- return collapsible && collapsed;
- }
-
- _computeRelatedChangesClass(collapsed) {
- return collapsed ? 'collapsed' : '';
- }
-
- _computeCollapseText(collapsed) {
- // Symbols are up and down triangles.
- return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
- }
-
- /**
- * Returns the text to be copied when
- * click the copy icon next to change subject
- *
- * @param {!Object} change
- */
- _computeCopyTextForTitle(change) {
- return `${change._number}: ${change.subject} | ` +
- `${location.protocol}//${location.host}` +
- `${this._computeChangeUrl(change)}`;
- }
-
- _toggleCommitCollapsed() {
- this._commitCollapsed = !this._commitCollapsed;
- if (this._commitCollapsed) {
- window.scrollTo(0, 0);
- }
- }
-
- _toggleRelatedChangesCollapsed() {
- this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
- if (this._relatedChangesCollapsed) {
- window.scrollTo(0, 0);
- }
- }
-
- _computeCommitCollapsible(commitMessage) {
- if (!commitMessage) { return false; }
- return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
- }
-
- _getOffsetHeight(element) {
- return element.offsetHeight;
- }
-
- _getScrollHeight(element) {
- return element.scrollHeight;
- }
-
- /**
- * Get the line height of an element to the nearest integer.
- */
- _getLineHeight(element) {
- const lineHeightStr = getComputedStyle(element).lineHeight;
- return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
- }
-
- /**
- * New max height for the related changes section, shorter than the existing
- * change info height.
- */
- _updateRelatedChangeMaxHeight() {
- // Takes into account approximate height for the expand button and
- // bottom margin.
- const EXTRA_HEIGHT = 30;
- let newHeight;
-
- if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
- .matches) {
- // In a small (mobile) view, give the relation chain some space.
- newHeight = SMALL_RELATED_HEIGHT;
- } else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`)
- .matches) {
- // Since related changes are below the commit message, but still next to
- // metadata, the height should be the height of the metadata minus the
- // height of the commit message to reduce jank. However, if that doesn't
- // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
- // Note: extraHeight is to take into account margin/padding.
- const medRelatedHeight = Math.max(
- this._getOffsetHeight(this.$.mainChangeInfo) -
- this._getOffsetHeight(this.$.commitMessage) - 2 * EXTRA_HEIGHT,
- MINIMUM_RELATED_MAX_HEIGHT);
- newHeight = medRelatedHeight;
- } else {
- if (this._commitCollapsible) {
- // Make sure the content is lined up if both areas have buttons. If
- // the commit message is not collapsed, instead use the change info
- // height.
- newHeight = this._getOffsetHeight(this.$.commitMessage);
- } else {
- newHeight = this._getOffsetHeight(this.$.commitAndRelated) -
- EXTRA_HEIGHT;
- }
- }
- const stylesToUpdate = {};
-
- // Get the line height of related changes, and convert it to the nearest
- // integer.
- const lineHeight = this._getLineHeight(this.$.relatedChanges);
-
- // Figure out a new height that is divisible by the rounded line height.
- const remainder = newHeight % lineHeight;
- newHeight = newHeight - remainder;
-
- stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
-
- // Update the max-height of the relation chain to this new height.
- if (this._commitCollapsible) {
- stylesToUpdate['--related-change-btn-top-padding'] = remainder + 'px';
- }
-
- this.updateStyles(stylesToUpdate);
- }
-
- _computeShowRelatedToggle() {
- // Make sure the max height has been applied, since there is now content
- // to populate.
- if (!getComputedStyleValue('--relation-chain-max-height', this)) {
- this._updateRelatedChangeMaxHeight();
- }
- // Prevents showMore from showing when click on related change, since the
- // line height would be positive, but related changes height is 0.
- if (!this._getScrollHeight(this.$.relatedChanges)) {
- return this._showRelatedToggle = false;
- }
-
- if (this._getScrollHeight(this.$.relatedChanges) >
- (this._getOffsetHeight(this.$.relatedChanges) +
- this._getLineHeight(this.$.relatedChanges))) {
- return this._showRelatedToggle = true;
- }
- this._showRelatedToggle = false;
- }
-
- _updateToggleContainerClass(showRelatedToggle) {
- if (showRelatedToggle) {
- this.$.relatedChangesToggle.classList.add('showToggle');
- } else {
- this.$.relatedChangesToggle.classList.remove('showToggle');
- }
- }
-
- _startUpdateCheckTimer() {
- if (!this._serverConfig ||
- !this._serverConfig.change ||
- this._serverConfig.change.update_delay === undefined ||
- this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
- return;
- }
-
- this._updateCheckTimerHandle = this.async(() => {
- fetchChangeUpdates(this._change, this.$.restAPI).then(result => {
- let toastMessage = null;
- if (!result.isLatest) {
- toastMessage = ReloadToastMessage.NEWER_REVISION;
- } else if (result.newStatus === ChangeStatus.MERGED) {
- toastMessage = ReloadToastMessage.MERGED;
- } else if (result.newStatus === ChangeStatus.ABANDONED) {
- toastMessage = ReloadToastMessage.ABANDONED;
- } else if (result.newStatus === ChangeStatus.NEW) {
- toastMessage = ReloadToastMessage.RESTORED;
- } else if (result.newMessages) {
- toastMessage = ReloadToastMessage.NEW_MESSAGE;
- }
-
- if (!toastMessage) {
- this._startUpdateCheckTimer();
- return;
- }
-
- this._cancelUpdateCheckTimer();
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: toastMessage,
- // Persist this alert.
- dismissOnNavigation: true,
- action: 'Reload',
- callback: function() {
- // Load the current change without any patch range.
- GerritNav.navigateToChange(this._change);
- }.bind(this),
- },
- composed: true, bubbles: true,
- }));
- });
- }, this._serverConfig.change.update_delay * 1000);
- }
-
- _cancelUpdateCheckTimer() {
- if (this._updateCheckTimerHandle) {
- this.cancelAsync(this._updateCheckTimerHandle);
- }
- this._updateCheckTimerHandle = null;
- }
-
- _handleVisibilityChange() {
- if (document.hidden && this._updateCheckTimerHandle) {
- this._cancelUpdateCheckTimer();
- } else if (!this._updateCheckTimerHandle) {
- this._startUpdateCheckTimer();
- }
- }
-
- _handleTopicChanged() {
- this.$.relatedChanges.reload();
- }
-
- _computeHeaderClass(editMode) {
- const classes = ['header'];
- if (editMode) { classes.push('editMode'); }
- return classes.join(' ');
- }
-
- _computeEditMode(patchRangeRecord, paramsRecord) {
- if ([patchRangeRecord, paramsRecord].includes(undefined)) {
- return undefined;
- }
-
- if (paramsRecord.base && paramsRecord.base.edit) { return true; }
-
- const patchRange = patchRangeRecord.base || {};
- return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
- }
-
- _handleFileActionTap(e) {
- e.preventDefault();
- const controls = this.$.fileListHeader
- .shadowRoot.querySelector('#editControls');
- const path = e.detail.path;
- switch (e.detail.action) {
- case GrEditConstants.Actions.DELETE.id:
- controls.openDeleteDialog(path);
- break;
- case GrEditConstants.Actions.OPEN.id:
- GerritNav.navigateToRelativeUrl(
- GerritNav.getEditUrlForDiff(this._change, path,
- this._patchRange.patchNum));
- break;
- case GrEditConstants.Actions.RENAME.id:
- controls.openRenameDialog(path);
- break;
- case GrEditConstants.Actions.RESTORE.id:
- controls.openRestoreDialog(path);
- break;
- }
- }
-
- _computeCommitMessageKey(number, revision) {
- return `c${number}_rev${revision}`;
- }
-
- _patchNumChanged(patchNumStr) {
- if (!this._selectedRevision) {
- return;
- }
-
- let patchNum = parseInt(patchNumStr, 10);
- if (patchNumStr === 'edit') {
- patchNum = patchNumStr;
- }
-
- if (patchNum === this._selectedRevision._number) {
- return;
- }
- this._selectedRevision = Object.values(this._change.revisions).find(
- revision => revision._number === patchNum);
- }
-
- /**
- * If an edit exists already, load it. Otherwise, toggle edit mode via the
- * navigation API.
- */
- _handleEditTap() {
- const editInfo = Object.values(this._change.revisions).find(info =>
- info._number === SPECIAL_PATCH_SET_NUM.EDIT);
-
- if (editInfo) {
- GerritNav.navigateToChange(this._change, SPECIAL_PATCH_SET_NUM.EDIT);
- return;
- }
-
- // Avoid putting patch set in the URL unless a non-latest patch set is
- // selected.
- let patchNum;
- if (!patchNumEquals(this._patchRange.patchNum,
- computeLatestPatchNum(this._allPatchSets))) {
- patchNum = this._patchRange.patchNum;
- }
- GerritNav.navigateToChange(this._change, patchNum, null, true);
- }
-
- _handleStopEditTap() {
- GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
- }
-
- _resetReplyOverlayFocusStops() {
- this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
- }
-
- _handleToggleStar(e) {
- this.$.restAPI.saveChangeStarred(e.detail.change._number,
- e.detail.starred);
- }
-
- _getRevisionInfo(change) {
- return new RevisionInfo(change);
- }
-
- _computeCurrentRevision(currentRevision, revisions) {
- return currentRevision && revisions && revisions[currentRevision];
- }
-
- _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
- return disableDiffPrefs || !loggedIn;
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeLatestPatchNum(allPatchSets) {
- return computeLatestPatchNum(allPatchSets);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _hasEditBasedOnCurrentPatchSet(allPatchSets) {
- return hasEditBasedOnCurrentPatchSet(allPatchSets);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _hasEditPatchsetLoaded(patchRangeRecord) {
- return hasEditPatchsetLoaded(patchRangeRecord);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeAllPatchSets(change) {
- return computeAllPatchSets(change);
- }
-}
-
-customElements.define(GrChangeView.is, GrChangeView);
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
new file mode 100644
index 0000000..1119a20
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -0,0 +1,2790 @@
+/**
+ * @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 '@polymer/paper-tabs/paper-tabs';
+import '../../../styles/shared-styles';
+import '../../diff/gr-comment-api/gr-comment-api';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-account-link/gr-account-link';
+import '../../shared/gr-button/gr-button';
+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-editable-content/gr-editable-content';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../gr-change-actions/gr-change-actions';
+import '../gr-change-metadata/gr-change-metadata';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-commit-info/gr-commit-info';
+import '../gr-download-dialog/gr-download-dialog';
+import '../gr-file-list-header/gr-file-list-header';
+import '../gr-included-in-dialog/gr-included-in-dialog';
+import '../gr-messages-list/gr-messages-list';
+import '../gr-related-changes-list/gr-related-changes-list';
+import '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-reply-dialog/gr-reply-dialog';
+import '../gr-thread-list/gr-thread-list';
+import '../gr-upload-help-dialog/gr-upload-help-dialog';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-view_html';
+import {
+ KeyboardShortcutMixin,
+ Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrEditConstants} from '../../edit/gr-edit-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {PrimaryTab, SecondaryTab} from '../../../constants/constants';
+import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
+import {appContext} from '../../../services/app-context';
+import {ChangeStatus} from '../../../constants/constants';
+import {
+ computeAllPatchSets,
+ computeLatestPatchNum,
+ fetchChangeUpdates,
+ hasEditBasedOnCurrentPatchSet,
+ hasEditPatchsetLoaded,
+ patchNumEquals,
+ PatchSet,
+} from '../../../utils/patch-set-util';
+import {changeStatuses, changeStatusString} from '../../../utils/change-util';
+import {EventType} from '../../plugins/gr-plugin-types';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrJsApiInterface} from '../../shared/gr-js-api-interface/gr-js-api-interface-element';
+import {GrApplyFixDialog} from '../../diff/gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {GrFileListHeader} from '../gr-file-list-header/gr-file-list-header';
+import {GrEditableContent} from '../../shared/gr-editable-content/gr-editable-content';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrRelatedChangesList} from '../gr-related-changes-list/gr-related-changes-list';
+import {GrChangeStar} from '../../shared/gr-change-star/gr-change-star';
+import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
+import {
+ AccountDetailInfo,
+ ChangeInfo,
+ NumericChangeId,
+ PatchRange,
+ ActionNameToActionInfoMap,
+ CommitId,
+ PatchSetNum,
+ ParentPatchSetNum,
+ EditPatchSetNum,
+ ServerInfo,
+ ConfigInfo,
+ PreferencesInfo,
+ CommitInfo,
+ DiffPreferencesInfo,
+ RevisionInfo,
+ EditInfo,
+ LabelNameToInfoMap,
+ UrlEncodedCommentId,
+ QuickLabelInfo,
+ ApprovalInfo,
+ ElementPropertyDeepChange,
+} from '../../../types/common';
+import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
+import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
+import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
+import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
+import {
+ GrCommentApi,
+ ChangeComments,
+} from '../../diff/gr-comment-api/gr-comment-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {
+ CommentThread,
+ UIDraft,
+ DraftInfo,
+ isDraftThread,
+ isRobot,
+} from '../../../utils/comment-util';
+import {
+ PolymerDeepPropertyChange,
+ PolymerSpliceChange,
+ PolymerSplice,
+} from '@polymer/polymer/interfaces';
+import {AppElementChangeViewParams} from '../../gr-app-types';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {
+ EditRevisionInfo,
+ ParsedChangeInfo,
+} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+ GrFileList,
+ DEFAULT_NUM_FILES_SHOWN,
+} from '../gr-file-list/gr-file-list';
+import {ChangeViewState, isPolymerSpliceChange} from '../../../types/types';
+import {
+ CustomKeyboardEvent,
+ EditableContentSaveEvent,
+ OpenFixPreviewEvent,
+ ShowAlertEventDetail,
+ SwitchTabEvent,
+} from '../../../types/events';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrMessagesList} from '../gr-messages-list/gr-messages-list';
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {PORTING_COMMENTS_CHANGE_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
+
+const CHANGE_ID_ERROR = {
+ MISMATCH: 'mismatch',
+ MISSING: 'missing',
+};
+const CHANGE_ID_REGEX_PATTERN = /^(Change-Id:\s|Link:.*\/id\/)(I[0-9a-f]{8,40})/gm;
+
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+
+const REVIEWERS_REGEX = /^(R|CC)=/gm;
+const MIN_CHECK_INTERVAL_SECS = 0;
+
+// These are the same as the breakpoint set in CSS. Make sure both are changed
+// together.
+const BREAKPOINT_RELATED_SMALL = '50em';
+const BREAKPOINT_RELATED_MED = '75em';
+
+// In the event that the related changes medium width calculation is too close
+// to zero, provide some height.
+const MINIMUM_RELATED_MAX_HEIGHT = 100;
+
+const SMALL_RELATED_HEIGHT = 400;
+
+const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
+
+const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
+
+const MSG_PREFIX = '#message-';
+
+const ReloadToastMessage = {
+ NEWER_REVISION: 'A newer patch set has been uploaded',
+ RESTORED: 'This change has been restored',
+ ABANDONED: 'This change has been abandoned',
+ MERGED: 'This change has been merged',
+ NEW_MESSAGE: 'There are new messages on this change',
+};
+
+enum DiffViewMode {
+ SIDE_BY_SIDE = 'SIDE_BY_SIDE',
+ UNIFIED = 'UNIFIED_DIFF',
+}
+
+const CHANGE_DATA_TIMING_LABEL = 'ChangeDataLoaded';
+const CHANGE_RELOAD_TIMING_LABEL = 'ChangeReloaded';
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
+// Making the tab names more unique in case a plugin adds one with same name
+const ROBOT_COMMENTS_LIMIT = 10;
+
+export interface GrChangeView {
+ $: {
+ restAPI: RestApiService & Element;
+ jsAPI: GrJsApiInterface;
+ commentAPI: GrCommentApi;
+ applyFixDialog: GrApplyFixDialog;
+ fileList: GrFileList & Element;
+ fileListHeader: GrFileListHeader;
+ commitMessageEditor: GrEditableContent;
+ includedInOverlay: GrOverlay;
+ includedInDialog: GrIncludedInDialog;
+ downloadOverlay: GrOverlay;
+ downloadDialog: GrDownloadDialog;
+ uploadHelpOverlay: GrOverlay;
+ replyOverlay: GrOverlay;
+ replyDialog: GrReplyDialog;
+ mainContent: HTMLDivElement;
+ relatedChanges: GrRelatedChangesList;
+ changeStar: GrChangeStar;
+ actions: GrChangeActions;
+ commitMessage: HTMLDivElement;
+ commitAndRelated: HTMLDivElement;
+ metadata: GrChangeMetadata;
+ relatedChangesToggle: HTMLDivElement;
+ mainChangeInfo: HTMLDivElement;
+ commitCollapseToggleButton: GrButton;
+ commitCollapseToggle: HTMLDivElement;
+ relatedChangesToggleButton: GrButton;
+ replyBtn: GrButton;
+ };
+}
+
+export type ChangeViewPatchRange = Partial<PatchRange>;
+
+@customElement('gr-change-view')
+export class GrChangeView extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ /**
+ * Fired if an error occurs when fetching the change data.
+ *
+ * @event page-error
+ */
+
+ /**
+ * Fired if being logged in is required.
+ *
+ * @event show-auth-required
+ */
+
+ reporting = appContext.reportingService;
+
+ /**
+ * URL params passed from the router.
+ */
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: AppElementChangeViewParams;
+
+ @property({type: Object, notify: true, observer: '_viewStateChanged'})
+ viewState: Partial<ChangeViewState> = {};
+
+ @property({type: String})
+ backPage?: string;
+
+ @property({type: Boolean})
+ hasParent?: boolean;
+
+ @property({type: Object})
+ keyEventTarget = document.body;
+
+ @property({type: Boolean})
+ disableEdit = false;
+
+ @property({type: Boolean})
+ disableDiffPrefs = false;
+
+ @property({
+ type: Boolean,
+ computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+ })
+ _diffPrefsDisabled?: boolean;
+
+ @property({type: Array})
+ _commentThreads?: CommentThread[];
+
+ // TODO(taoalpha): Consider replacing diffDrafts
+ // with _draftCommentThreads everywhere, currently only
+ // replaced in reply-dialog
+ @property({type: Array})
+ _draftCommentThreads?: CommentThread[];
+
+ @property({
+ type: Array,
+ computed:
+ '_computeRobotCommentThreads(_commentThreads,' +
+ ' _currentRobotCommentsPatchSet, _showAllRobotComments)',
+ })
+ _robotCommentThreads?: CommentThread[];
+
+ @property({type: Object, observer: '_startUpdateCheckTimer'})
+ _serverConfig?: ServerInfo;
+
+ @property({type: Object})
+ _diffPrefs?: DiffPreferencesInfo;
+
+ @property({type: Number, observer: '_numFilesShownChanged'})
+ _numFilesShown = DEFAULT_NUM_FILES_SHOWN;
+
+ @property({type: Object})
+ _account?: AccountDetailInfo;
+
+ @property({type: Object})
+ _prefs?: PreferencesInfo;
+
+ @property({type: Object})
+ _changeComments?: ChangeComments;
+
+ @property({type: Boolean, computed: '_computeCanStartReview(_change)'})
+ _canStartReview?: boolean;
+
+ @property({type: Object, observer: '_changeChanged'})
+ _change?: ChangeInfo | ParsedChangeInfo;
+
+ @property({type: Object, computed: '_getRevisionInfo(_change)'})
+ _revisionInfo?: RevisionInfoClass;
+
+ @property({type: Object})
+ _commitInfo?: CommitInfo;
+
+ @property({
+ type: Object,
+ computed:
+ '_computeCurrentRevision(_change.current_revision, ' +
+ '_change.revisions)',
+ observer: '_handleCurrentRevisionUpdate',
+ })
+ _currentRevision?: RevisionInfo;
+
+ @property({type: String})
+ _changeNum?: NumericChangeId;
+
+ @property({type: Object})
+ _diffDrafts?: {[path: string]: UIDraft[]} = {};
+
+ @property({type: Boolean})
+ _editingCommitMessage = false;
+
+ @property({
+ type: Boolean,
+ computed:
+ '_computeHideEditCommitMessage(_loggedIn, ' +
+ '_editingCommitMessage, _change, _editMode, _commitCollapsed, ' +
+ '_commitCollapsible)',
+ })
+ _hideEditCommitMessage?: boolean;
+
+ @property({type: String})
+ _diffAgainst?: string;
+
+ @property({type: String})
+ _latestCommitMessage: string | null = '';
+
+ @property({type: Object})
+ _constants = {
+ SecondaryTab,
+ PrimaryTab,
+ };
+
+ @property({type: Object})
+ _messages = NO_ROBOT_COMMENTS_THREADS_MSG;
+
+ @property({type: Number})
+ _lineHeight?: number;
+
+ @property({
+ type: String,
+ computed:
+ '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
+ })
+ _changeIdCommitMessageError?: string;
+
+ @property({type: Object})
+ _patchRange?: ChangeViewPatchRange;
+
+ @property({type: String})
+ _filesExpanded?: string;
+
+ @property({type: String})
+ _basePatchNum?: string;
+
+ @property({type: Object})
+ _selectedRevision?: RevisionInfo | EditRevisionInfo;
+
+ @property({type: Object})
+ _currentRevisionActions?: ActionNameToActionInfoMap;
+
+ @property({
+ type: Array,
+ computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+ })
+ _allPatchSets?: PatchSet[];
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Boolean})
+ _loading?: boolean;
+
+ @property({type: Object})
+ _projectConfig?: ConfigInfo;
+
+ @property({
+ type: String,
+ computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+ })
+ _replyButtonLabel = 'Reply';
+
+ @property({type: String})
+ _selectedPatchSet?: string;
+
+ @property({type: Number})
+ _shownFileCount?: number;
+
+ @property({type: Boolean})
+ _initialLoadComplete = false;
+
+ @property({type: Boolean})
+ _replyDisabled = true;
+
+ @property({type: String, computed: '_changeStatusString(_change)'})
+ _changeStatus?: string;
+
+ @property({
+ type: String,
+ computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
+ })
+ _changeStatuses?: string[];
+
+ /** If false, then the "Show more" button was used to expand. */
+ @property({type: Boolean})
+ _commitCollapsed = true;
+
+ /** Is the "Show more/less" button visible? */
+ @property({
+ type: Boolean,
+ computed: '_computeCommitCollapsible(_latestCommitMessage)',
+ })
+ _commitCollapsible?: boolean;
+
+ @property({type: Boolean})
+ _relatedChangesCollapsed = true;
+
+ @property({type: Number})
+ _updateCheckTimerHandle?: number | null;
+
+ @property({
+ type: Boolean,
+ computed: '_computeEditMode(_patchRange.*, params.*)',
+ })
+ _editMode?: boolean;
+
+ @property({type: Boolean, observer: '_updateToggleContainerClass'})
+ _showRelatedToggle = false;
+
+ @property({
+ type: Boolean,
+ computed: '_isParentCurrent(_currentRevisionActions)',
+ })
+ _parentIsCurrent?: boolean;
+
+ @property({
+ type: Boolean,
+ computed: '_isSubmitEnabled(_currentRevisionActions)',
+ })
+ _submitEnabled?: boolean;
+
+ @property({type: Boolean})
+ _mergeable: boolean | null = null;
+
+ @property({type: Boolean})
+ _showFileTabContent = true;
+
+ @property({type: Array})
+ _dynamicTabHeaderEndpoints: string[] = [];
+
+ @property({type: Array})
+ _dynamicTabContentEndpoints: string[] = [];
+
+ @property({type: String})
+ // The dynamic content of the plugin added tab
+ _selectedTabPluginEndpoint?: string;
+
+ @property({type: String})
+ // The dynamic heading of the plugin added tab
+ _selectedTabPluginHeader?: string;
+
+ @property({
+ type: Array,
+ computed:
+ '_computeRobotCommentsPatchSetDropdownItems(_change, _commentThreads)',
+ })
+ _robotCommentsPatchSetDropdownItems: DropdownLink[] = [];
+
+ @property({type: Number})
+ _currentRobotCommentsPatchSet?: PatchSetNum;
+
+ /**
+ * this is a two-element tuple to always
+ * hold the current active tab for both primary and secondary tabs
+ */
+ @property({type: Array})
+ _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
+
+ @property({type: Boolean})
+ _showAllRobotComments = false;
+
+ @property({type: Boolean})
+ _showRobotCommentsButton = false;
+
+ _throttledToggleChangeStar?: EventListener;
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
+ [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
+ [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
+ [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
+ [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut',
+ [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+ [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
+ [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
+ [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
+ [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
+ [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_expandAllDiffs',
+ [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
+ [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
+ [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+ [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+ [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+ [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+ [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+ };
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ this._throttledToggleChangeStar = this._throttleWrap(e =>
+ this._handleToggleChangeStar(e as CustomKeyboardEvent)
+ );
+ }
+
+ /** @override */
+ created() {
+ super.created();
+
+ this.addEventListener('topic-changed', () => this._handleTopicChanged());
+
+ this.addEventListener(
+ // When an overlay is opened in a mobile viewport, the overlay has a full
+ // screen view. When it has a full screen view, we do not want the
+ // background to be scrollable. This will eliminate background scroll by
+ // hiding most of the contents on the screen upon opening, and showing
+ // again upon closing.
+ 'fullscreen-overlay-opened',
+ () => this._handleHideBackgroundContent()
+ );
+
+ this.addEventListener('fullscreen-overlay-closed', () =>
+ this._handleShowBackgroundContent()
+ );
+
+ this.addEventListener('diff-comments-modified', () =>
+ this._handleReloadCommentThreads()
+ );
+
+ this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getServerConfig().then(config => {
+ this._serverConfig = config;
+ this._replyDisabled = false;
+ });
+
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ if (loggedIn) {
+ this.$.restAPI.getAccount().then(acct => {
+ this._account = acct;
+ });
+ }
+ this._setDiffViewMode();
+ });
+
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._dynamicTabHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-tab-header'
+ );
+ this._dynamicTabContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-tab-content'
+ );
+ if (
+ this._dynamicTabContentEndpoints.length !==
+ this._dynamicTabHeaderEndpoints.length
+ ) {
+ console.warn('Different number of tab headers and tab content.');
+ }
+ })
+ .then(() => this._initActiveTabs(this.params));
+
+ this.addEventListener('comment-save', e => this._handleCommentSave(e));
+ this.addEventListener('comment-refresh', () => this._reloadDrafts());
+ this.addEventListener('comment-discard', e =>
+ this._handleCommentDiscard(e)
+ );
+ this.addEventListener('change-message-deleted', () => this._reload());
+ this.addEventListener('editable-content-save', e =>
+ this._handleCommitMessageSave(e)
+ );
+ this.addEventListener('editable-content-cancel', () =>
+ this._handleCommitMessageCancel()
+ );
+ this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+ this.addEventListener('close-fix-preview', () => this._onCloseFixPreview());
+ this.listen(window, 'scroll', '_handleScroll');
+ this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+
+ this.addEventListener('show-primary-tab', e =>
+ this._setActivePrimaryTab(e)
+ );
+ this.addEventListener('show-secondary-tab', e =>
+ this._setActiveSecondaryTab(e)
+ );
+ this.addEventListener('reload', e => {
+ e.stopPropagation();
+ this._reload(
+ /* isLocationChange= */ false,
+ /* clearPatchset= */ e.detail && e.detail.clearPatchset
+ );
+ });
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.unlisten(window, 'scroll', '_handleScroll');
+ this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+
+ if (this._updateCheckTimerHandle) {
+ this._cancelUpdateCheckTimer();
+ }
+ }
+
+ get messagesList(): GrMessagesList | null {
+ return this.shadowRoot!.querySelector('gr-messages-list');
+ }
+
+ get threadList(): GrThreadList | null {
+ return this.shadowRoot!.querySelector('gr-thread-list');
+ }
+
+ _changeStatusString(change: ChangeInfo) {
+ return changeStatusString(change);
+ }
+
+ _setDiffViewMode(opt_reset?: boolean) {
+ if (!opt_reset && this.viewState.diffViewMode) {
+ return;
+ }
+
+ return this._getPreferences()
+ .then(prefs => {
+ if (!this.viewState.diffMode && prefs) {
+ this.set('viewState.diffMode', prefs.default_diff_view);
+ }
+ })
+ .then(() => {
+ if (!this.viewState.diffMode) {
+ this.set('viewState.diffMode', 'SIDE_BY_SIDE');
+ }
+ });
+ }
+
+ _onOpenFixPreview(e: OpenFixPreviewEvent) {
+ this.$.applyFixDialog.open(e);
+ }
+
+ _onCloseFixPreview() {
+ this._reload();
+ }
+
+ _handleToggleDiffMode(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
+ this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
+ } else {
+ this.$.fileListHeader.setDiffViewMode(DiffViewMode.SIDE_BY_SIDE);
+ }
+ }
+
+ _isTabActive(tab: string, activeTabs: string[]) {
+ return activeTabs.includes(tab);
+ }
+
+ /**
+ * Actual implementation of switching a tab
+ *
+ * @param paperTabs - the parent tabs container
+ */
+ _setActiveTab(
+ paperTabs: PaperTabsElement,
+ activeDetails: {
+ activeTabName?: string;
+ activeTabIndex?: number;
+ scrollIntoView?: boolean;
+ }
+ ) {
+ const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
+ const tabs = paperTabs.querySelectorAll('paper-tab') as NodeListOf<
+ HTMLElement
+ >;
+ let activeIndex = -1;
+ if (activeTabIndex !== undefined) {
+ activeIndex = activeTabIndex;
+ } else {
+ for (let i = 0; i <= tabs.length; i++) {
+ const tab = tabs[i];
+ if (tab.dataset['name'] === activeTabName) {
+ activeIndex = i;
+ break;
+ }
+ }
+ }
+ if (activeIndex === -1) {
+ console.warn('tab not found with given info', activeDetails);
+ return;
+ }
+ const tabName = tabs[activeIndex].dataset['name'];
+ if (scrollIntoView) {
+ paperTabs.scrollIntoView();
+ }
+ if (paperTabs.selected !== activeIndex) {
+ paperTabs.selected = activeIndex;
+ this.reporting.reportInteraction('show-tab', {tabName});
+ }
+ return tabName;
+ }
+
+ /**
+ * Changes active primary tab.
+ */
+ _setActivePrimaryTab(e: SwitchTabEvent) {
+ const primaryTabs = this.shadowRoot!.querySelector(
+ '#primaryTabs'
+ ) as PaperTabsElement;
+ const activeTabName = this._setActiveTab(primaryTabs, {
+ activeTabName: e.detail.tab,
+ activeTabIndex: e.detail.value,
+ scrollIntoView: e.detail.scrollIntoView,
+ });
+ if (activeTabName) {
+ this._activeTabs = [activeTabName, this._activeTabs[1]];
+
+ // update plugin endpoint if its a plugin tab
+ const pluginIndex = (this._dynamicTabHeaderEndpoints || []).indexOf(
+ activeTabName
+ );
+ if (pluginIndex !== -1) {
+ this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
+ pluginIndex
+ ];
+ this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
+ pluginIndex
+ ];
+ } else {
+ this._selectedTabPluginEndpoint = '';
+ this._selectedTabPluginHeader = '';
+ }
+ }
+ }
+
+ /**
+ * Changes active secondary tab.
+ */
+ _setActiveSecondaryTab(e: SwitchTabEvent) {
+ const secondaryTabs = this.shadowRoot!.querySelector(
+ '#secondaryTabs'
+ ) as PaperTabsElement;
+ const activeTabName = this._setActiveTab(secondaryTabs, {
+ activeTabName: e.detail.tab,
+ activeTabIndex: e.detail.value,
+ scrollIntoView: e.detail.scrollIntoView,
+ });
+ if (activeTabName) {
+ this._activeTabs = [this._activeTabs[0], activeTabName];
+ }
+ }
+
+ _handleEditCommitMessage() {
+ this._editingCommitMessage = true;
+ this.$.commitMessageEditor.focusTextarea();
+ }
+
+ _handleCommitMessageSave(e: EditableContentSaveEvent) {
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ // Trim trailing whitespace from each line.
+ const message = e.detail.content.replace(TRAILING_WHITESPACE_REGEX, '');
+
+ this.$.jsAPI.handleCommitMessage(this._change, message);
+
+ this.$.commitMessageEditor.disabled = true;
+ this.$.restAPI
+ .putChangeCommitMessage(this._changeNum, message)
+ .then(resp => {
+ this.$.commitMessageEditor.disabled = false;
+ if (!resp.ok) {
+ return;
+ }
+
+ this._latestCommitMessage = this._prepareCommitMsgForLinkify(message);
+ this._editingCommitMessage = false;
+ this._reloadWindow();
+ })
+ .catch(() => {
+ this.$.commitMessageEditor.disabled = false;
+ });
+ }
+
+ _reloadWindow() {
+ window.location.reload();
+ }
+
+ _handleCommitMessageCancel() {
+ this._editingCommitMessage = false;
+ }
+
+ _computeChangeStatusChips(
+ change: ChangeInfo | undefined,
+ mergeable: boolean | null,
+ submitEnabled?: boolean
+ ) {
+ if (!change) {
+ return undefined;
+ }
+
+ // Show no chips until mergeability is loaded.
+ if (mergeable === null) {
+ return [];
+ }
+
+ const options = {
+ includeDerived: true,
+ mergeable: !!mergeable,
+ submitEnabled: !!submitEnabled,
+ };
+ return changeStatuses(change, options);
+ }
+
+ _computeHideEditCommitMessage(
+ loggedIn: boolean,
+ editing: boolean,
+ change: ChangeInfo,
+ editMode?: boolean,
+ collapsed?: boolean,
+ collapsible?: boolean
+ ) {
+ if (
+ !loggedIn ||
+ editing ||
+ (change && change.status === ChangeStatus.MERGED) ||
+ editMode ||
+ (collapsed && collapsible)
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ _robotCommentCountPerPatchSet(threads: CommentThread[]) {
+ return threads.reduce((robotCommentCountMap, thread) => {
+ const comments = thread.comments;
+ const robotCommentsCount = comments.reduce(
+ (acc, comment) => (isRobot(comment) ? acc + 1 : acc),
+ 0
+ );
+ if (comments[0].patch_set)
+ robotCommentCountMap[`${comments[0].patch_set}`] =
+ (robotCommentCountMap[`${comments[0].patch_set}`] || 0) +
+ robotCommentsCount;
+ return robotCommentCountMap;
+ }, {} as {[patchset: string]: number});
+ }
+
+ _computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
+ const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
+ const commentCnt = commentCount[patch._number] || 0;
+ if (commentCnt === 0) return `Patchset ${patch._number}`;
+ const findingsText = commentCnt === 1 ? 'finding' : 'findings';
+ return `Patchset ${patch._number} (${commentCnt} ${findingsText})`;
+ }
+
+ _computeRobotCommentsPatchSetDropdownItems(
+ change: ChangeInfo,
+ commentThreads: CommentThread[]
+ ) {
+ if (!change || !commentThreads || !change.revisions) return [];
+
+ return Object.values(change.revisions)
+ .filter(patch => patch._number !== 'edit')
+ .map(patch => {
+ return {
+ text: this._computeText(patch, commentThreads),
+ value: patch._number,
+ };
+ })
+ .sort((a, b) => (b.value as number) - (a.value as number));
+ }
+
+ _handleCurrentRevisionUpdate(currentRevision: RevisionInfo) {
+ this._currentRobotCommentsPatchSet = currentRevision._number;
+ }
+
+ _handleRobotCommentPatchSetChanged(e: CustomEvent<{value: string}>) {
+ const patchSet = Number(e.detail.value) as PatchSetNum;
+ if (patchSet === this._currentRobotCommentsPatchSet) return;
+ this._currentRobotCommentsPatchSet = patchSet;
+ }
+
+ _computeShowText(showAllRobotComments: boolean) {
+ return showAllRobotComments ? 'Show Less' : 'Show more';
+ }
+
+ _toggleShowRobotComments() {
+ this._showAllRobotComments = !this._showAllRobotComments;
+ }
+
+ _computeRobotCommentThreads(
+ commentThreads: CommentThread[],
+ currentRobotCommentsPatchSet: PatchSetNum,
+ showAllRobotComments: boolean
+ ) {
+ if (!commentThreads || !currentRobotCommentsPatchSet) return [];
+ const threads = commentThreads.filter(thread => {
+ const comments = thread.comments || [];
+ return (
+ comments.length &&
+ isRobot(comments[0]) &&
+ comments[0].patch_set === currentRobotCommentsPatchSet
+ );
+ });
+ this._showRobotCommentsButton = threads.length > ROBOT_COMMENTS_LIMIT;
+ return threads.slice(
+ 0,
+ showAllRobotComments ? undefined : ROBOT_COMMENTS_LIMIT
+ );
+ }
+
+ _handleReloadCommentThreads() {
+ // Get any new drafts that have been saved in the diff view and show
+ // in the comment thread view.
+ this._reloadDrafts().then(() => {
+ this._commentThreads = this._changeComments?.getAllThreadsForChange();
+ flush();
+ });
+ }
+
+ _handleReloadDiffComments(
+ e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
+ ) {
+ // Keeps the file list counts updated.
+ this._reloadDrafts().then(() => {
+ // Get any new drafts that have been saved in the thread view and show
+ // in the diff view.
+ this.$.fileList.reloadCommentsForThreadWithRootId(
+ e.detail.rootId,
+ e.detail.path
+ );
+ flush();
+ });
+ }
+
+ _computeTotalCommentCounts(
+ unresolvedCount: number,
+ changeComments: ChangeComments
+ ) {
+ if (!changeComments) return undefined;
+ const draftCount = changeComments.computeDraftCount();
+ const unresolvedString = GrCountStringFormatter.computeString(
+ unresolvedCount,
+ 'unresolved'
+ );
+ const draftString = GrCountStringFormatter.computePluralString(
+ draftCount,
+ 'draft'
+ );
+
+ return (
+ unresolvedString +
+ // Add a comma and space if both unresolved and draft comments exist.
+ (unresolvedString && draftString ? ', ' : '') +
+ draftString
+ );
+ }
+
+ _handleCommentSave(e: CustomEvent<{comment: DraftInfo}>) {
+ const draft = e.detail.comment;
+ if (!draft.__draft || !draft.path) return;
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+
+ draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+ // The use of path-based notification helpers (set, push) can’t be used
+ // because the paths could contain dots in them. A new object must be
+ // created to satisfy Polymer’s dirty checking.
+ // https://github.com/Polymer/polymer/issues/3127
+ const diffDrafts = {...this._diffDrafts};
+ if (!diffDrafts[draft.path]) {
+ diffDrafts[draft.path] = [draft];
+ this._diffDrafts = diffDrafts;
+ return;
+ }
+ for (let i = 0; i < diffDrafts[draft.path].length; i++) {
+ if (diffDrafts[draft.path][i].id === draft.id) {
+ diffDrafts[draft.path][i] = draft;
+ this._diffDrafts = diffDrafts;
+ return;
+ }
+ }
+ diffDrafts[draft.path].push(draft);
+ diffDrafts[draft.path].sort(
+ (c1, c2) =>
+ // No line number means that it’s a file comment. Sort it above the
+ // others.
+ (c1.line || -1) - (c2.line || -1)
+ );
+ this._diffDrafts = diffDrafts;
+ }
+
+ _handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) {
+ const draft = e.detail.comment;
+ if (!draft.__draft || !draft.path) {
+ return;
+ }
+
+ if (!this._diffDrafts || !this._diffDrafts[draft.path]) {
+ return;
+ }
+ let index = -1;
+ for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
+ if (this._diffDrafts[draft.path][i].id === draft.id) {
+ index = i;
+ break;
+ }
+ }
+ if (index === -1) {
+ // It may be a draft that hasn’t been added to _diffDrafts since it was
+ // never saved.
+ return;
+ }
+
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ draft.patch_set = draft.patch_set || this._patchRange.patchNum;
+
+ // The use of path-based notification helpers (set, push) can’t be used
+ // because the paths could contain dots in them. A new object must be
+ // created to satisfy Polymer’s dirty checking.
+ // https://github.com/Polymer/polymer/issues/3127
+ const diffDrafts = {...this._diffDrafts};
+ diffDrafts[draft.path].splice(index, 1);
+ if (diffDrafts[draft.path].length === 0) {
+ delete diffDrafts[draft.path];
+ }
+ this._diffDrafts = diffDrafts;
+ }
+
+ _handleReplyTap(e: MouseEvent) {
+ e.preventDefault();
+ this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+ }
+
+ _handleOpenDiffPrefs() {
+ this.$.fileList.openDiffPrefs();
+ }
+
+ _handleOpenIncludedInDialog() {
+ this.$.includedInDialog.loadData().then(() => {
+ flush();
+ this.$.includedInOverlay.refit();
+ });
+ this.$.includedInOverlay.open();
+ }
+
+ _handleIncludedInDialogClose() {
+ this.$.includedInOverlay.close();
+ }
+
+ _handleOpenDownloadDialog() {
+ this.$.downloadOverlay.open().then(() => {
+ this.$.downloadOverlay.setFocusStops(
+ this.$.downloadDialog.getFocusStops()
+ );
+ this.$.downloadDialog.focus();
+ });
+ }
+
+ _handleDownloadDialogClose() {
+ this.$.downloadOverlay.close();
+ }
+
+ _handleOpenUploadHelpDialog() {
+ this.$.uploadHelpOverlay.open();
+ }
+
+ _handleCloseUploadHelpDialog() {
+ this.$.uploadHelpOverlay.close();
+ }
+
+ _handleMessageReply(e: CustomEvent<{message: {message: string}}>) {
+ const msg: string = e.detail.message.message;
+ const quoteStr =
+ msg
+ .split('\n')
+ .map(line => '> ' + line)
+ .join('\n') + '\n\n';
+ this.$.replyDialog.quote = quoteStr;
+ this._openReplyDialog(this.$.replyDialog.FocusTarget.BODY);
+ }
+
+ _handleHideBackgroundContent() {
+ this.$.mainContent.classList.add('overlayOpen');
+ }
+
+ _handleShowBackgroundContent() {
+ this.$.mainContent.classList.remove('overlayOpen');
+ }
+
+ _handleReplySent() {
+ this.addEventListener(
+ 'change-details-loaded',
+ () => {
+ this.reporting.timeEnd(SEND_REPLY_TIMING_LABEL);
+ },
+ {once: true}
+ );
+ this.$.replyOverlay.close();
+ this._reload();
+ }
+
+ _handleReplyCancel() {
+ this.$.replyOverlay.close();
+ }
+
+ _handleReplyAutogrow() {
+ // If the textarea resizes, we need to re-fit the overlay.
+ this.debounce(
+ 'reply-overlay-refit',
+ () => {
+ this.$.replyOverlay.refit();
+ },
+ REPLY_REFIT_DEBOUNCE_INTERVAL_MS
+ );
+ }
+
+ _handleShowReplyDialog(e: CustomEvent<{value: {ccsOnly: boolean}}>) {
+ let target = this.$.replyDialog.FocusTarget.REVIEWERS;
+ if (e.detail.value && e.detail.value.ccsOnly) {
+ target = this.$.replyDialog.FocusTarget.CCS;
+ }
+ this._openReplyDialog(target);
+ }
+
+ _handleScroll() {
+ this.debounce(
+ 'scroll',
+ () => {
+ this.viewState.scrollTop = document.body.scrollTop;
+ },
+ 150
+ );
+ }
+
+ _setShownFiles(e: CustomEvent<{length: number}>) {
+ this._shownFileCount = e.detail.length;
+ }
+
+ _expandAllDiffs(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ this.$.fileList.expandAllDiffs();
+ }
+
+ _collapseAllDiffs() {
+ this.$.fileList.collapseAllDiffs();
+ }
+
+ _paramsChanged(value: AppElementChangeViewParams) {
+ if (value.view !== GerritView.CHANGE) {
+ this._initialLoadComplete = false;
+ return;
+ }
+
+ if (value.changeNum && value.project) {
+ this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+ }
+
+ const patchChanged =
+ this._patchRange &&
+ value.patchNum !== undefined &&
+ value.basePatchNum !== undefined &&
+ (this._patchRange.patchNum !== value.patchNum ||
+ this._patchRange.basePatchNum !== value.basePatchNum);
+ const changeChanged = this._changeNum !== value.changeNum;
+
+ const patchRange: ChangeViewPatchRange = {
+ patchNum: value.patchNum,
+ basePatchNum: value.basePatchNum || ParentPatchSetNum,
+ };
+
+ this.$.fileList.collapseAllDiffs();
+ this._patchRange = patchRange;
+
+ // If the change has already been loaded and the parameter change is only
+ // in the patch range, then don't do a full reload.
+ if (!changeChanged && patchChanged) {
+ if (!patchRange.patchNum) {
+ patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
+ }
+ this._reloadPatchNumDependentResources().then(() => {
+ this._sendShowChangeEvent();
+ });
+ return;
+ }
+
+ this._initialLoadComplete = false;
+ this._changeNum = value.changeNum;
+ this.$.relatedChanges.clear();
+
+ this._reload(true).then(() => {
+ this._performPostLoadTasks();
+ });
+
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._initActiveTabs(value);
+ });
+ }
+
+ _initActiveTabs(params?: AppElementChangeViewParams) {
+ let primaryTab = PrimaryTab.FILES;
+ if (params && params.queryMap && params.queryMap.has('tab')) {
+ primaryTab = params.queryMap.get('tab') as PrimaryTab;
+ }
+ this._setActivePrimaryTab(
+ new CustomEvent('initActiveTab', {
+ detail: {
+ tab: primaryTab,
+ },
+ })
+ );
+ this._setActiveSecondaryTab(
+ new CustomEvent('initActiveTab', {
+ detail: {
+ tab: SecondaryTab.CHANGE_LOG,
+ },
+ })
+ );
+ }
+
+ _sendShowChangeEvent() {
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ this.$.jsAPI.handleEvent(EventType.SHOW_CHANGE, {
+ change: this._change,
+ patchNum: this._patchRange.patchNum,
+ info: {mergeable: this._mergeable},
+ });
+ }
+
+ _performPostLoadTasks() {
+ this._maybeShowReplyDialog();
+ this._maybeShowRevertDialog();
+ this._maybeShowDownloadDialog();
+
+ this._sendShowChangeEvent();
+
+ this.async(() => {
+ if (this.viewState.scrollTop) {
+ document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
+ } else {
+ this._maybeScrollToMessage(window.location.hash);
+ }
+ this._initialLoadComplete = true;
+ });
+ }
+
+ @observe('params', '_change')
+ _paramsAndChangeChanged(
+ value?: AppElementChangeViewParams,
+ change?: ChangeInfo
+ ) {
+ // Polymer 2: check for undefined
+ if (!value || !change) {
+ return;
+ }
+
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ // If the change number or patch range is different, then reset the
+ // selected file index.
+ const patchRangeState = this.viewState.patchRange;
+ if (
+ this.viewState.changeNum !== this._changeNum ||
+ !patchRangeState ||
+ patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
+ patchRangeState.patchNum !== this._patchRange.patchNum
+ ) {
+ this._resetFileListViewState();
+ }
+ }
+
+ _viewStateChanged(viewState: ChangeViewState) {
+ this._numFilesShown = viewState.numFilesShown
+ ? viewState.numFilesShown
+ : DEFAULT_NUM_FILES_SHOWN;
+ }
+
+ _numFilesShownChanged(numFilesShown: number) {
+ this.viewState.numFilesShown = numFilesShown;
+ }
+
+ _handleMessageAnchorTap(e: CustomEvent<{id: string}>) {
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const hash = MSG_PREFIX + e.detail.id;
+ const url = GerritNav.getUrlForChange(
+ this._change,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum,
+ this._editMode,
+ hash
+ );
+ history.replaceState(null, '', url);
+ }
+
+ _maybeScrollToMessage(hash: string) {
+ if (hash.startsWith(MSG_PREFIX) && this.messagesList) {
+ this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
+ }
+ }
+
+ _getLocationSearch() {
+ // Not inlining to make it easier to test.
+ return window.location.search;
+ }
+
+ _getUrlParameter(param: string) {
+ const pageURL = this._getLocationSearch().substring(1);
+ const vars = pageURL.split('&');
+ for (let i = 0; i < vars.length; i++) {
+ const name = vars[i].split('=');
+ if (name[0] === param) {
+ return name[0];
+ }
+ }
+ return null;
+ }
+
+ _maybeShowRevertDialog() {
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => this._getLoggedIn())
+ .then(loggedIn => {
+ if (
+ !loggedIn ||
+ !this._change ||
+ this._change.status !== ChangeStatus.MERGED
+ ) {
+ // Do not display dialog if not logged-in or the change is not
+ // merged.
+ return;
+ }
+ if (this._getUrlParameter('revert')) {
+ this.$.actions.showRevertDialog();
+ }
+ });
+ }
+
+ _maybeShowReplyDialog() {
+ this._getLoggedIn().then(loggedIn => {
+ if (!loggedIn) {
+ return;
+ }
+
+ if (this.viewState.showReplyDialog) {
+ this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+ // TODO(kaspern@): Find a better signal for when to call center.
+ this.async(() => {
+ this.$.replyOverlay.center();
+ }, 100);
+ this.async(() => {
+ this.$.replyOverlay.center();
+ }, 1000);
+ this.set('viewState.showReplyDialog', false);
+ }
+ });
+ }
+
+ _maybeShowDownloadDialog() {
+ if (this.viewState.showDownloadDialog) {
+ this._handleOpenDownloadDialog();
+ this.set('viewState.showDownloadDialog', false);
+ }
+ }
+
+ _resetFileListViewState() {
+ this.set('viewState.selectedFileIndex', 0);
+ this.set('viewState.scrollTop', 0);
+ if (
+ !!this.viewState.changeNum &&
+ this.viewState.changeNum !== this._changeNum
+ ) {
+ // Reset the diff mode to null when navigating from one change to
+ // another, so that the user's preference is restored.
+ this._setDiffViewMode(true);
+ this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
+ }
+ this.set('viewState.changeNum', this._changeNum);
+ this.set('viewState.patchRange', this._patchRange);
+ }
+
+ _changeChanged(change?: ChangeInfo | ParsedChangeInfo) {
+ if (!change || !this._patchRange || !this._allPatchSets) {
+ return;
+ }
+
+ // We get the parent first so we keep the original value for basePatchNum
+ // and not the updated value.
+ const parent = this._getBasePatchNum(change, this._patchRange);
+
+ this.set(
+ '_patchRange.patchNum',
+ this._patchRange.patchNum || computeLatestPatchNum(this._allPatchSets)
+ );
+
+ this.set('_patchRange.basePatchNum', parent);
+
+ const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ /**
+ * Gets base patch number, if it is a parent try and decide from
+ * preference whether to default to `auto merge`, `Parent 1` or `PARENT`.
+ */
+ _getBasePatchNum(
+ change: ChangeInfo | ParsedChangeInfo,
+ patchRange: ChangeViewPatchRange
+ ) {
+ if (patchRange.basePatchNum && patchRange.basePatchNum !== 'PARENT') {
+ return patchRange.basePatchNum;
+ }
+
+ const revisionInfo = this._getRevisionInfo(change);
+ if (!revisionInfo) return 'PARENT';
+
+ const parentCounts = revisionInfo.getParentCountMap();
+ // check that there is at least 2 parents otherwise fall back to 1,
+ // which means there is only one parent.
+ const parentCount = hasOwnProperty(parentCounts, 1) ? parentCounts[1] : 1;
+
+ const preferFirst =
+ this._prefs && this._prefs.default_base_for_merges === 'FIRST_PARENT';
+
+ if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
+ return -1;
+ }
+
+ return 'PARENT';
+ }
+
+ _computeChangeUrl(change: ChangeInfo) {
+ return GerritNav.getUrlForChange(change);
+ }
+
+ _computeShowCommitInfo(changeStatus: string, current_revision: RevisionInfo) {
+ return changeStatus === 'Merged' && current_revision;
+ }
+
+ _computeMergedCommitInfo(
+ current_revision: CommitId,
+ revisions: {[revisionId: string]: RevisionInfo}
+ ) {
+ const rev = revisions[current_revision];
+ if (!rev || !rev.commit) {
+ return {};
+ }
+ // CommitInfo.commit is optional. Set commit in all cases to avoid error
+ // in <gr-commit-info>. @see Issue 5337
+ if (!rev.commit.commit) {
+ rev.commit.commit = current_revision;
+ }
+ return rev.commit;
+ }
+
+ _computeChangeIdClass(displayChangeId: string) {
+ return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
+ }
+
+ _computeTitleAttributeWarning(displayChangeId: string) {
+ if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
+ return 'Change-Id mismatch';
+ } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
+ return 'No Change-Id in commit message';
+ }
+ return undefined;
+ }
+
+ _computeChangeIdCommitMessageError(
+ commitMessage?: string,
+ change?: ChangeInfo
+ ) {
+ if (change === undefined) {
+ return undefined;
+ }
+
+ if (!commitMessage) {
+ return CHANGE_ID_ERROR.MISSING;
+ }
+
+ // Find the last match in the commit message:
+ let changeId;
+ let changeIdArr;
+
+ while ((changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage))) {
+ changeId = changeIdArr[2];
+ }
+
+ if (changeId) {
+ // A change-id is detected in the commit message.
+
+ if (changeId === change.change_id) {
+ // The change-id found matches the real change-id.
+ return null;
+ }
+ // The change-id found does not match the change-id.
+ return CHANGE_ID_ERROR.MISMATCH;
+ }
+ // There is no change-id in the commit message.
+ return CHANGE_ID_ERROR.MISSING;
+ }
+
+ _computeReplyButtonLabel(
+ changeRecord?: ElementPropertyDeepChange<
+ GrChangeView,
+ '_diffDrafts'
+ > | null,
+ canStartReview?: boolean
+ ) {
+ if (changeRecord === undefined || canStartReview === undefined) {
+ return 'Reply';
+ }
+
+ const drafts = (changeRecord && changeRecord.base) || {};
+ const draftCount = Object.keys(drafts).reduce(
+ (count, file) => count + drafts[file].length,
+ 0
+ );
+
+ let label = canStartReview ? 'Start Review' : 'Reply';
+ if (draftCount > 0) {
+ label += ` (${draftCount})`;
+ }
+ return label;
+ }
+
+ _handleOpenReplyDialog(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ this._getLoggedIn().then(isLoggedIn => {
+ if (!isLoggedIn) {
+ this.dispatchEvent(
+ new CustomEvent('show-auth-required', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+
+ e.preventDefault();
+ this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
+ });
+ }
+
+ _handleOpenDownloadDialogShortcut(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._handleOpenDownloadDialog();
+ }
+
+ _handleEditTopic(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.metadata.editTopic();
+ }
+
+ _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Base is already selected.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+ }
+
+ _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Left is already base.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
+ }
+
+ _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ const detail: ShowAlertEventDetail = {
+ message: 'Latest is already selected.',
+ };
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail,
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(
+ this._change,
+ latestPatchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Right is already latest.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(
+ this._change,
+ latestPatchNum,
+ this._patchRange.patchNum
+ );
+ }
+
+ _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (
+ patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+ patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+ ) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Already diffing base against latest.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToChange(this._change, latestPatchNum);
+ }
+
+ _handleRefreshChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ e.preventDefault();
+ this._reload(/* isLocationChange= */ false, /* clearPatchset= */ true);
+ }
+
+ _handleToggleChangeStar(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ e.preventDefault();
+ this.$.changeStar.toggleStar();
+ }
+
+ _handleUpToDashboard(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._determinePageBack();
+ }
+
+ _handleExpandAllMessages(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.messagesList) {
+ this.messagesList.handleExpandCollapse(true);
+ }
+ }
+
+ _handleCollapseAllMessages(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.messagesList) {
+ this.messagesList.handleExpandCollapse(false);
+ }
+ }
+
+ _handleOpenDiffPrefsShortcut(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ if (this._diffPrefsDisabled) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.fileList.openDiffPrefs();
+ }
+
+ _determinePageBack() {
+ // Default backPage to root if user came to change view page
+ // via an email link, etc.
+ GerritNav.navigateToRelativeUrl(this.backPage || GerritNav.getUrlForRoot());
+ }
+
+ _handleLabelRemoved(
+ splices: Array<PolymerSplice<ApprovalInfo[]>>,
+ path: string
+ ) {
+ for (const splice of splices) {
+ for (const removed of splice.removed) {
+ const changePath = path.split('.');
+ const labelPath = changePath.splice(0, changePath.length - 2);
+ const labelDict = this.get(labelPath) as QuickLabelInfo;
+ if (
+ labelDict.approved &&
+ labelDict.approved._account_id === removed._account_id
+ ) {
+ this._reload();
+ return;
+ }
+ }
+ }
+ }
+
+ @observe('_change.labels.*')
+ _labelsChanged(
+ changeRecord: PolymerDeepPropertyChange<
+ LabelNameToInfoMap,
+ PolymerSpliceChange<ApprovalInfo[]>
+ >
+ ) {
+ if (!changeRecord) {
+ return;
+ }
+ if (changeRecord.value && isPolymerSpliceChange(changeRecord.value)) {
+ this._handleLabelRemoved(
+ changeRecord.value.indexSplices,
+ changeRecord.path
+ );
+ }
+ this.$.jsAPI.handleEvent(EventType.LABEL_CHANGE, {
+ change: this._change,
+ });
+ }
+
+ _openReplyDialog(section?: FocusTarget) {
+ this.$.replyOverlay.open().finally(() => {
+ // the following code should be executed no matter open succeed or not
+ this._resetReplyOverlayFocusStops();
+ this.$.replyDialog.open(section);
+ flush();
+ this.$.replyOverlay.center();
+ });
+ }
+
+ _handleGetChangeDetailError(response?: Response | null) {
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _getServerConfig() {
+ return this.$.restAPI.getConfig();
+ }
+
+ _getProjectConfig() {
+ if (!this._change) throw new Error('missing required change property');
+ return this.$.restAPI
+ .getProjectConfig(this._change.project)
+ .then(config => {
+ this._projectConfig = config;
+ });
+ }
+
+ _getPreferences() {
+ return this.$.restAPI.getPreferences();
+ }
+
+ _prepareCommitMsgForLinkify(msg: string) {
+ // TODO(wyatta) switch linkify sequence, see issue 5526.
+ // This is a zero-with space. It is added to prevent the linkify library
+ // from including R= or CC= as part of the email address.
+ return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
+ }
+
+ /**
+ * Utility function to make the necessary modifications to a change in the
+ * case an edit exists.
+ */
+ _processEdit(change: ParsedChangeInfo, edit?: EditInfo | false) {
+ if (!edit) return;
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (!edit.commit.commit) throw new Error('undefined edit.commit.commit');
+ const changeWithEdit = change;
+ if (changeWithEdit.revisions)
+ changeWithEdit.revisions[edit.commit.commit] = {
+ _number: EditPatchSetNum,
+ basePatchNum: edit.base_patch_set_number,
+ commit: edit.commit,
+ fetch: edit.fetch,
+ };
+
+ // If the edit is based on the most recent patchset, load it by
+ // default, unless another patch set to load was specified in the URL.
+ if (
+ !this._patchRange.patchNum &&
+ changeWithEdit.current_revision === edit.base_revision
+ ) {
+ changeWithEdit.current_revision = edit.commit.commit;
+ this.set('_patchRange.patchNum', EditPatchSetNum);
+ // Because edits are fibbed as revisions and added to the revisions
+ // array, and revision actions are always derived from the 'latest'
+ // patch set, we must copy over actions from the patch set base.
+ // Context: Issue 7243
+ if (changeWithEdit.revisions) {
+ changeWithEdit.revisions[edit.commit.commit].actions =
+ changeWithEdit.revisions[edit.base_revision].actions;
+ }
+ }
+ }
+
+ _getChangeDetail() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ const detailCompletes = this.$.restAPI.getChangeDetail(this._changeNum, r =>
+ this._handleGetChangeDetailError(r)
+ );
+ const editCompletes = this._getEdit();
+ const prefCompletes = this._getPreferences();
+
+ return Promise.all([detailCompletes, editCompletes, prefCompletes]).then(
+ ([change, edit, prefs]) => {
+ this._prefs = prefs;
+
+ if (!change) {
+ return false;
+ }
+ this._processEdit(change, edit);
+ // Issue 4190: Coalesce missing topics to null.
+ // TODO(TS): code needs second thought,
+ // it might be that nulls were assigned to trigger some bindings
+ if (!change.topic) {
+ change.topic = (null as unknown) as undefined;
+ }
+ if (!change.reviewer_updates) {
+ change.reviewer_updates = (null as unknown) as undefined;
+ }
+ const latestRevisionSha = this._getLatestRevisionSHA(change);
+ if (!latestRevisionSha)
+ throw new Error('Could not find latest Revision Sha');
+ const currentRevision = change.revisions[latestRevisionSha];
+ if (currentRevision.commit && currentRevision.commit.message) {
+ this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+ currentRevision.commit.message
+ );
+ } else {
+ this._latestCommitMessage = null;
+ }
+
+ const lineHeight = getComputedStyle(this).lineHeight;
+
+ // Slice returns a number as a string, convert to an int.
+ this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
+
+ this._change = change;
+ if (
+ !this._patchRange ||
+ !this._patchRange.patchNum ||
+ patchNumEquals(this._patchRange.patchNum, currentRevision._number)
+ ) {
+ // CommitInfo.commit is optional, and may need patching.
+ if (currentRevision.commit && !currentRevision.commit.commit) {
+ currentRevision.commit.commit = latestRevisionSha as CommitId;
+ }
+ this._commitInfo = currentRevision.commit;
+ this._selectedRevision = currentRevision;
+ // TODO: Fetch and process files.
+ } else {
+ if (!this._change?.revisions || !this._patchRange) return false;
+ this._selectedRevision = Object.values(this._change.revisions).find(
+ revision => {
+ // edit patchset is a special one
+ const thePatchNum = this._patchRange!.patchNum;
+ if (thePatchNum === 'edit') {
+ return revision._number === thePatchNum;
+ }
+ return revision._number === Number(`${thePatchNum}`);
+ }
+ );
+ }
+ return false;
+ }
+ );
+ }
+
+ _isSubmitEnabled(revisionActions: ActionNameToActionInfoMap) {
+ return !!(
+ revisionActions &&
+ revisionActions.submit &&
+ revisionActions.submit.enabled
+ );
+ }
+
+ _isParentCurrent(revisionActions: ActionNameToActionInfoMap) {
+ if (revisionActions && revisionActions.rebase) {
+ return !revisionActions.rebase.enabled;
+ } else {
+ return true;
+ }
+ }
+
+ _getEdit() {
+ if (!this._changeNum)
+ return Promise.reject(new Error('missing required changeNum property'));
+ return this.$.restAPI.getChangeEdit(this._changeNum, true);
+ }
+
+ _getLatestCommitMessage() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ const lastpatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (lastpatchNum === undefined)
+ throw new Error('missing lastPatchNum property');
+ return this.$.restAPI
+ .getChangeCommitInfo(this._changeNum, lastpatchNum)
+ .then(commitInfo => {
+ if (!commitInfo) return;
+ this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+ commitInfo.message
+ );
+ });
+ }
+
+ _getLatestRevisionSHA(change: ChangeInfo | ParsedChangeInfo) {
+ if (change.current_revision) {
+ return change.current_revision;
+ }
+ // current_revision may not be present in the case where the latest rev is
+ // a draft and the user doesn’t have permission to view that rev.
+ let latestRev = null;
+ let latestPatchNum = -1 as PatchSetNum;
+ for (const rev in change.revisions) {
+ if (!hasOwnProperty(change.revisions, rev)) {
+ continue;
+ }
+
+ if (change.revisions[rev]._number > latestPatchNum) {
+ latestRev = rev;
+ latestPatchNum = change.revisions[rev]._number;
+ }
+ }
+ return latestRev;
+ }
+
+ _getCommitInfo() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ if (this._patchRange.patchNum === undefined)
+ throw new Error('missing required patchNum property');
+ return this.$.restAPI
+ .getChangeCommitInfo(this._changeNum, this._patchRange.patchNum)
+ .then(commitInfo => {
+ this._commitInfo = commitInfo;
+ });
+ }
+
+ _reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
+ return this._reloadDrafts().then(() => e.detail.resolve());
+ }
+
+ /**
+ * Fetches a new changeComment object, and data for all types of comments
+ * (comments, robot comments, draft comments) is requested.
+ */
+ _reloadComments() {
+ // We are resetting all comment related properties, because we want to avoid
+ // a new change being loaded and then paired with outdated comments.
+ this._changeComments = undefined;
+ this._commentThreads = undefined;
+ this._diffDrafts = undefined;
+ this._draftCommentThreads = undefined;
+ this._robotCommentThreads = undefined;
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+
+ const portedCommentsPromise = this.$.commentAPI.getPortedComments(
+ this._changeNum
+ );
+ const commentsPromise = this.$.commentAPI
+ .loadAll(this._changeNum)
+ .then(comments => {
+ this.reporting.time(PORTING_COMMENTS_CHANGE_LATENCY_LABEL);
+ this._recomputeComments(comments);
+ });
+ Promise.all([portedCommentsPromise, commentsPromise]).then(() => {
+ this.reporting.timeEnd(PORTING_COMMENTS_CHANGE_LATENCY_LABEL);
+ });
+ return commentsPromise;
+ }
+
+ /**
+ * Fetches a new changeComment object, but only updated data for drafts is
+ * requested.
+ *
+ * TODO(taoalpha): clean up this and _reloadComments, as single comment
+ * can be a thread so it does not make sense to only update drafts
+ * without updating threads
+ */
+ _reloadDrafts() {
+ if (!this._changeNum)
+ throw new Error('missing required changeNum property');
+ return this.$.commentAPI
+ .reloadDrafts(this._changeNum)
+ .then(comments => this._recomputeComments(comments));
+ }
+
+ _recomputeComments(comments: ChangeComments) {
+ this._changeComments = comments;
+ this._diffDrafts = {...this._changeComments.drafts};
+ this._commentThreads = this._changeComments.getAllThreadsForChange();
+ this._draftCommentThreads = this._commentThreads
+ .filter(isDraftThread)
+ .map(thread => {
+ const copiedThread = {...thread};
+ // Make a hardcopy of all comments and collapse all but last one
+ const commentsInThread = (copiedThread.comments = thread.comments.map(
+ comment => {
+ return {...comment, collapsed: true as boolean};
+ }
+ ));
+ commentsInThread[commentsInThread.length - 1].collapsed = false;
+ return copiedThread;
+ });
+ }
+
+ /**
+ * Reload the change.
+ *
+ * @param isLocationChange Reloads the related changes
+ * when true and ends reporting events that started on location change.
+ * @param clearPatchset Reloads the related changes
+ * ignoring any patchset choice made.
+ * @return A promise that resolves when the core data has loaded.
+ * Some non-core data loading may still be in-flight when the core data
+ * promise resolves.
+ */
+ _reload(isLocationChange?: boolean, clearPatchset?: boolean) {
+ if (clearPatchset && this._change) {
+ GerritNav.navigateToChange(this._change);
+ return Promise.resolve([]);
+ }
+ this._loading = true;
+ this._relatedChangesCollapsed = true;
+ this.reporting.time(CHANGE_RELOAD_TIMING_LABEL);
+ this.reporting.time(CHANGE_DATA_TIMING_LABEL);
+
+ // Array to house all promises related to data requests.
+ const allDataPromises: Promise<unknown>[] = [];
+
+ // Resolves when the change detail and the edit patch set (if available)
+ // are loaded.
+ const detailCompletes = this._getChangeDetail();
+ allDataPromises.push(detailCompletes);
+
+ // Resolves when the loading flag is set to false, meaning that some
+ // change content may start appearing.
+ const loadingFlagSet = detailCompletes
+ .then(() => {
+ this._loading = false;
+ this.dispatchEvent(
+ new CustomEvent('change-details-loaded', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ })
+ .then(() => {
+ this.reporting.timeEnd(CHANGE_RELOAD_TIMING_LABEL);
+ if (isLocationChange) {
+ this.reporting.changeDisplayed();
+ }
+ });
+
+ // Resolves when the project config has loaded.
+ const projectConfigLoaded = detailCompletes.then(() =>
+ this._getProjectConfig()
+ );
+ allDataPromises.push(projectConfigLoaded);
+
+ // Resolves when change comments have loaded (comments, drafts and robot
+ // comments).
+ const commentsLoaded = this._reloadComments();
+ allDataPromises.push(commentsLoaded);
+
+ let coreDataPromise;
+
+ // If the patch number is specified
+ if (this._patchRange && this._patchRange.patchNum) {
+ // Because a specific patchset is specified, reload the resources that
+ // are keyed by patch number or patch range.
+ const patchResourcesLoaded = this._reloadPatchNumDependentResources();
+ allDataPromises.push(patchResourcesLoaded);
+
+ // Promise resolves when the change detail and patch dependent resources
+ // have loaded.
+ const detailAndPatchResourcesLoaded = Promise.all([
+ patchResourcesLoaded,
+ loadingFlagSet,
+ ]);
+
+ // Promise resolves when mergeability information has loaded.
+ const mergeabilityLoaded = detailAndPatchResourcesLoaded.then(() =>
+ this._getMergeability()
+ );
+ allDataPromises.push(mergeabilityLoaded);
+
+ // Promise resovles when the change actions have loaded.
+ const actionsLoaded = detailAndPatchResourcesLoaded.then(() =>
+ this.$.actions.reload()
+ );
+ allDataPromises.push(actionsLoaded);
+
+ // The core data is loaded when both mergeability and actions are known.
+ coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
+ } else {
+ // Resolves when the file list has loaded.
+ const fileListReload = loadingFlagSet.then(() =>
+ this.$.fileList.reload()
+ );
+ allDataPromises.push(fileListReload);
+
+ const latestCommitMessageLoaded = loadingFlagSet.then(() => {
+ // If the latest commit message is known, there is nothing to do.
+ if (this._latestCommitMessage) {
+ return Promise.resolve();
+ }
+ return this._getLatestCommitMessage();
+ });
+ allDataPromises.push(latestCommitMessageLoaded);
+
+ // Promise resolves when mergeability information has loaded.
+ const mergeabilityLoaded = loadingFlagSet.then(() =>
+ this._getMergeability()
+ );
+ allDataPromises.push(mergeabilityLoaded);
+
+ // Core data is loaded when mergeability has been loaded.
+ coreDataPromise = Promise.all([mergeabilityLoaded]);
+ }
+
+ if (isLocationChange) {
+ this._editingCommitMessage = false;
+ const relatedChangesLoaded = coreDataPromise.then(() =>
+ this.$.relatedChanges.reload()
+ );
+ allDataPromises.push(relatedChangesLoaded);
+ }
+
+ Promise.all(allDataPromises).then(() => {
+ this.reporting.timeEnd(CHANGE_DATA_TIMING_LABEL);
+ if (isLocationChange) {
+ this.reporting.changeFullyLoaded();
+ }
+ });
+
+ return coreDataPromise;
+ }
+
+ /**
+ * Kicks off requests for resources that rely on the patch range
+ * (`this._patchRange`) being defined.
+ */
+ _reloadPatchNumDependentResources() {
+ return Promise.all([this._getCommitInfo(), this.$.fileList.reload()]);
+ }
+
+ _getMergeability() {
+ if (!this._change) {
+ this._mergeable = null;
+ return Promise.resolve();
+ }
+ // If the change is closed, it is not mergeable. Note: already merged
+ // changes are obviously not mergeable, but the mergeability API will not
+ // answer for abandoned changes.
+ if (
+ this._change.status === ChangeStatus.MERGED ||
+ this._change.status === ChangeStatus.ABANDONED
+ ) {
+ this._mergeable = false;
+ return Promise.resolve();
+ }
+
+ if (!this._changeNum) {
+ return Promise.reject(new Error('missing required changeNum property'));
+ }
+
+ this._mergeable = null;
+ return this.$.restAPI.getMergeable(this._changeNum).then(mergableInfo => {
+ if (mergableInfo) {
+ this._mergeable = mergableInfo.mergeable;
+ }
+ });
+ }
+
+ _computeCanStartReview(change: ChangeInfo) {
+ return !!(
+ change.actions &&
+ change.actions.ready &&
+ change.actions.ready.enabled
+ );
+ }
+
+ _computeReplyDisabled() {
+ return false;
+ }
+
+ _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
+ return `Change ${changeNum}`;
+ }
+
+ _computeCommitMessageCollapsed(collapsed?: boolean, collapsible?: boolean) {
+ return collapsible && collapsed;
+ }
+
+ _computeRelatedChangesClass(collapsed: boolean) {
+ return collapsed ? 'collapsed' : '';
+ }
+
+ _computeCollapseText(collapsed: boolean) {
+ // Symbols are up and down triangles.
+ return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
+ }
+
+ /**
+ * Returns the text to be copied when
+ * click the copy icon next to change subject
+ */
+ _computeCopyTextForTitle(change: ChangeInfo) {
+ return (
+ `${change._number}: ${change.subject} | ` +
+ `${location.protocol}//${location.host}` +
+ `${this._computeChangeUrl(change)}`
+ );
+ }
+
+ _toggleCommitCollapsed() {
+ this._commitCollapsed = !this._commitCollapsed;
+ if (this._commitCollapsed) {
+ window.scrollTo(0, 0);
+ }
+ }
+
+ _toggleRelatedChangesCollapsed() {
+ this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
+ if (this._relatedChangesCollapsed) {
+ window.scrollTo(0, 0);
+ }
+ }
+
+ _computeCommitCollapsible(commitMessage?: string) {
+ if (!commitMessage) {
+ return false;
+ }
+ return commitMessage.split('\n').length >= MIN_LINES_FOR_COMMIT_COLLAPSE;
+ }
+
+ _getOffsetHeight(element: HTMLElement) {
+ return element.offsetHeight;
+ }
+
+ _getScrollHeight(element: HTMLElement) {
+ return element.scrollHeight;
+ }
+
+ /**
+ * Get the line height of an element to the nearest integer.
+ */
+ _getLineHeight(element: Element) {
+ const lineHeightStr = getComputedStyle(element).lineHeight;
+ return Math.round(Number(lineHeightStr.slice(0, lineHeightStr.length - 2)));
+ }
+
+ /**
+ * New max height for the related changes section, shorter than the existing
+ * change info height.
+ */
+ _updateRelatedChangeMaxHeight() {
+ // Takes into account approximate height for the expand button and
+ // bottom margin.
+ const EXTRA_HEIGHT = 30;
+ let newHeight;
+
+ if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`).matches) {
+ // In a small (mobile) view, give the relation chain some space.
+ newHeight = SMALL_RELATED_HEIGHT;
+ } else if (
+ window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`).matches
+ ) {
+ // Since related changes are below the commit message, but still next to
+ // metadata, the height should be the height of the metadata minus the
+ // height of the commit message to reduce jank. However, if that doesn't
+ // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
+ // Note: extraHeight is to take into account margin/padding.
+ const medRelatedHeight = Math.max(
+ this._getOffsetHeight(this.$.mainChangeInfo) -
+ this._getOffsetHeight(this.$.commitMessage) -
+ 2 * EXTRA_HEIGHT,
+ MINIMUM_RELATED_MAX_HEIGHT
+ );
+ newHeight = medRelatedHeight;
+ } else {
+ if (this._commitCollapsible) {
+ // Make sure the content is lined up if both areas have buttons. If
+ // the commit message is not collapsed, instead use the change info
+ // height.
+ newHeight = this._getOffsetHeight(this.$.commitMessage);
+ } else {
+ newHeight =
+ this._getOffsetHeight(this.$.commitAndRelated) - EXTRA_HEIGHT;
+ }
+ }
+ const stylesToUpdate: {[key: string]: string} = {};
+
+ // Get the line height of related changes, and convert it to the nearest
+ // integer.
+ const lineHeight = this._getLineHeight(this.$.relatedChanges);
+
+ // Figure out a new height that is divisible by the rounded line height.
+ const remainder = newHeight % lineHeight;
+ newHeight = newHeight - remainder;
+
+ stylesToUpdate['--relation-chain-max-height'] = `${newHeight}px`;
+
+ // Update the max-height of the relation chain to this new height.
+ if (this._commitCollapsible) {
+ stylesToUpdate['--related-change-btn-top-padding'] = `${remainder}px`;
+ }
+
+ this.updateStyles(stylesToUpdate);
+ }
+
+ _computeShowRelatedToggle() {
+ // Make sure the max height has been applied, since there is now content
+ // to populate.
+ if (!getComputedStyleValue('--relation-chain-max-height', this)) {
+ this._updateRelatedChangeMaxHeight();
+ }
+ // Prevents showMore from showing when click on related change, since the
+ // line height would be positive, but related changes height is 0.
+ if (!this._getScrollHeight(this.$.relatedChanges)) {
+ return (this._showRelatedToggle = false);
+ }
+
+ if (
+ this._getScrollHeight(this.$.relatedChanges) >
+ this._getOffsetHeight(this.$.relatedChanges) +
+ this._getLineHeight(this.$.relatedChanges)
+ ) {
+ return (this._showRelatedToggle = true);
+ }
+ return (this._showRelatedToggle = false);
+ }
+
+ _updateToggleContainerClass(showRelatedToggle: boolean) {
+ if (showRelatedToggle) {
+ this.$.relatedChangesToggle.classList.add('showToggle');
+ } else {
+ this.$.relatedChangesToggle.classList.remove('showToggle');
+ }
+ }
+
+ _startUpdateCheckTimer() {
+ if (
+ !this._serverConfig ||
+ !this._serverConfig.change ||
+ this._serverConfig.change.update_delay === undefined ||
+ this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS
+ ) {
+ return;
+ }
+
+ this._updateCheckTimerHandle = this.async(() => {
+ if (!this._change) throw new Error('missing required change property');
+ const change = this._change;
+ fetchChangeUpdates(change, this.$.restAPI).then(result => {
+ let toastMessage = null;
+ if (!result.isLatest) {
+ toastMessage = ReloadToastMessage.NEWER_REVISION;
+ } else if (result.newStatus === ChangeStatus.MERGED) {
+ toastMessage = ReloadToastMessage.MERGED;
+ } else if (result.newStatus === ChangeStatus.ABANDONED) {
+ toastMessage = ReloadToastMessage.ABANDONED;
+ } else if (result.newStatus === ChangeStatus.NEW) {
+ toastMessage = ReloadToastMessage.RESTORED;
+ } else if (result.newMessages) {
+ toastMessage = ReloadToastMessage.NEW_MESSAGE;
+ }
+
+ // We have to make sure that the update is still relevant for the user.
+ // Since starting to fetch the change update the user may have sent a
+ // reply, or the change might have been reloaded, or it could be in the
+ // process of being reloaded.
+ const changeWasReloaded = change !== this._change;
+ if (!toastMessage || this._loading || changeWasReloaded) {
+ this._startUpdateCheckTimer();
+ return;
+ }
+
+ this._cancelUpdateCheckTimer();
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: toastMessage,
+ // Persist this alert.
+ dismissOnNavigation: true,
+ action: 'Reload',
+ callback: () => {
+ this._reload(
+ /* isLocationChange= */ false,
+ /* clearPatchset= */ true
+ );
+ },
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }, this._serverConfig.change.update_delay * 1000);
+ }
+
+ _cancelUpdateCheckTimer() {
+ if (this._updateCheckTimerHandle) {
+ this.cancelAsync(this._updateCheckTimerHandle);
+ }
+ this._updateCheckTimerHandle = null;
+ }
+
+ _handleVisibilityChange() {
+ if (document.hidden && this._updateCheckTimerHandle) {
+ this._cancelUpdateCheckTimer();
+ } else if (!this._updateCheckTimerHandle) {
+ this._startUpdateCheckTimer();
+ }
+ }
+
+ _handleTopicChanged() {
+ this.$.relatedChanges.reload();
+ }
+
+ _computeHeaderClass(editMode?: boolean) {
+ const classes = ['header'];
+ if (editMode) {
+ classes.push('editMode');
+ }
+ return classes.join(' ');
+ }
+
+ _computeEditMode(
+ patchRangeRecord: PolymerDeepPropertyChange<
+ ChangeViewPatchRange,
+ ChangeViewPatchRange
+ >,
+ paramsRecord: PolymerDeepPropertyChange<
+ AppElementChangeViewParams,
+ AppElementChangeViewParams
+ >
+ ) {
+ if (!patchRangeRecord || !paramsRecord) {
+ return undefined;
+ }
+
+ if (paramsRecord.base && paramsRecord.base.edit) {
+ return true;
+ }
+
+ const patchRange = patchRangeRecord.base || {};
+ return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+ }
+
+ _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
+ e.preventDefault();
+ const controls = this.$.fileListHeader.shadowRoot!.querySelector(
+ '#editControls'
+ ) as GrEditControls | null;
+ if (!controls) throw new Error('Missing edit controls');
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ const path = e.detail.path;
+ switch (e.detail.action) {
+ case GrEditConstants.Actions.DELETE.id:
+ controls.openDeleteDialog(path);
+ break;
+ case GrEditConstants.Actions.OPEN.id:
+ GerritNav.navigateToRelativeUrl(
+ GerritNav.getEditUrlForDiff(
+ this._change,
+ path,
+ this._patchRange.patchNum
+ )
+ );
+ break;
+ case GrEditConstants.Actions.RENAME.id:
+ controls.openRenameDialog(path);
+ break;
+ case GrEditConstants.Actions.RESTORE.id:
+ controls.openRestoreDialog(path);
+ break;
+ }
+ }
+
+ _computeCommitMessageKey(number: NumericChangeId, revision: CommitId) {
+ return `c${number}_rev${revision}`;
+ }
+
+ @observe('_patchRange.patchNum')
+ _patchNumChanged(patchNumStr: PatchSetNum) {
+ if (!this._selectedRevision) {
+ return;
+ }
+ if (!this._change) throw new Error('missing required change property');
+
+ let patchNum: PatchSetNum;
+ if (patchNumStr === 'edit') {
+ patchNum = EditPatchSetNum;
+ } else {
+ patchNum = Number(`${patchNumStr}`) as PatchSetNum;
+ }
+
+ if (patchNum === this._selectedRevision._number) {
+ return;
+ }
+ if (this._change.revisions)
+ this._selectedRevision = Object.values(this._change.revisions).find(
+ revision => revision._number === patchNum
+ );
+ }
+
+ /**
+ * If an edit exists already, load it. Otherwise, toggle edit mode via the
+ * navigation API.
+ */
+ _handleEditTap() {
+ if (!this._change || !this._change.revisions)
+ throw new Error('missing required change property');
+ const editInfo = Object.values(this._change.revisions).find(
+ info => info._number === EditPatchSetNum
+ );
+
+ if (editInfo) {
+ GerritNav.navigateToChange(this._change, EditPatchSetNum);
+ return;
+ }
+
+ // Avoid putting patch set in the URL unless a non-latest patch set is
+ // selected.
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ let patchNum;
+ if (
+ !patchNumEquals(
+ this._patchRange.patchNum,
+ computeLatestPatchNum(this._allPatchSets)
+ )
+ ) {
+ patchNum = this._patchRange.patchNum;
+ }
+ GerritNav.navigateToChange(this._change, patchNum, undefined, true);
+ }
+
+ _handleStopEditTap() {
+ if (!this._change) throw new Error('missing required change property');
+ if (!this._patchRange)
+ throw new Error('missing required _patchRange property');
+ GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
+ }
+
+ _resetReplyOverlayFocusStops() {
+ this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
+ }
+
+ _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+ this.$.restAPI.saveChangeStarred(e.detail.change._number, e.detail.starred);
+ }
+
+ _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo) {
+ return new RevisionInfoClass(change);
+ }
+
+ _computeCurrentRevision(
+ currentRevision: CommitId,
+ revisions: {[revisionId: string]: RevisionInfo}
+ ) {
+ return currentRevision && revisions && revisions[currentRevision];
+ }
+
+ _computeDiffPrefsDisabled(disableDiffPrefs: boolean, loggedIn: boolean) {
+ return disableDiffPrefs || !loggedIn;
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeLatestPatchNum(allPatchSets: PatchSet[]) {
+ return computeLatestPatchNum(allPatchSets);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
+ return hasEditBasedOnCurrentPatchSet(allPatchSets);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _hasEditPatchsetLoaded(
+ patchRangeRecord: PolymerDeepPropertyChange<
+ ChangeViewPatchRange,
+ ChangeViewPatchRange
+ >
+ ) {
+ const patchRange = patchRangeRecord.base;
+ if (!patchRange) {
+ return false;
+ }
+ return hasEditPatchsetLoaded(patchRange);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeAllPatchSets(change: ChangeInfo) {
+ return computeAllPatchSets(change);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-view': GrChangeView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
index 04a810f..efc86bc 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_html.ts
@@ -85,6 +85,7 @@
.container .changeInfo {
display: flex;
background-color: var(--background-color-secondary);
+ padding-right: var(--spacing-m);
}
section {
background-color: var(--view-background-color);
@@ -164,6 +165,7 @@
margin-bottom: var(--spacing-l);
max-height: var(--relation-chain-max-height, 2em);
overflow: hidden;
+ position: relative; /* for arrowToCurrentChange to have position:absolute and be hidden */
}
.commitContainer {
display: flex;
@@ -347,6 +349,9 @@
>
<section class="changeInfoSection">
<div class$="[[_computeHeaderClass(_editMode)]]">
+ <h1 class="assistive-tech-only">
+ Change [[_change._number]]: [[_change.subject]]
+ </h1>
<div class="headerTitle">
<div class="changeStatuses">
<template is="dom-repeat" items="[[_changeStatuses]]" as="status">
@@ -409,7 +414,6 @@
edit-mode="[[_editMode]]"
edit-based-on-current-patch-set="[[_hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
private-by-default="[[_projectConfig.private_by_default]]"
- on-reload-change="_handleReloadChange"
on-edit-tap="_handleEditTap"
on-stop-edit-tap="_handleStopEditTap"
on-download-tap="_handleOpenDownloadDialog"
@@ -418,6 +422,7 @@
<!-- end commit actions -->
</div>
<!-- end header -->
+ <h2 class="assistive-tech-only">Change metadata</h2>
<div class="changeInfo">
<div class="changeInfo-column changeMetadata hideOnMobileOverlay">
<gr-change-metadata
@@ -435,6 +440,7 @@
<div id="mainChangeInfo" class="changeInfo-column mainChangeInfo">
<div id="commitAndRelated" class="hideOnMobileOverlay">
<div class="commitContainer">
+ <h3 class="assistive-tech-only">Commit Message</h3>
<div>
<gr-button
id="replyBtn"
@@ -467,6 +473,7 @@
<gr-button
link=""
class="editCommitMessage"
+ title="Edit commit message"
on-click="_handleEditCommitMessage"
hidden$="[[_hideEditCommitMessage]]"
>Edit</gr-button
@@ -537,6 +544,7 @@
</div>
</section>
+ <h2 class="assistive-tech-only">Files and Comments tabs</h2>
<paper-tabs id="primaryTabs" on-selected-changed="_setActivePrimaryTab">
<paper-tab data-name$="[[_constants.PrimaryTab.FILES]]">Files</paper-tab>
<paper-tab
@@ -635,6 +643,7 @@
logged-in="[[_loggedIn]]"
only-show-robot-comments-with-human-reply=""
on-thread-list-modified="_handleReloadDiffComments"
+ unresolved-only
></gr-thread-list>
</template>
<template
@@ -696,6 +705,7 @@
</paper-tab>
</paper-tabs>
<section class="changeLog">
+ <h2 class="assistive-tech-only">Change Log</h2>
<template
is="dom-if"
if="[[_isTabActive(_constants.SecondaryTab.CHANGE_LOG, _activeTabs)]]"
@@ -751,6 +761,7 @@
class="scrollable"
no-cancel-on-outside-click=""
no-cancel-on-esc-key=""
+ scroll-action="lock"
with-backdrop=""
>
<gr-reply-dialog
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
deleted file mode 100644
index ff8cbac..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.js
+++ /dev/null
@@ -1,2470 +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 '../../../test/common-test-setup-karma.js';
-import '../../edit/gr-edit-constants.js';
-import './gr-change-view.js';
-import {PrimaryTab, SecondaryTab, ChangeStatus} from '../../../constants/constants.js';
-
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrEditConstants} from '../../edit/gr-edit-constants.js';
-import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
-
-import 'lodash/lodash.js';
-import {generateChange, TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-const fixture = fixtureFromElement('gr-change-view');
-
-suite('gr-change-view tests', () => {
- let element;
-
- let navigateToChangeStub;
-
- suiteSetup(() => {
- const kb = TestKeyboardShortcutBinder.push();
- kb.bindShortcut(Shortcut.SEND_REPLY, 'ctrl+enter');
- kb.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r');
- kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
- kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
- kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
- kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
- kb.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
- kb.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
- kb.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
- kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
- kb.bindShortcut(Shortcut.EDIT_TOPIC, 't');
- });
-
- suiteTeardown(() => {
- TestKeyboardShortcutBinder.pop();
- });
-
- const TEST_SCROLL_TOP_PX = 100;
-
- const ROBOT_COMMENTS_LIMIT = 10;
-
- // TODO: should have a mock service to generate VALID fake data
- const THREADS = [
- {
- comments: [
- {
- __path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 2,
- robot_id: 'rb1',
- id: 'ecf0b9fa_fe1a5f62',
- line: 5,
- updated: '2018-02-08 18:49:18.000000000',
- message: 'test',
- unresolved: true,
- },
- {
- __path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'ecf0b9fa_fe1a5f62_1',
- line: 5,
- updated: '2018-02-08 18:49:18.000000000',
- message: 'test',
- unresolved: true,
- },
- {
- id: '503008e2_0ab203ee',
- path: '/COMMIT_MSG',
- line: 5,
- in_reply_to: 'ecf0b9fa_fe1a5f62',
- updated: '2018-02-13 22:48:48.018000000',
- message: 'draft',
- unresolved: false,
- __draft: true,
- __draftID: '0.m683trwff68',
- __editing: false,
- patch_set: '2',
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 5,
- rootId: 'ecf0b9fa_fe1a5f62',
- start_datetime: '2018-02-08 18:49:18.000000000',
- },
- {
- comments: [
- {
- __path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 3,
- id: 'ecf0b9fa_fe5f62',
- robot_id: 'rb2',
- line: 5,
- updated: '2018-02-08 18:49:18.000000000',
- message: 'test',
- unresolved: true,
- },
- {
- __path: 'test.txt',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 3,
- id: '09a9fb0a_1484e6cf',
- side: 'PARENT',
- updated: '2018-02-13 22:47:19.000000000',
- message: 'Some comment on another patchset.',
- unresolved: false,
- },
- ],
- patchNum: 3,
- path: 'test.txt',
- rootId: '09a9fb0a_1484e6cf',
- start_datetime: '2018-02-13 22:47:19.000000000',
- commentSide: 'PARENT',
- },
- {
- comments: [
- {
- __path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 2,
- id: '8caddf38_44770ec1',
- line: 4,
- updated: '2018-02-13 22:48:40.000000000',
- message: 'Another unresolved comment',
- unresolved: true,
- },
- ],
- patchNum: 2,
- path: '/COMMIT_MSG',
- line: 4,
- rootId: '8caddf38_44770ec1',
- start_datetime: '2018-02-13 22:48:40.000000000',
- },
- {
- comments: [
- {
- __path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 2,
- id: 'scaddf38_44770ec1',
- line: 4,
- updated: '2018-02-14 22:48:40.000000000',
- message: 'Yet another unresolved comment',
- unresolved: true,
- },
- ],
- patchNum: 2,
- path: '/COMMIT_MSG',
- line: 4,
- rootId: 'scaddf38_44770ec1',
- start_datetime: '2018-02-14 22:48:40.000000000',
- },
- {
- comments: [
- {
- id: 'zcf0b9fa_fe1a5f62',
- path: '/COMMIT_MSG',
- line: 6,
- updated: '2018-02-15 22:48:48.018000000',
- message: 'resolved draft',
- unresolved: false,
- __draft: true,
- __draftID: '0.m683trwff68',
- __editing: false,
- patch_set: '2',
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 6,
- rootId: 'zcf0b9fa_fe1a5f62',
- start_datetime: '2018-02-09 18:49:18.000000000',
- },
- {
- comments: [
- {
- __path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'rc1',
- line: 5,
- updated: '2019-02-08 18:49:18.000000000',
- message: 'test',
- unresolved: true,
- robot_id: 'rc1',
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 5,
- rootId: 'rc1',
- start_datetime: '2019-02-08 18:49:18.000000000',
- },
- {
- comments: [
- {
- __path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'rc2',
- line: 5,
- updated: '2019-03-08 18:49:18.000000000',
- message: 'test',
- unresolved: true,
- robot_id: 'rc2',
- },
- {
- __path: '/COMMIT_MSG',
- author: {
- _account_id: 1000000,
- name: 'user',
- username: 'user',
- },
- patch_set: 4,
- id: 'c2_1',
- line: 5,
- updated: '2019-03-08 18:49:18.000000000',
- message: 'test',
- unresolved: true,
- },
- ],
- patchNum: 4,
- path: '/COMMIT_MSG',
- line: 5,
- rootId: 'rc2',
- start_datetime: '2019-03-08 18:49:18.000000000',
- },
- ];
-
- setup(() => {
- // Since pluginEndpoints are global, must reset state.
- _testOnly_resetEndpoints();
- navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
- stub('gr-rest-api-interface', {
- getConfig() { return Promise.resolve({test: 'config'}); },
- getAccount() { return Promise.resolve(null); },
- getDiffComments() { return Promise.resolve({}); },
- getDiffRobotComments() { return Promise.resolve({}); },
- getDiffDrafts() { return Promise.resolve({}); },
- _fetchSharedCacheURL() { return Promise.resolve({}); },
- });
- element = fixture.instantiate();
- sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
- pluginLoader.loadPlugins([]);
- pluginApi.install(
- plugin => {
- plugin.registerDynamicCustomComponent(
- 'change-view-tab-header',
- 'gr-checks-change-view-tab-header-view'
- );
- plugin.registerDynamicCustomComponent(
- 'change-view-tab-content',
- 'gr-checks-view'
- );
- },
- '0.1',
- 'http://some/plugins/url.html'
- );
- });
-
- teardown(done => {
- flush(() => {
- done();
- });
- });
-
- const getCustomCssValue =
- cssParam => getComputedStyleValue(cssParam, element);
-
- test('_handleMessageAnchorTap', () => {
- element._changeNum = '1';
- element._patchRange = {
- basePatchNum: 'PARENT',
- patchNum: 1,
- };
- const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
- const replaceStateStub = sinon.stub(history, 'replaceState');
- element._handleMessageAnchorTap({detail: {id: 'a12345'}});
-
- assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
- assert.isTrue(replaceStateStub.called);
- });
-
- test('_handleDiffAgainstBase', () => {
- element._change = generateChange({revisionsCount: 10});
- element._patchRange = {
- patchNum: 3,
- basePatchNum: 1,
- };
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
- element._handleDiffAgainstBase(new CustomEvent(''));
- assert(navigateToChangeStub.called);
- const args = navigateToChangeStub.getCall(0).args;
- assert.equal(args[0], element._change);
- assert.equal(args[1], 3);
- });
-
- test('_handleDiffAgainstLatest', () => {
- element._change = generateChange({revisionsCount: 10});
- element._patchRange = {
- basePatchNum: 1,
- patchNum: 3,
- };
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
- element._handleDiffAgainstLatest(new CustomEvent(''));
- assert(navigateToChangeStub.called);
- const args = navigateToChangeStub.getCall(0).args;
- assert.equal(args[0], element._change);
- assert.equal(args[1], 10);
- assert.equal(args[2], 1);
- });
-
- test('_handleDiffBaseAgainstLeft', () => {
- element._change = generateChange({revisionsCount: 10});
- element._patchRange = {
- patchNum: 3,
- basePatchNum: 1,
- };
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
- element._handleDiffBaseAgainstLeft(new CustomEvent(''));
- assert(navigateToChangeStub.called);
- const args = navigateToChangeStub.getCall(0).args;
- assert.equal(args[0], element._change);
- assert.equal(args[1], 1);
- });
-
- test('_handleDiffRightAgainstLatest', () => {
- element._change = generateChange({revisionsCount: 10});
- element._patchRange = {
- basePatchNum: 1,
- patchNum: 3,
- };
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
- element._handleDiffRightAgainstLatest(new CustomEvent(''));
- assert(navigateToChangeStub.called);
- const args = navigateToChangeStub.getCall(0).args;
- assert.equal(args[1], 10);
- assert.equal(args[2], 3);
- });
-
- test('_handleDiffBaseAgainstLatest', () => {
- element._change = generateChange({revisionsCount: 10});
- element._patchRange = {
- basePatchNum: 1,
- patchNum: 3,
- };
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
- element._handleDiffBaseAgainstLatest(new CustomEvent(''));
- assert(navigateToChangeStub.called);
- const args = navigateToChangeStub.getCall(0).args;
- assert.equal(args[1], 10);
- assert.isNotOk(args[2]);
- });
-
- suite('plugins adding to file tab', () => {
- setup(done => {
- // Resolving it here instead of during setup() as other tests depend
- // on flush() not being called during setup.
- flush(() => done());
- });
-
- test('plugin added tab shows up as a dynamic endpoint', () => {
- assert(element._dynamicTabHeaderEndpoints.includes(
- 'change-view-tab-header-url'));
- const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
- // 4 Tabs are : Files, Comment Threads, Plugin, Findings
- assert.equal(paperTabs.querySelectorAll('paper-tab').length, 4);
- assert.equal(paperTabs.querySelectorAll('paper-tab')[2].dataset.name,
- 'change-view-tab-header-url');
- });
-
- test('_setActivePrimaryTab switched tab correctly', done => {
- element._setActivePrimaryTab({detail:
- {tab: 'change-view-tab-header-url'}});
- flush(() => {
- assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
- done();
- });
- });
-
- test('show-primary-tab switched primary tab correctly', done => {
- element.dispatchEvent(
- new CustomEvent('show-primary-tab', {
- composed: true,
- bubbles: true,
- detail: {
- tab: 'change-view-tab-header-url',
- },
- }));
- flush(() => {
- assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
- done();
- });
- });
-
- test('param change should switch primary tab correctly', done => {
- assert.equal(element._activeTabs[0], PrimaryTab.FILES);
- const queryMap = new Map();
- queryMap.set('tab', PrimaryTab.FINDINGS);
- // view is required
- element.params = Object.assign(
- {
- view: GerritNav.View.CHANGE,
- },
- element.params, {queryMap});
- flush(() => {
- assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
- done();
- });
- });
-
- test('invalid param change should not switch primary tab', done => {
- assert.equal(element._activeTabs[0], PrimaryTab.FILES);
- const queryMap = new Map();
- queryMap.set('tab', 'random');
- // view is required
- element.params = Object.assign(
- {
- view: GerritNav.View.CHANGE,
- },
- element.params, {queryMap});
- flush(() => {
- assert.equal(element._activeTabs[0], PrimaryTab.FILES);
- done();
- });
- });
-
- test('switching tab sets _selectedTabPluginEndpoint', done => {
- const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
- MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[2]);
- flush(() => {
- assert.equal(element._selectedTabPluginEndpoint,
- 'change-view-tab-content-url');
- done();
- });
- });
- });
-
- suite('keyboard shortcuts', () => {
- test('t to add topic', () => {
- const editStub = sinon.stub(element.$.metadata, 'editTopic');
- MockInteractions.pressAndReleaseKeyOn(element, 83, null, 't');
- assert(editStub.called);
- });
-
- test('S should toggle the CL star', () => {
- const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
- MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
- assert(starStub.called);
- });
-
- test('U should navigate to root if no backPage set', () => {
- const relativeNavStub = sinon.stub(GerritNav,
- 'navigateToRelativeUrl');
- MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
- assert.isTrue(relativeNavStub.called);
- assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
- GerritNav.getUrlForRoot()));
- });
-
- test('U should navigate to backPage if set', () => {
- const relativeNavStub = sinon.stub(GerritNav,
- 'navigateToRelativeUrl');
- element.backPage = '/dashboard/self';
- MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
- assert.isTrue(relativeNavStub.called);
- assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
- '/dashboard/self'));
- });
-
- test('A fires an error event when not logged in', done => {
- sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
- const loggedInErrorSpy = sinon.spy();
- element.addEventListener('show-auth-required', loggedInErrorSpy);
- MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
- flush(() => {
- assert.isFalse(element.$.replyOverlay.opened);
- assert.isTrue(loggedInErrorSpy.called);
- done();
- });
- });
-
- test('shift A does not open reply overlay', done => {
- sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
- MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
- flush(() => {
- assert.isFalse(element.$.replyOverlay.opened);
- done();
- });
- });
-
- test('A toggles overlay when logged in', done => {
- sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
- element._change = generateChange({
- revisionsCount: 1,
- messagesCount: 1,
- });
- element._change.labels = {};
- sinon.stub(element.$.restAPI, 'getChangeDetail')
- .callsFake(() => Promise.resolve(generateChange({
- // element has latest info
- revisionsCount: 1,
- messagesCount: 1,
- })));
-
- const openSpy = sinon.spy(element, '_openReplyDialog');
-
- MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
- flush(() => {
- assert.isTrue(element.$.replyOverlay.opened);
- element.$.replyOverlay.close();
- assert.isFalse(element.$.replyOverlay.opened);
- assert(openSpy.lastCall.calledWithExactly(
- element.$.replyDialog.FocusTarget.ANY),
- '_openReplyDialog should have been passed ANY');
- assert.equal(openSpy.callCount, 1);
- done();
- });
- });
-
- test('fullscreen-overlay-opened hides content', () => {
- element._loggedIn = true;
- element._loading = false;
- element._change = {
- owner: {_account_id: 1},
- labels: {},
- actions: {
- abandon: {
- enabled: true,
- label: 'Abandon',
- method: 'POST',
- title: 'Abandon',
- },
- },
- };
- sinon.spy(element, '_handleHideBackgroundContent');
- element.$.replyDialog.dispatchEvent(
- new CustomEvent('fullscreen-overlay-opened', {
- composed: true, bubbles: true,
- }));
- assert.isTrue(element._handleHideBackgroundContent.called);
- assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
- assert.equal(getComputedStyle(element.$.actions).display, 'flex');
- });
-
- test('fullscreen-overlay-closed shows content', () => {
- element._loggedIn = true;
- element._loading = false;
- element._change = {
- owner: {_account_id: 1},
- labels: {},
- actions: {
- abandon: {
- enabled: true,
- label: 'Abandon',
- method: 'POST',
- title: 'Abandon',
- },
- },
- };
- sinon.spy(element, '_handleShowBackgroundContent');
- element.$.replyDialog.dispatchEvent(
- new CustomEvent('fullscreen-overlay-closed', {
- composed: true, bubbles: true,
- }));
- assert.isTrue(element._handleShowBackgroundContent.called);
- assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
- });
-
- test('expand all messages when expand-diffs fired', () => {
- const handleExpand =
- sinon.stub(element.$.fileList, 'expandAllDiffs');
- element.$.fileListHeader.dispatchEvent(
- new CustomEvent('expand-diffs', {
- composed: true, bubbles: true,
- }));
- assert.isTrue(handleExpand.called);
- });
-
- test('collapse all messages when collapse-diffs fired', () => {
- const handleCollapse =
- sinon.stub(element.$.fileList, 'collapseAllDiffs');
- element.$.fileListHeader.dispatchEvent(
- new CustomEvent('collapse-diffs', {
- composed: true, bubbles: true,
- }));
- assert.isTrue(handleCollapse.called);
- });
-
- test('X should expand all messages', done => {
- flush(() => {
- const handleExpand = sinon.stub(element.messagesList,
- 'handleExpandCollapse');
- MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
- assert(handleExpand.calledWith(true));
- done();
- });
- });
-
- test('Z should collapse all messages', done => {
- flush(() => {
- const handleExpand = sinon.stub(element.messagesList,
- 'handleExpandCollapse');
- MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
- assert(handleExpand.calledWith(false));
- done();
- });
- });
-
- test('shift + R should fetch and navigate to the latest patch set',
- done => {
- element._changeNum = '42';
- element._patchRange = {
- basePatchNum: 'PARENT',
- patchNum: 1,
- };
- element._change = {
- change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
- _number: 42,
- revisions: {
- rev1: {_number: 1, commit: {parents: []}},
- },
- current_revision: 'rev1',
- status: 'NEW',
- labels: {},
- actions: {},
- };
-
- navigateToChangeStub.restore();
- navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange')
- .callsFake((change, patchNum, basePatchNum) => {
- assert.equal(change, element._change);
- assert.isUndefined(patchNum);
- assert.isUndefined(basePatchNum);
- done();
- });
-
- MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
- });
-
- test('d should open download overlay', () => {
- const stub = sinon.stub(element.$.downloadOverlay, 'open').returns(
- new Promise(resolve => {})
- );
- MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
- assert.isTrue(stub.called);
- });
-
- test(', should open diff preferences', () => {
- const stub = sinon.stub(
- element.$.fileList.$.diffPreferencesDialog, 'open');
- element._loggedIn = false;
- element.disableDiffPrefs = true;
- MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
- assert.isFalse(stub.called);
-
- element._loggedIn = true;
- MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
- assert.isFalse(stub.called);
-
- element.disableDiffPrefs = false;
- MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
- assert.isTrue(stub.called);
- });
-
- test('m should toggle diff mode', () => {
- sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
- const setModeStub = sinon.stub(element.$.fileListHeader,
- 'setDiffViewMode');
- const e = {preventDefault: () => {}};
- flushAsynchronousOperations();
-
- element.viewState.diffMode = 'SIDE_BY_SIDE';
- element._handleToggleDiffMode(e);
- assert.isTrue(setModeStub.calledWith('UNIFIED_DIFF'));
-
- element.viewState.diffMode = 'UNIFIED_DIFF';
- element._handleToggleDiffMode(e);
- assert.isTrue(setModeStub.calledWith('SIDE_BY_SIDE'));
- });
- });
-
- suite('reloading drafts', () => {
- let reloadStub;
- const drafts = {
- 'testfile.txt': [
- {
- patch_set: 5,
- id: 'dd2982f5_c01c9e6a',
- line: 1,
- updated: '2017-11-08 18:47:45.000000000',
- message: 'test',
- unresolved: true,
- },
- ],
- };
- setup(() => {
- // Fake computeDraftCount as its required for ChangeComments,
- // see gr-comment-api#reloadDrafts.
- reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts')
- .returns(Promise.resolve({
- drafts,
- getAllThreadsForChange: () => ([]),
- computeDraftCount: () => 1,
- }));
- });
-
- test('drafts are reloaded when reload-drafts fired', done => {
- element.$.fileList.dispatchEvent(
- new CustomEvent('reload-drafts', {
- detail: {
- resolve: () => {
- assert.isTrue(reloadStub.called);
- assert.deepEqual(element._diffDrafts, drafts);
- done();
- },
- },
- composed: true, bubbles: true,
- }));
- });
-
- test('drafts are reloaded when comment-refresh fired', () => {
- element.dispatchEvent(
- new CustomEvent('comment-refresh', {
- composed: true, bubbles: true,
- }));
- assert.isTrue(reloadStub.called);
- });
- });
-
- suite('_recomputeComments', () => {
- setup(() => {
- // Fake computeDraftCount as its required for ChangeComments,
- // see gr-comment-api#reloadDrafts.
- sinon.stub(element.$.commentAPI, 'reloadDrafts')
- .returns(Promise.resolve({
- drafts: {},
- getAllThreadsForChange: () => THREADS,
- computeDraftCount: () => 0,
- }));
- });
-
- test('draft threads should be a new copy with correct states', done => {
- element.$.fileList.dispatchEvent(
- new CustomEvent('reload-drafts', {
- detail: {
- resolve: () => {
- assert.equal(element._draftCommentThreads.length, 2);
- assert.equal(
- element._draftCommentThreads[0].rootId,
- THREADS[0].rootId
- );
- assert.notEqual(
- element._draftCommentThreads[0].comments,
- THREADS[0].comments
- );
- assert.notEqual(
- element._draftCommentThreads[0].comments[0],
- THREADS[0].comments[0]
- );
- assert.isTrue(
- element._draftCommentThreads[0]
- .comments
- .slice(0, 2)
- .every(c => c.collapsed === true)
- );
-
- assert.isTrue(
- element._draftCommentThreads[0]
- .comments[2]
- .collapsed === false
- );
- done();
- },
- },
- composed: true, bubbles: true,
- }));
- });
- });
-
- test('diff comments modified', () => {
- sinon.spy(element, '_handleReloadCommentThreads');
- return element._reloadComments().then(() => {
- element.dispatchEvent(
- new CustomEvent('diff-comments-modified', {
- composed: true, bubbles: true,
- }));
- assert.isTrue(element._handleReloadCommentThreads.called);
- });
- });
-
- test('thread list modified', () => {
- sinon.spy(element, '_handleReloadDiffComments');
- element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
- flushAsynchronousOperations();
-
- return element._reloadComments().then(() => {
- element.threadList.dispatchEvent(
- new CustomEvent('thread-list-modified', {
- composed: true, bubbles: true,
- }));
- assert.isTrue(element._handleReloadDiffComments.called);
-
- let draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
- .returns(1);
- assert.equal(element._computeTotalCommentCounts(5,
- element._changeComments), '5 unresolved, 1 draft');
- assert.equal(element._computeTotalCommentCounts(0,
- element._changeComments), '1 draft');
- draftStub.restore();
- draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
- .returns(0);
- assert.equal(element._computeTotalCommentCounts(0,
- element._changeComments), '');
- assert.equal(element._computeTotalCommentCounts(1,
- element._changeComments), '1 unresolved');
- draftStub.restore();
- draftStub = sinon.stub(element._changeComments, 'computeDraftCount')
- .returns(2);
- assert.equal(element._computeTotalCommentCounts(1,
- element._changeComments), '1 unresolved, 2 drafts');
- draftStub.restore();
- });
- });
-
- suite('thread list and change log tabs', () => {
- setup(() => {
- element._changeNum = '1';
- element._patchRange = {
- basePatchNum: 'PARENT',
- patchNum: 1,
- };
- element._change = {
- change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
- revisions: {
- rev2: {_number: 2, commit: {parents: []}},
- rev1: {_number: 1, commit: {parents: []}},
- rev13: {_number: 13, commit: {parents: []}},
- rev3: {_number: 3, commit: {parents: []}},
- },
- current_revision: 'rev3',
- status: 'NEW',
- labels: {
- test: {
- all: [],
- default_value: 0,
- values: [],
- approved: {},
- },
- },
- };
- sinon.stub(element.$.relatedChanges, 'reload');
- sinon.stub(element, '_reload').returns(Promise.resolve());
- sinon.spy(element, '_paramsChanged');
- element.params = {view: 'change', changeNum: '1'};
- });
- });
-
- suite('Findings comment tab', () => {
- setup(done => {
- element._change = {
- change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
- revisions: {
- rev2: {_number: 2, commit: {parents: []}},
- rev1: {_number: 1, commit: {parents: []}},
- rev13: {_number: 13, commit: {parents: []}},
- rev3: {_number: 3, commit: {parents: []}},
- rev4: {_number: 4, commit: {parents: []}},
- },
- current_revision: 'rev4',
- };
- element._commentThreads = THREADS;
- const paperTabs = element.shadowRoot.querySelector('#primaryTabs');
- MockInteractions.tap(paperTabs.querySelectorAll('paper-tab')[3]);
- flush(() => {
- done();
- });
- });
-
- test('robot comments count per patchset', () => {
- const count = element._robotCommentCountPerPatchSet(THREADS);
- const expectedCount = {
- 2: 1,
- 3: 1,
- 4: 2,
- };
- assert.deepEqual(count, expectedCount);
- assert.equal(element._computeText({_number: 2}, THREADS),
- 'Patchset 2 (1 finding)');
- assert.equal(element._computeText({_number: 4}, THREADS),
- 'Patchset 4 (2 findings)');
- assert.equal(element._computeText({_number: 5}, THREADS),
- 'Patchset 5');
- });
-
- test('only robot comments are rendered', () => {
- assert.equal(element._robotCommentThreads.length, 2);
- assert.equal(element._robotCommentThreads[0].comments[0].robot_id,
- 'rc1');
- assert.equal(element._robotCommentThreads[1].comments[0].robot_id,
- 'rc2');
- });
-
- test('changing patchsets resets robot comments', done => {
- element.set('_change.current_revision', 'rev3');
- flush(() => {
- assert.equal(element._robotCommentThreads.length, 1);
- done();
- });
- });
-
- test('Show more button is hidden', () => {
- assert.isNull(element.shadowRoot.querySelector('.show-robot-comments'));
- });
-
- suite('robot comments show more button', () => {
- setup(done => {
- const arr = [];
- for (let i = 0; i <= 30; i++) {
- arr.push(...THREADS);
- }
- element._commentThreads = arr;
- flush(() => {
- done();
- });
- });
-
- test('Show more button is rendered', () => {
- assert.isOk(element.shadowRoot.querySelector('.show-robot-comments'));
- assert.equal(element._robotCommentThreads.length,
- ROBOT_COMMENTS_LIMIT);
- });
-
- test('Clicking show more button renders all comments', done => {
- MockInteractions.tap(element.shadowRoot.querySelector(
- '.show-robot-comments'));
- flush(() => {
- assert.equal(element._robotCommentThreads.length, 62);
- done();
- });
- });
- });
- });
-
- test('reply button is not visible when logged out', () => {
- assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
- element._loggedIn = true;
- assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
- });
-
- test('download tap calls _handleOpenDownloadDialog', () => {
- sinon.stub(element, '_handleOpenDownloadDialog');
- element.$.actions.dispatchEvent(
- new CustomEvent('download-tap', {
- composed: true, bubbles: true,
- }));
- assert.isTrue(element._handleOpenDownloadDialog.called);
- });
-
- test('fetches the server config on attached', done => {
- flush(() => {
- assert.equal(element._serverConfig.test, 'config');
- done();
- });
- });
-
- test('_changeStatuses', () => {
- element._loading = false;
- element._change = {
- change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
- revisions: {
- rev2: {_number: 2},
- rev1: {_number: 1},
- rev13: {_number: 13},
- rev3: {_number: 3},
- },
- current_revision: 'rev3',
- status: ChangeStatus.MERGED,
- work_in_progress: true,
- labels: {
- test: {
- all: [],
- default_value: 0,
- values: [],
- approved: {},
- },
- },
- };
- element._mergeable = true;
- const expectedStatuses = ['Merged', 'WIP'];
- assert.deepEqual(element._changeStatuses, expectedStatuses);
- assert.equal(element._changeStatus, expectedStatuses.join(', '));
- flushAsynchronousOperations();
- const statusChips = dom(element.root)
- .querySelectorAll('gr-change-status');
- assert.equal(statusChips.length, 2);
- });
-
- test('diff preferences open when open-diff-prefs is fired', () => {
- const overlayOpenStub = sinon.stub(element.$.fileList,
- 'openDiffPrefs');
- element.$.fileListHeader.dispatchEvent(
- new CustomEvent('open-diff-prefs', {
- composed: true, bubbles: true,
- }));
- assert.isTrue(overlayOpenStub.called);
- });
-
- test('_prepareCommitMsgForLinkify', () => {
- let commitMessage = 'R=test@google.com';
- let result = element._prepareCommitMsgForLinkify(commitMessage);
- assert.equal(result, 'R=\u200Btest@google.com');
-
- commitMessage = 'R=test@google.com\nR=test@google.com';
- result = element._prepareCommitMsgForLinkify(commitMessage);
- assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
-
- commitMessage = 'CC=test@google.com';
- result = element._prepareCommitMsgForLinkify(commitMessage);
- assert.equal(result, 'CC=\u200Btest@google.com');
- });
-
- test('_isSubmitEnabled', () => {
- assert.isFalse(element._isSubmitEnabled({}));
- assert.isFalse(element._isSubmitEnabled({submit: {}}));
- assert.isTrue(element._isSubmitEnabled(
- {submit: {enabled: true}}));
- });
-
- test('_reload is called when an approved label is removed', () => {
- const vote = {_account_id: 1, name: 'bojack', value: 1};
- element._changeNum = '42';
- element._patchRange = {
- basePatchNum: 'PARENT',
- patchNum: 1,
- };
- element._change = {
- change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
- owner: {email: 'abc@def'},
- revisions: {
- rev2: {_number: 2, commit: {parents: []}},
- rev1: {_number: 1, commit: {parents: []}},
- rev13: {_number: 13, commit: {parents: []}},
- rev3: {_number: 3, commit: {parents: []}},
- },
- current_revision: 'rev3',
- status: 'NEW',
- labels: {
- test: {
- all: [vote],
- default_value: 0,
- values: [],
- approved: {},
- },
- },
- };
- flushAsynchronousOperations();
- const reloadStub = sinon.stub(element, '_reload');
- element.splice('_change.labels.test.all', 0, 1);
- assert.isFalse(reloadStub.called);
- element._change.labels.test.all.push(vote);
- element._change.labels.test.all.push(vote);
- element._change.labels.test.approved = vote;
- flushAsynchronousOperations();
- element.splice('_change.labels.test.all', 0, 2);
- assert.isTrue(reloadStub.called);
- assert.isTrue(reloadStub.calledOnce);
- });
-
- test('reply button has updated count when there are drafts', () => {
- const getLabel = element._computeReplyButtonLabel;
-
- assert.equal(getLabel(null, false), 'Reply');
- assert.equal(getLabel(null, true), 'Start Review');
-
- const changeRecord = {base: null};
- assert.equal(getLabel(changeRecord, false), 'Reply');
-
- changeRecord.base = {};
- assert.equal(getLabel(changeRecord, false), 'Reply');
-
- changeRecord.base = {
- 'file1.txt': [{}],
- 'file2.txt': [{}, {}],
- };
- assert.equal(getLabel(changeRecord, false), 'Reply (3)');
- assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
- });
-
- test('comment events properly update diff drafts', () => {
- element._patchRange = {
- basePatchNum: 'PARENT',
- patchNum: 2,
- };
- const draft = {
- __draft: true,
- id: 'id1',
- path: '/foo/bar.txt',
- text: 'hello',
- };
- element._handleCommentSave({detail: {comment: draft}});
- draft.patch_set = 2;
- assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
- draft.patch_set = null;
- draft.text = 'hello, there';
- element._handleCommentSave({detail: {comment: draft}});
- draft.patch_set = 2;
- assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
- const draft2 = {
- __draft: true,
- id: 'id2',
- path: '/foo/bar.txt',
- text: 'hola',
- };
- element._handleCommentSave({detail: {comment: draft2}});
- draft2.patch_set = 2;
- assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
- draft.patch_set = null;
- element._handleCommentDiscard({detail: {comment: draft}});
- draft.patch_set = 2;
- assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
- element._handleCommentDiscard({detail: {comment: draft2}});
- assert.deepEqual(element._diffDrafts, {});
- });
-
- test('change num change', () => {
- element._changeNum = null;
- element._patchRange = {
- basePatchNum: 'PARENT',
- patchNum: 2,
- };
- element._change = {
- change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
- labels: {},
- };
- element.viewState.changeNum = null;
- element.viewState.diffMode = 'UNIFIED';
- assert.equal(element.viewState.numFilesShown, 200);
- assert.equal(element._numFilesShown, 200);
- element._numFilesShown = 150;
- flushAsynchronousOperations();
- assert.equal(element.viewState.diffMode, 'UNIFIED');
- assert.equal(element.viewState.numFilesShown, 150);
-
- element._changeNum = '1';
- element.params = {changeNum: '1'};
- element._change.newProp = '1';
- flushAsynchronousOperations();
- assert.equal(element.viewState.diffMode, 'UNIFIED');
- assert.equal(element.viewState.changeNum, '1');
-
- element._changeNum = '2';
- element.params = {changeNum: '2'};
- element._change.newProp = '2';
- flushAsynchronousOperations();
- assert.equal(element.viewState.diffMode, 'UNIFIED');
- assert.equal(element.viewState.changeNum, '2');
- assert.equal(element.viewState.numFilesShown, 200);
- assert.equal(element._numFilesShown, 200);
- });
-
- test('_setDiffViewMode is called with reset when new change is loaded',
- () => {
- sinon.stub(element, '_setDiffViewMode');
- element.viewState = {changeNum: 1};
- element._changeNum = 2;
- element._resetFileListViewState();
- assert.isTrue(
- element._setDiffViewMode.lastCall.calledWithExactly(true));
- });
-
- test('diffViewMode is propagated from file list header', () => {
- element.viewState = {diffMode: 'UNIFIED'};
- element.$.fileListHeader.diffViewMode = 'SIDE_BY_SIDE';
- assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
- });
-
- test('diffMode defaults to side by side without preferences', done => {
- sinon.stub(element.$.restAPI, 'getPreferences').returns(
- Promise.resolve({}));
- // No user prefs or diff view mode set.
-
- element._setDiffViewMode().then(() => {
- assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
- done();
- });
- });
-
- test('diffMode defaults to preference when not already set', done => {
- sinon.stub(element.$.restAPI, 'getPreferences').returns(
- Promise.resolve({default_diff_view: 'UNIFIED'}));
-
- element._setDiffViewMode().then(() => {
- assert.equal(element.viewState.diffMode, 'UNIFIED');
- done();
- });
- });
-
- test('existing diffMode overrides preference', done => {
- element.viewState.diffMode = 'SIDE_BY_SIDE';
- sinon.stub(element.$.restAPI, 'getPreferences').returns(
- Promise.resolve({default_diff_view: 'UNIFIED'}));
- element._setDiffViewMode().then(() => {
- assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
- done();
- });
- });
-
- test('don’t reload entire page when patchRange changes', () => {
- const reloadStub = sinon.stub(element, '_reload').callsFake(
- () => Promise.resolve());
- const reloadPatchDependentStub = sinon.stub(element,
- '_reloadPatchNumDependentResources')
- .callsFake(() => Promise.resolve());
- const relatedClearSpy = sinon.spy(element.$.relatedChanges, 'clear');
- const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-
- const value = {
- view: GerritNav.View.CHANGE,
- patchNum: '1',
- };
- element._paramsChanged(value);
- assert.isTrue(reloadStub.calledOnce);
- assert.isTrue(relatedClearSpy.calledOnce);
-
- element._initialLoadComplete = true;
-
- value.basePatchNum = '1';
- value.patchNum = '2';
- element._paramsChanged(value);
- assert.isFalse(reloadStub.calledTwice);
- assert.isTrue(reloadPatchDependentStub.calledOnce);
- assert.isTrue(relatedClearSpy.calledOnce);
- assert.isTrue(collapseStub.calledTwice);
- });
-
- test('reload entire page when patchRange doesnt change', () => {
- const reloadStub = sinon.stub(element, '_reload').callsFake(
- () => Promise.resolve());
- const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
- const value = {
- view: GerritNav.View.CHANGE,
- };
- element._paramsChanged(value);
- assert.isTrue(reloadStub.calledOnce);
- element._initialLoadComplete = true;
- element._paramsChanged(value);
- assert.isTrue(reloadStub.calledTwice);
- assert.isTrue(collapseStub.calledTwice);
- });
-
- test('related changes are not updated after other action', done => {
- sinon.stub(element, '_reload').callsFake(() => Promise.resolve());
- sinon.stub(element.$.relatedChanges, 'reload');
- const e = {detail: {action: 'abandon'}};
- element._handleReloadChange(e).then(() => {
- assert.isFalse(navigateToChangeStub.called);
- done();
- });
- });
-
- test('_computeMergedCommitInfo', () => {
- const dummyRevs = {
- 1: {commit: {commit: 1}},
- 2: {commit: {}},
- };
- assert.deepEqual(element._computeMergedCommitInfo(0, dummyRevs), {});
- assert.deepEqual(element._computeMergedCommitInfo(1, dummyRevs),
- dummyRevs[1].commit);
-
- // Regression test for issue 5337.
- const commit = element._computeMergedCommitInfo(2, dummyRevs);
- assert.notDeepEqual(commit, dummyRevs[2]);
- assert.deepEqual(commit, {commit: 2});
- });
-
- test('_computeCopyTextForTitle', () => {
- const change = {
- _number: 123,
- subject: 'test subject',
- revisions: {
- rev1: {_number: 1},
- rev3: {_number: 3},
- },
- current_revision: 'rev3',
- };
- sinon.stub(GerritNav, 'getUrlForChange')
- .returns('/change/123');
- assert.equal(
- element._computeCopyTextForTitle(change),
- `123: test subject | http://${location.host}/change/123`
- );
- });
-
- test('get latest revision', () => {
- let change = {
- revisions: {
- rev1: {_number: 1},
- rev3: {_number: 3},
- },
- current_revision: 'rev3',
- };
- assert.equal(element._getLatestRevisionSHA(change), 'rev3');
- change = {
- revisions: {
- rev1: {_number: 1},
- },
- };
- assert.equal(element._getLatestRevisionSHA(change), 'rev1');
- });
-
- test('show commit message edit button', () => {
- const _change = {
- status: ChangeStatus.MERGED,
- };
- assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
- assert.isTrue(element._computeHideEditCommitMessage(true, true, {}));
- assert.isTrue(element._computeHideEditCommitMessage(false, true, {}));
- assert.isFalse(element._computeHideEditCommitMessage(true, false, {}));
- assert.isTrue(element._computeHideEditCommitMessage(true, false,
- _change));
- assert.isTrue(element._computeHideEditCommitMessage(true, false, {},
- true));
- assert.isFalse(element._computeHideEditCommitMessage(true, false, {},
- false));
- });
-
- test('_handleCommitMessageSave trims trailing whitespace', () => {
- const putStub = sinon.stub(element.$.restAPI, 'putChangeCommitMessage')
- .returns(Promise.resolve({}));
-
- const mockEvent = content => { return {detail: {content}}; };
-
- element._handleCommitMessageSave(mockEvent('test \n test '));
- assert.equal(putStub.lastCall.args[1], 'test\n test');
-
- element._handleCommitMessageSave(mockEvent(' test\ntest'));
- assert.equal(putStub.lastCall.args[1], ' test\ntest');
-
- element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
- assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
- });
-
- test('_computeChangeIdCommitMessageError', () => {
- let commitMessage =
- 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
- let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
- assert.equal(
- element._computeChangeIdCommitMessageError(commitMessage, change),
- null);
-
- change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
- assert.equal(
- element._computeChangeIdCommitMessageError(commitMessage, change),
- 'mismatch');
-
- commitMessage = 'This is the greatest change.';
- assert.equal(
- element._computeChangeIdCommitMessageError(commitMessage, change),
- 'missing');
- });
-
- test('multiple change Ids in commit message picks last', () => {
- const commitMessage = [
- 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
- 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
- ].join('\n');
- let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
- assert.equal(
- element._computeChangeIdCommitMessageError(commitMessage, change),
- null);
- change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
- assert.equal(
- element._computeChangeIdCommitMessageError(commitMessage, change),
- 'mismatch');
- });
-
- test('does not count change Id that starts mid line', () => {
- const commitMessage = [
- 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
- 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
- ].join(' and ');
- let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
- assert.equal(
- element._computeChangeIdCommitMessageError(commitMessage, change),
- null);
- change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
- assert.equal(
- element._computeChangeIdCommitMessageError(commitMessage, change),
- 'mismatch');
- });
-
- test('_computeTitleAttributeWarning', () => {
- let changeIdCommitMessageError = 'missing';
- assert.equal(
- element._computeTitleAttributeWarning(changeIdCommitMessageError),
- 'No Change-Id in commit message');
-
- changeIdCommitMessageError = 'mismatch';
- assert.equal(
- element._computeTitleAttributeWarning(changeIdCommitMessageError),
- 'Change-Id mismatch');
- });
-
- test('_computeChangeIdClass', () => {
- let changeIdCommitMessageError = 'missing';
- assert.equal(
- element._computeChangeIdClass(changeIdCommitMessageError), '');
-
- changeIdCommitMessageError = 'mismatch';
- assert.equal(
- element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
- });
-
- test('topic is coalesced to null', done => {
- sinon.stub(element, '_changeChanged');
- sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(
- () => Promise.resolve({
- id: '123456789',
- labels: {},
- current_revision: 'foo',
- revisions: {foo: {commit: {}}},
- }));
-
- element._getChangeDetail().then(() => {
- assert.isNull(element._change.topic);
- done();
- });
- });
-
- test('commit sha is populated from getChangeDetail', done => {
- sinon.stub(element, '_changeChanged');
- sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(
- () => Promise.resolve({
- id: '123456789',
- labels: {},
- current_revision: 'foo',
- revisions: {foo: {commit: {}}},
- }));
-
- element._getChangeDetail().then(() => {
- assert.equal('foo', element._commitInfo.commit);
- done();
- });
- });
-
- test('edit is added to change', () => {
- sinon.stub(element, '_changeChanged');
- sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(
- () => Promise.resolve({
- id: '123456789',
- labels: {},
- current_revision: 'foo',
- revisions: {foo: {commit: {}}},
- }));
- sinon.stub(element, '_getEdit').callsFake(() => Promise.resolve({
- base_patch_set_number: 1,
- commit: {commit: 'bar'},
- }));
- element._patchRange = {};
-
- return element._getChangeDetail().then(() => {
- const revs = element._change.revisions;
- assert.equal(Object.keys(revs).length, 2);
- assert.deepEqual(revs['foo'], {commit: {commit: 'foo'}});
- assert.deepEqual(revs['bar'], {
- _number: SPECIAL_PATCH_SET_NUM.EDIT,
- basePatchNum: 1,
- commit: {commit: 'bar'},
- fetch: undefined,
- });
- });
- });
-
- test('_getBasePatchNum', () => {
- const _change = {
- _number: 42,
- revisions: {
- '98da160735fb81604b4c40e93c368f380539dd0e': {
- _number: 1,
- commit: {
- parents: [],
- },
- },
- },
- };
- const _patchRange = {
- basePatchNum: 'PARENT',
- };
- assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
-
- element._prefs = {
- default_base_for_merges: 'FIRST_PARENT',
- };
-
- const _change2 = {
- _number: 42,
- revisions: {
- '98da160735fb81604b4c40e93c368f380539dd0e': {
- _number: 1,
- commit: {
- parents: [
- {
- commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8',
- subject: 'test',
- },
- {
- commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841',
- subject: 'test3',
- },
- ],
- },
- },
- },
- };
- assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
-
- _patchRange.patchNum = 1;
- assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
- });
-
- test('_openReplyDialog called with `ANY` when coming from tap event',
- () => {
- const openStub = sinon.stub(element, '_openReplyDialog');
- element._serverConfig = {};
- MockInteractions.tap(element.$.replyBtn);
- assert(openStub.lastCall.calledWithExactly(
- element.$.replyDialog.FocusTarget.ANY),
- '_openReplyDialog should have been passed ANY');
- assert.equal(openStub.callCount, 1);
- });
-
- test('_openReplyDialog called with `BODY` when coming from message reply' +
- 'event', done => {
- flush(() => {
- const openStub = sinon.stub(element, '_openReplyDialog');
- element.messagesList.dispatchEvent(
- new CustomEvent('reply', {
- detail:
- {message: {message: 'text'}},
- composed: true, bubbles: true,
- }));
- assert(openStub.lastCall.calledWithExactly(
- element.$.replyDialog.FocusTarget.BODY),
- '_openReplyDialog should have been passed BODY');
- assert.equal(openStub.callCount, 1);
- done();
- });
- });
-
- test('reply dialog focus can be controlled', () => {
- const FocusTarget = element.$.replyDialog.FocusTarget;
- const openStub = sinon.stub(element, '_openReplyDialog');
-
- const e = {detail: {}};
- element._handleShowReplyDialog(e);
- assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
- '_openReplyDialog should have been passed REVIEWERS');
- assert.equal(openStub.callCount, 1);
-
- e.detail.value = {ccsOnly: true};
- element._handleShowReplyDialog(e);
- assert(openStub.lastCall.calledWithExactly(FocusTarget.CCS),
- '_openReplyDialog should have been passed CCS');
- assert.equal(openStub.callCount, 2);
- });
-
- test('getUrlParameter functionality', () => {
- const locationStub = sinon.stub(element, '_getLocationSearch');
-
- locationStub.returns('?test');
- assert.equal(element._getUrlParameter('test'), 'test');
- locationStub.returns('?test2=12&test=3');
- assert.equal(element._getUrlParameter('test'), 'test');
- locationStub.returns('');
- assert.isNull(element._getUrlParameter('test'));
- locationStub.returns('?');
- assert.isNull(element._getUrlParameter('test'));
- locationStub.returns('?test2');
- assert.isNull(element._getUrlParameter('test'));
- });
-
- test('revert dialog opened with revert param', done => {
- sinon.stub(element.$.restAPI, 'getLoggedIn')
- .callsFake(() => Promise.resolve(true));
- sinon.stub(pluginLoader, 'awaitPluginsLoaded')
- .callsFake(() => Promise.resolve());
-
- element._patchRange = {
- basePatchNum: 'PARENT',
- patchNum: 2,
- };
- element._change = {
- change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
- revisions: {
- rev1: {_number: 1, commit: {parents: []}},
- rev2: {_number: 2, commit: {parents: []}},
- },
- current_revision: 'rev1',
- status: ChangeStatus.MERGED,
- labels: {},
- actions: {},
- };
-
- sinon.stub(element, '_getUrlParameter').callsFake(
- param => {
- assert.equal(param, 'revert');
- return param;
- });
-
- sinon.stub(element.$.actions, 'showRevertDialog').callsFake(
- done);
-
- element._maybeShowRevertDialog();
- assert.isTrue(pluginLoader.awaitPluginsLoaded.called);
- });
-
- suite('scroll related tests', () => {
- test('document scrolling calls function to set scroll height', done => {
- const originalHeight = document.body.scrollHeight;
- const scrollStub = sinon.stub(element, '_handleScroll').callsFake(
- () => {
- assert.isTrue(scrollStub.called);
- document.body.style.height = originalHeight + 'px';
- scrollStub.restore();
- done();
- });
- document.body.style.height = '10000px';
- element._handleScroll();
- });
-
- test('scrollTop is set correctly', () => {
- element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
-
- sinon.stub(element, '_reload').callsFake(() => {
- // When element is reloaded, ensure that the history
- // state has the scrollTop set earlier. This will then
- // be reset.
- assert.isTrue(element.viewState.scrollTop == TEST_SCROLL_TOP_PX);
- return Promise.resolve({});
- });
-
- // simulate reloading component, which is done when route
- // changes to match a regex of change view type.
- element._paramsChanged({view: GerritNav.View.CHANGE});
- });
-
- test('scrollTop is reset when new change is loaded', () => {
- element._resetFileListViewState();
- assert.equal(element.viewState.scrollTop, 0);
- });
- });
-
- suite('reply dialog tests', () => {
- setup(() => {
- sinon.stub(element.$.replyDialog, '_draftChanged');
- element._change = generateChange({
- revisionsCount: 1,
- messagesCount: 1,
- });
- element._change.labels = {};
- sinon.stub(element.$.restAPI, 'getChangeDetail')
- .callsFake(() => Promise.resolve(generateChange({
- // element has latest info
- revisionsCount: 1,
- messagesCount: 1,
- })));
- });
-
- test('show reply dialog on open-reply-dialog event', done => {
- sinon.stub(element, '_openReplyDialog');
- element.dispatchEvent(
- new CustomEvent('open-reply-dialog', {
- composed: true,
- bubbles: true,
- detail: {},
- }));
- flush(() => {
- assert.isTrue(element._openReplyDialog.calledOnce);
- done();
- });
- });
-
- test('reply from comment adds quote text', () => {
- const e = {detail: {message: {message: 'quote text'}}};
- element._handleMessageReply(e);
- assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
- });
-
- test('reply from comment replaces quote text', () => {
- element.$.replyDialog.draft = '> old quote text\n\n some draft text';
- element.$.replyDialog.quote = '> old quote text\n\n';
- const e = {detail: {message: {message: 'quote text'}}};
- element._handleMessageReply(e);
- assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
- });
-
- test('reply from same comment preserves quote text', () => {
- element.$.replyDialog.draft = '> quote text\n\n some draft text';
- element.$.replyDialog.quote = '> quote text\n\n';
- const e = {detail: {message: {message: 'quote text'}}};
- element._handleMessageReply(e);
- assert.equal(element.$.replyDialog.draft,
- '> quote text\n\n some draft text');
- assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
- });
-
- test('reply from top of page contains previous draft', () => {
- const div = document.createElement('div');
- element.$.replyDialog.draft = '> quote text\n\n some draft text';
- element.$.replyDialog.quote = '> quote text\n\n';
- const e = {target: div, preventDefault: sinon.spy()};
- element._handleReplyTap(e);
- assert.equal(element.$.replyDialog.draft,
- '> quote text\n\n some draft text');
- assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
- });
- });
-
- test('reply button is disabled until server config is loaded', () => {
- assert.isTrue(element._replyDisabled);
- element._serverConfig = {};
- assert.isFalse(element._replyDisabled);
- });
-
- suite('commit message expand/collapse', () => {
- setup(() => {
- element._change = generateChange({
- revisionsCount: 1,
- messagesCount: 1,
- });
- element._change.labels = {};
- sinon.stub(element.$.restAPI, 'getChangeDetail')
- .callsFake(() => Promise.resolve(generateChange({
- // new patchset was uploaded
- revisionsCount: 2,
- messagesCount: 1,
- })));
- });
-
- test('commitCollapseToggle hidden for short commit message', () => {
- element._latestCommitMessage = '';
- assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
- });
-
- test('commitCollapseToggle shown for long commit message', () => {
- element._latestCommitMessage = _.times(31, String).join('\n');
- assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
- });
-
- test('commitCollapseToggle functions', () => {
- element._latestCommitMessage = _.times(35, String).join('\n');
- assert.isTrue(element._commitCollapsed);
- assert.isTrue(element._commitCollapsible);
- assert.isTrue(
- element.$.commitMessageEditor.hasAttribute('collapsed'));
- MockInteractions.tap(element.$.commitCollapseToggleButton);
- assert.isFalse(element._commitCollapsed);
- assert.isTrue(element._commitCollapsible);
- assert.isFalse(
- element.$.commitMessageEditor.hasAttribute('collapsed'));
- });
- });
-
- suite('related changes expand/collapse', () => {
- let updateHeightSpy;
- setup(() => {
- updateHeightSpy = sinon.spy(element, '_updateRelatedChangeMaxHeight');
- });
-
- test('relatedChangesToggle shown height greater than changeInfo height',
- () => {
- assert.isFalse(element.$.relatedChangesToggle.classList
- .contains('showToggle'));
- sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
- sinon.stub(element, '_getScrollHeight').callsFake(() => 60);
- sinon.stub(element, '_getLineHeight').callsFake(() => 5);
- sinon.stub(window, 'matchMedia')
- .callsFake(() => { return {matches: true}; });
- element.$.relatedChanges.dispatchEvent(
- new CustomEvent('new-section-loaded'));
- assert.isTrue(element.$.relatedChangesToggle.classList
- .contains('showToggle'));
- assert.equal(updateHeightSpy.callCount, 1);
- });
-
- test('relatedChangesToggle hidden height less than changeInfo height',
- () => {
- assert.isFalse(element.$.relatedChangesToggle.classList
- .contains('showToggle'));
- sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
- sinon.stub(element, '_getScrollHeight').callsFake(() => 40);
- sinon.stub(element, '_getLineHeight').callsFake(() => 5);
- sinon.stub(window, 'matchMedia')
- .callsFake(() => { return {matches: true}; });
- element.$.relatedChanges.dispatchEvent(
- new CustomEvent('new-section-loaded'));
- assert.isFalse(element.$.relatedChangesToggle.classList
- .contains('showToggle'));
- assert.equal(updateHeightSpy.callCount, 1);
- });
-
- test('relatedChangesToggle functions', () => {
- sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
- sinon.stub(window, 'matchMedia')
- .callsFake(() => { return {matches: false}; });
- element._relatedChangesLoading = false;
- assert.isTrue(element._relatedChangesCollapsed);
- assert.isTrue(
- element.$.relatedChanges.classList.contains('collapsed'));
- MockInteractions.tap(element.$.relatedChangesToggleButton);
- assert.isFalse(element._relatedChangesCollapsed);
- assert.isFalse(
- element.$.relatedChanges.classList.contains('collapsed'));
- });
-
- test('_updateRelatedChangeMaxHeight without commit toggle', () => {
- sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
- sinon.stub(element, '_getLineHeight').callsFake(() => 12);
- sinon.stub(window, 'matchMedia')
- .callsFake(() => { return {matches: false}; });
-
- // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
- // 20 (max existing height) % 12 (line height) = 6 (remainder).
- // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
-
- element._updateRelatedChangeMaxHeight();
- assert.equal(getCustomCssValue('--relation-chain-max-height'),
- '12px');
- assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
- '');
- });
-
- test('_updateRelatedChangeMaxHeight with commit toggle', () => {
- element._latestCommitMessage = _.times(31, String).join('\n');
- sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
- sinon.stub(element, '_getLineHeight').callsFake(() => 12);
- sinon.stub(window, 'matchMedia')
- .callsFake(() => { return {matches: false}; });
-
- // 50 (existing height) % 12 (line height) = 2 (remainder).
- // 50 (existing height) - 2 (remainder) = 48 (max height to set).
-
- element._updateRelatedChangeMaxHeight();
- assert.equal(getCustomCssValue('--relation-chain-max-height'),
- '48px');
- assert.equal(getCustomCssValue('--related-change-btn-top-padding'),
- '2px');
- });
-
- test('_updateRelatedChangeMaxHeight in small screen mode', () => {
- element._latestCommitMessage = _.times(31, String).join('\n');
- sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
- sinon.stub(element, '_getLineHeight').callsFake(() => 12);
- sinon.stub(window, 'matchMedia')
- .callsFake(() => { return {matches: true}; });
-
- element._updateRelatedChangeMaxHeight();
-
- // 400 (new height) % 12 (line height) = 4 (remainder).
- // 400 (new height) - 4 (remainder) = 396.
-
- assert.equal(getCustomCssValue('--relation-chain-max-height'),
- '396px');
- });
-
- test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
- element._latestCommitMessage = _.times(31, String).join('\n');
- sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
- sinon.stub(element, '_getLineHeight').callsFake(() => 12);
- sinon.stub(window, 'matchMedia').callsFake(() => {
- if (window.matchMedia.lastCall.args[0] === '(max-width: 75em)') {
- return {matches: true};
- } else {
- return {matches: false};
- }
- });
-
- // 100 (new height) % 12 (line height) = 4 (remainder).
- // 100 (new height) - 4 (remainder) = 96.
- element._updateRelatedChangeMaxHeight();
- assert.equal(getCustomCssValue('--relation-chain-max-height'),
- '96px');
- });
-
- suite('update checks', () => {
- setup(() => {
- sinon.spy(element, '_startUpdateCheckTimer');
- sinon.stub(element, 'async').callsFake( f => {
- // Only fire the async callback one time.
- if (element.async.callCount > 1) { return; }
- f.call(element);
- });
- element._change = generateChange({
- revisionsCount: 1,
- messagesCount: 1,
- });
- });
-
- test('_startUpdateCheckTimer negative delay', () => {
- const getChangeDetailStub =
- sinon.stub(element.$.restAPI, 'getChangeDetail')
- .callsFake(() => Promise.resolve(generateChange({
- // element has latest info
- revisionsCount: 1,
- messagesCount: 1,
- })));
-
- element._serverConfig = {change: {update_delay: -1}};
-
- assert.isTrue(element._startUpdateCheckTimer.called);
- assert.isFalse(getChangeDetailStub.called);
- });
-
- test('_startUpdateCheckTimer up-to-date', () => {
- const getChangeDetailStub =
- sinon.stub(element.$.restAPI, 'getChangeDetail')
- .callsFake(() => Promise.resolve(generateChange({
- // element has latest info
- revisionsCount: 1,
- messagesCount: 1,
- })));
-
- element._serverConfig = {change: {update_delay: 12345}};
-
- assert.isTrue(element._startUpdateCheckTimer.called);
- assert.isTrue(getChangeDetailStub.called);
- assert.equal(element.async.lastCall.args[1], 12345 * 1000);
- });
-
- test('_startUpdateCheckTimer out-of-date shows an alert', done => {
- sinon.stub(element.$.restAPI, 'getChangeDetail')
- .callsFake(() => Promise.resolve(generateChange({
- // new patchset was uploaded
- revisionsCount: 2,
- messagesCount: 1,
- })));
-
- element.addEventListener('show-alert', e => {
- assert.equal(e.detail.message,
- 'A newer patch set has been uploaded');
- done();
- });
- element._serverConfig = {change: {update_delay: 12345}};
- });
-
- test('_startUpdateCheckTimer new status shows an alert', done => {
- sinon.stub(element.$.restAPI, 'getChangeDetail')
- .callsFake(() => Promise.resolve(generateChange({
- // element has latest info
- revisionsCount: 1,
- messagesCount: 1,
- status: ChangeStatus.MERGED,
- })));
-
- element.addEventListener('show-alert', e => {
- assert.equal(e.detail.message, 'This change has been merged');
- done();
- });
- element._serverConfig = {change: {update_delay: 12345}};
- });
-
- test('_startUpdateCheckTimer new messages shows an alert', done => {
- sinon.stub(element.$.restAPI, 'getChangeDetail')
- .callsFake(() => Promise.resolve(generateChange({
- revisionsCount: 1,
- // element has new message
- messagesCount: 2,
- })));
- element.addEventListener('show-alert', e => {
- assert.equal(e.detail.message,
- 'There are new messages on this change');
- done();
- });
- element._serverConfig = {change: {update_delay: 12345}};
- });
- });
-
- test('canStartReview computation', () => {
- const change1 = {};
- const change2 = {
- actions: {
- ready: {
- enabled: true,
- },
- },
- };
- const change3 = {
- actions: {
- ready: {
- label: 'Ready for Review',
- },
- },
- };
- assert.isFalse(element._computeCanStartReview(change1));
- assert.isTrue(element._computeCanStartReview(change2));
- assert.isFalse(element._computeCanStartReview(change3));
- });
- });
-
- test('header class computation', () => {
- assert.equal(element._computeHeaderClass(), 'header');
- assert.equal(element._computeHeaderClass(true), 'header editMode');
- });
-
- test('_maybeScrollToMessage', done => {
- flush(() => {
- const scrollStub = sinon.stub(element.messagesList,
- 'scrollToMessage');
-
- element._maybeScrollToMessage('');
- assert.isFalse(scrollStub.called);
- element._maybeScrollToMessage('message');
- assert.isFalse(scrollStub.called);
- element._maybeScrollToMessage('#message-TEST');
- assert.isTrue(scrollStub.called);
- assert.equal(scrollStub.lastCall.args[0], 'TEST');
- done();
- });
- });
-
- test('topic update reloads related changes', () => {
- sinon.stub(element.$.relatedChanges, 'reload');
- element.dispatchEvent(new CustomEvent('topic-changed'));
- assert.isTrue(element.$.relatedChanges.reload.calledOnce);
- });
-
- test('_computeEditMode', () => {
- const callCompute = (range, params) =>
- element._computeEditMode({base: range}, {base: params});
- assert.isFalse(callCompute({}, {}));
- assert.isTrue(callCompute({}, {edit: true}));
- assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}, {}));
- assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}, {}));
- assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}, {}));
- });
-
- test('_processEdit', () => {
- element._patchRange = {};
- const change = {
- current_revision: 'foo',
- revisions: {foo: {commit: {}, actions: {cherrypick: {enabled: true}}}},
- };
- let mockChange;
-
- // With no edit, mockChange should be unmodified.
- element._processEdit(mockChange = _.cloneDeep(change), null);
- assert.deepEqual(mockChange, change);
-
- // When edit is not based on the latest PS, current_revision should be
- // unmodified.
- const edit = {
- base_patch_set_number: 1,
- commit: {commit: 'bar'},
- fetch: true,
- };
- element._processEdit(mockChange = _.cloneDeep(change), edit);
- assert.notDeepEqual(mockChange, change);
- assert.equal(mockChange.revisions.bar._number, SPECIAL_PATCH_SET_NUM.EDIT);
- assert.equal(mockChange.current_revision, change.current_revision);
- assert.deepEqual(mockChange.revisions.bar.commit, {commit: 'bar'});
- assert.notOk(mockChange.revisions.bar.actions);
-
- edit.base_revision = 'foo';
- element._processEdit(mockChange = _.cloneDeep(change), edit);
- assert.notDeepEqual(mockChange, change);
- assert.equal(mockChange.current_revision, 'bar');
- assert.deepEqual(mockChange.revisions.bar.actions,
- mockChange.revisions.foo.actions);
-
- // If _patchRange.patchNum is defined, do not load edit.
- element._patchRange.patchNum = 'baz';
- change.current_revision = 'baz';
- element._processEdit(mockChange = _.cloneDeep(change), edit);
- assert.equal(element._patchRange.patchNum, 'baz');
- assert.notOk(mockChange.revisions.bar.actions);
- });
-
- test('file-action-tap handling', () => {
- element._patchRange = {
- basePatchNum: 'PARENT',
- patchNum: 1,
- };
- const fileList = element.$.fileList;
- const Actions = GrEditConstants.Actions;
- element.$.fileListHeader.editMode = true;
- flushAsynchronousOperations();
- const controls = element.$.fileListHeader
- .shadowRoot.querySelector('#editControls');
- sinon.stub(controls, 'openDeleteDialog');
- sinon.stub(controls, 'openRenameDialog');
- sinon.stub(controls, 'openRestoreDialog');
- sinon.stub(GerritNav, 'getEditUrlForDiff');
- sinon.stub(GerritNav, 'navigateToRelativeUrl');
-
- // Delete
- fileList.dispatchEvent(new CustomEvent('file-action-tap', {
- detail: {action: Actions.DELETE.id, path: 'foo'},
- bubbles: true,
- composed: true,
- }));
- flushAsynchronousOperations();
-
- assert.isTrue(controls.openDeleteDialog.called);
- assert.equal(controls.openDeleteDialog.lastCall.args[0], 'foo');
-
- // Restore
- fileList.dispatchEvent(new CustomEvent('file-action-tap', {
- detail: {action: Actions.RESTORE.id, path: 'foo'},
- bubbles: true,
- composed: true,
- }));
- flushAsynchronousOperations();
-
- assert.isTrue(controls.openRestoreDialog.called);
- assert.equal(controls.openRestoreDialog.lastCall.args[0], 'foo');
-
- // Rename
- fileList.dispatchEvent(new CustomEvent('file-action-tap', {
- detail: {action: Actions.RENAME.id, path: 'foo'},
- bubbles: true,
- composed: true,
- }));
- flushAsynchronousOperations();
-
- assert.isTrue(controls.openRenameDialog.called);
- assert.equal(controls.openRenameDialog.lastCall.args[0], 'foo');
-
- // Open
- fileList.dispatchEvent(new CustomEvent('file-action-tap', {
- detail: {action: Actions.OPEN.id, path: 'foo'},
- bubbles: true,
- composed: true,
- }));
- flushAsynchronousOperations();
-
- assert.isTrue(GerritNav.getEditUrlForDiff.called);
- assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[1], 'foo');
- assert.equal(GerritNav.getEditUrlForDiff.lastCall.args[2], '1');
- assert.isTrue(GerritNav.navigateToRelativeUrl.called);
- });
-
- test('_selectedRevision updates when patchNum is changed', () => {
- const revision1 = {_number: 1, commit: {parents: []}};
- const revision2 = {_number: 2, commit: {parents: []}};
- sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
- Promise.resolve({
- revisions: {
- aaa: revision1,
- bbb: revision2,
- },
- labels: {},
- actions: {},
- current_revision: 'bbb',
- change_id: 'loremipsumdolorsitamet',
- }));
- sinon.stub(element, '_getEdit').returns(Promise.resolve());
- sinon.stub(element, '_getPreferences').returns(Promise.resolve({}));
- element._patchRange = {patchNum: '2'};
- return element._getChangeDetail().then(() => {
- assert.strictEqual(element._selectedRevision, revision2);
-
- element.set('_patchRange.patchNum', '1');
- assert.strictEqual(element._selectedRevision, revision1);
- });
- });
-
- test('_selectedRevision is assigned when patchNum is edit', () => {
- const revision1 = {_number: 1, commit: {parents: []}};
- const revision2 = {_number: 2, commit: {parents: []}};
- const revision3 = {_number: 'edit', commit: {parents: []}};
- sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
- Promise.resolve({
- revisions: {
- aaa: revision1,
- bbb: revision2,
- ccc: revision3,
- },
- labels: {},
- actions: {},
- current_revision: 'ccc',
- change_id: 'loremipsumdolorsitamet',
- }));
- sinon.stub(element, '_getEdit').returns(Promise.resolve());
- sinon.stub(element, '_getPreferences').returns(Promise.resolve({}));
- element._patchRange = {patchNum: 'edit'};
- return element._getChangeDetail().then(() => {
- assert.strictEqual(element._selectedRevision, revision3);
- });
- });
-
- test('_sendShowChangeEvent', () => {
- element._change = {labels: {}};
- element._patchRange = {patchNum: 4};
- element._mergeable = true;
- const showStub = sinon.stub(element.$.jsAPI, 'handleEvent');
- element._sendShowChangeEvent();
- assert.isTrue(showStub.calledOnce);
- assert.equal(
- showStub.lastCall.args[0], element.$.jsAPI.EventType.SHOW_CHANGE);
- assert.deepEqual(showStub.lastCall.args[1], {
- change: {labels: {}},
- patchNum: 4,
- info: {mergeable: true},
- });
- });
-
- suite('_handleEditTap', () => {
- let fireEdit;
-
- setup(() => {
- fireEdit = () => {
- element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
- };
- navigateToChangeStub.restore();
-
- element._change = {revisions: {rev1: {_number: 1}}};
- });
-
- test('edit exists in revisions', done => {
- sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
- assert.equal(args.length, 2);
- assert.equal(args[1], SPECIAL_PATCH_SET_NUM.EDIT); // patchNum
- done();
- });
-
- element.set('_change.revisions.rev2',
- {_number: SPECIAL_PATCH_SET_NUM.EDIT});
- flushAsynchronousOperations();
-
- fireEdit();
- });
-
- test('no edit exists in revisions, non-latest patchset', done => {
- sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
- assert.equal(args.length, 4);
- assert.equal(args[1], 1); // patchNum
- assert.equal(args[3], true); // opt_isEdit
- done();
- });
-
- element.set('_change.revisions.rev2', {_number: 2});
- element._patchRange = {patchNum: 1};
- flushAsynchronousOperations();
-
- fireEdit();
- });
-
- test('no edit exists in revisions, latest patchset', done => {
- sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
- assert.equal(args.length, 4);
- // No patch should be specified when patchNum == latest.
- assert.isNotOk(args[1]); // patchNum
- assert.equal(args[3], true); // opt_isEdit
- done();
- });
-
- element.set('_change.revisions.rev2', {_number: 2});
- element._patchRange = {patchNum: 2};
- flushAsynchronousOperations();
-
- fireEdit();
- });
- });
-
- test('_handleStopEditTap', done => {
- sinon.stub(element.$.metadata, '_computeLabelNames');
- navigateToChangeStub.restore();
- sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
- assert.equal(args.length, 2);
- assert.equal(args[1], 1); // patchNum
- done();
- });
-
- element._patchRange = {patchNum: 1};
- element.$.actions.dispatchEvent(new CustomEvent('stop-edit-tap',
- {bubbles: false}));
- });
-
- suite('plugin endpoints', () => {
- test('endpoint params', done => {
- element._change = {labels: {}};
- element._selectedRevision = {};
- let hookEl;
- let plugin;
- pluginApi.install(
- p => {
- plugin = p;
- plugin.hook('change-view-integration').getLastAttached()
- .then(
- el => hookEl = el);
- },
- '0.1',
- 'http://some/plugins/url.html');
- flush(() => {
- assert.strictEqual(hookEl.plugin, plugin);
- assert.strictEqual(hookEl.change, element._change);
- assert.strictEqual(hookEl.revision, element._selectedRevision);
- done();
- });
- });
- });
-
- suite('_getMergeability', () => {
- let getMergeableStub;
-
- setup(() => {
- element._change = {labels: {}};
- getMergeableStub = sinon.stub(element.$.restAPI, 'getMergeable')
- .returns(Promise.resolve({mergeable: true}));
- });
-
- test('merged change', () => {
- element._mergeable = null;
- element._change.status = ChangeStatus.MERGED;
- return element._getMergeability().then(() => {
- assert.isFalse(element._mergeable);
- assert.isFalse(getMergeableStub.called);
- });
- });
-
- test('abandoned change', () => {
- element._mergeable = null;
- element._change.status = ChangeStatus.ABANDONED;
- return element._getMergeability().then(() => {
- assert.isFalse(element._mergeable);
- assert.isFalse(getMergeableStub.called);
- });
- });
-
- test('open change', () => {
- element._mergeable = null;
- return element._getMergeability().then(() => {
- assert.isTrue(element._mergeable);
- assert.isTrue(getMergeableStub.called);
- });
- });
- });
-
- test('_paramsChanged sets in projectLookup', () => {
- sinon.stub(element.$.relatedChanges, 'reload');
- sinon.stub(element, '_reload').returns(Promise.resolve());
- const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
- element._paramsChanged({
- view: GerritNav.View.CHANGE,
- changeNum: 101,
- project: 'test-project',
- });
- assert.isTrue(setStub.calledOnce);
- assert.isTrue(setStub.calledWith(101, 'test-project'));
- });
-
- test('_handleToggleStar called when star is tapped', () => {
- element._change = {
- owner: {_account_id: 1},
- starred: false,
- };
- element._loggedIn = true;
- const stub = sinon.stub(element, '_handleToggleStar');
- flushAsynchronousOperations();
-
- MockInteractions.tap(element.$.changeStar.shadowRoot
- .querySelector('button'));
- assert.isTrue(stub.called);
- });
-
- suite('gr-reporting tests', () => {
- setup(() => {
- element._patchRange = {
- basePatchNum: 'PARENT',
- patchNum: 1,
- };
- sinon.stub(element, '_getChangeDetail').returns(Promise.resolve());
- sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
- sinon.stub(element, '_reloadComments').returns(Promise.resolve());
- sinon.stub(element, '_getMergeability').returns(Promise.resolve());
- sinon.stub(element, '_getLatestCommitMessage')
- .returns(Promise.resolve());
- });
-
- test('don\'t report changedDisplayed on reply', done => {
- const changeDisplayStub =
- sinon.stub(element.reporting, 'changeDisplayed');
- const changeFullyLoadedStub =
- sinon.stub(element.reporting, 'changeFullyLoaded');
- element._handleReplySent();
- flush(() => {
- assert.isFalse(changeDisplayStub.called);
- assert.isFalse(changeFullyLoadedStub.called);
- done();
- });
- });
-
- test('report changedDisplayed on _paramsChanged', done => {
- const changeDisplayStub =
- sinon.stub(element.reporting, 'changeDisplayed');
- const changeFullyLoadedStub =
- sinon.stub(element.reporting, 'changeFullyLoaded');
- element._paramsChanged({
- view: GerritNav.View.CHANGE,
- changeNum: 101,
- project: 'test-project',
- });
- flush(() => {
- assert.isTrue(changeDisplayStub.called);
- assert.isTrue(changeFullyLoadedStub.called);
- done();
- });
- });
- });
-});
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
new file mode 100644
index 0000000..8d06a03
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -0,0 +1,2947 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import '../../edit/gr-edit-constants';
+import './gr-change-view';
+import {
+ ChangeStatus,
+ CommentSide,
+ DefaultBase,
+ DiffViewMode,
+ HttpMethod,
+ PrimaryTab,
+ SecondaryTab,
+} from '../../../constants/constants';
+import {GrEditConstants} from '../../edit/gr-edit-constants';
+import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getComputedStyleValue} from '../../../utils/dom-util';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit';
+import {EventType, PluginApi} from '../../plugins/gr-plugin-types';
+
+import 'lodash/lodash';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util';
+import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+ createAppElementChangeViewParams,
+ createApproval,
+ createChange,
+ createChangeConfig,
+ createChangeMessages,
+ createCommit,
+ createMergeable,
+ createPreferences,
+ createRevision,
+ createRevisions,
+ createServerInfo,
+ createUserConfig,
+ TEST_NUMERIC_CHANGE_ID,
+ TEST_PROJECT_NAME,
+ getCurrentRevision,
+ createEditRevision,
+ createAccountWithIdNameAndEmail,
+} from '../../../test/test-data-generators';
+import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
+import {
+ AccountId,
+ ApprovalInfo,
+ ChangeId,
+ ChangeInfo,
+ CommitId,
+ CommitInfo,
+ EditInfo,
+ EditPatchSetNum,
+ ElementPropertyDeepChange,
+ GitRef,
+ NumericChangeId,
+ ParentPatchSetNum,
+ ParsedJSON,
+ PatchRange,
+ PatchSetNum,
+ RevisionInfo,
+ RobotId,
+ Timestamp,
+ UrlEncodedCommentId,
+} from '../../../types/common';
+import {
+ pressAndReleaseKeyOn,
+ tap,
+} from '@polymer/iron-test-helpers/mock-interactions';
+import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
+import {AppElementChangeViewParams} from '../../gr-app-types';
+import {
+ SinonFakeTimers,
+ SinonSpy,
+ SinonStubbedMember,
+} from 'sinon/pkg/sinon-esm';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {CustomKeyboardEvent} from '../../../types/events';
+import {
+ CommentThread,
+ DraftInfo,
+ UIDraft,
+ UIRobot,
+} from '../../../utils/comment-util';
+import 'lodash/lodash';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+const fixture = fixtureFromElement('gr-change-view');
+
+type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
+ Parameters<F>,
+ ReturnType<F>
+>;
+
+suite('gr-change-view tests', () => {
+ let element: GrChangeView;
+
+ let navigateToChangeStub: SinonStubbedMember<typeof GerritNav.navigateToChange>;
+
+ suiteSetup(() => {
+ const kb = TestKeyboardShortcutBinder.push();
+ kb.bindShortcut(Shortcut.SEND_REPLY, 'ctrl+enter');
+ kb.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r');
+ kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
+ kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
+ kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
+ kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
+ kb.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
+ kb.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
+ kb.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+ kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
+ kb.bindShortcut(Shortcut.EDIT_TOPIC, 't');
+ });
+
+ suiteTeardown(() => {
+ TestKeyboardShortcutBinder.pop();
+ });
+
+ const TEST_SCROLL_TOP_PX = 100;
+
+ const ROBOT_COMMENTS_LIMIT = 10;
+
+ // TODO: should have a mock service to generate VALID fake data
+ const THREADS: CommentThread[] = [
+ {
+ comments: [
+ {
+ __path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 2 as PatchSetNum,
+ robot_id: 'rb1' as RobotId,
+ id: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ },
+ {
+ __path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'ecf0b9fa_fe1a5f62_1' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ },
+ {
+ id: '503008e2_0ab203ee' as UrlEncodedCommentId,
+ path: '/COMMIT_MSG',
+ line: 5,
+ in_reply_to: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ updated: '2018-02-13 22:48:48.018000000' as Timestamp,
+ message: 'draft',
+ unresolved: false,
+ __draft: true,
+ __draftID: '0.m683trwff68',
+ __editing: false,
+ patch_set: 2 as PatchSetNum,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 5,
+ rootId: 'ecf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ },
+ {
+ comments: [
+ {
+ __path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 3 as PatchSetNum,
+ id: 'ecf0b9fa_fe5f62' as UrlEncodedCommentId,
+ robot_id: 'rb2' as RobotId,
+ line: 5,
+ updated: '2018-02-08 18:49:18.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ },
+ {
+ __path: 'test.txt',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 3 as PatchSetNum,
+ id: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+ side: CommentSide.PARENT,
+ updated: '2018-02-13 22:47:19.000000000' as Timestamp,
+ message: 'Some comment on another patchset.',
+ unresolved: false,
+ },
+ ],
+ patchNum: 3 as PatchSetNum,
+ path: 'test.txt',
+ rootId: '09a9fb0a_1484e6cf' as UrlEncodedCommentId,
+ commentSide: CommentSide.PARENT,
+ },
+ {
+ comments: [
+ {
+ __path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 2 as PatchSetNum,
+ id: '8caddf38_44770ec1' as UrlEncodedCommentId,
+ line: 4,
+ updated: '2018-02-13 22:48:40.000000000' as Timestamp,
+ message: 'Another unresolved comment',
+ unresolved: true,
+ },
+ ],
+ patchNum: 2 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 4,
+ rootId: '8caddf38_44770ec1' as UrlEncodedCommentId,
+ },
+ {
+ comments: [
+ {
+ __path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 2 as PatchSetNum,
+ id: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+ line: 4,
+ updated: '2018-02-14 22:48:40.000000000' as Timestamp,
+ message: 'Yet another unresolved comment',
+ unresolved: true,
+ },
+ ],
+ patchNum: 2 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 4,
+ rootId: 'scaddf38_44770ec1' as UrlEncodedCommentId,
+ },
+ {
+ comments: [
+ {
+ id: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ path: '/COMMIT_MSG',
+ line: 6,
+ updated: '2018-02-15 22:48:48.018000000' as Timestamp,
+ message: 'resolved draft',
+ unresolved: false,
+ __draft: true,
+ __draftID: '0.m683trwff68',
+ __editing: false,
+ patch_set: 2 as PatchSetNum,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 6,
+ rootId: 'zcf0b9fa_fe1a5f62' as UrlEncodedCommentId,
+ },
+ {
+ comments: [
+ {
+ __path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'rc1' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2019-02-08 18:49:18.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ robot_id: 'rc1' as RobotId,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 5,
+ rootId: 'rc1' as UrlEncodedCommentId,
+ },
+ {
+ comments: [
+ {
+ __path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'rc2' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2019-03-08 18:49:18.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ robot_id: 'rc2' as RobotId,
+ },
+ {
+ __path: '/COMMIT_MSG',
+ author: {
+ _account_id: 1000000 as AccountId,
+ name: 'user',
+ username: 'user',
+ },
+ patch_set: 4 as PatchSetNum,
+ id: 'c2_1' as UrlEncodedCommentId,
+ line: 5,
+ updated: '2019-03-08 18:49:18.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ },
+ ],
+ patchNum: 4 as PatchSetNum,
+ path: '/COMMIT_MSG',
+ line: 5,
+ rootId: 'rc2' as UrlEncodedCommentId,
+ },
+ ];
+
+ setup(() => {
+ // Since pluginEndpoints are global, must reset state.
+ _testOnly_resetEndpoints();
+ navigateToChangeStub = sinon.stub(GerritNav, 'navigateToChange');
+
+ function getCommentsStub() {
+ return Promise.resolve({});
+ }
+ stub('gr-rest-api-interface', {
+ getConfig() {
+ return Promise.resolve({
+ ...createServerInfo(),
+ user: {
+ ...createUserConfig(),
+ anonymous_coward_name: 'test coward name',
+ },
+ });
+ },
+ getAccount() {
+ return Promise.resolve(undefined);
+ },
+ getDiffComments: (getCommentsStub as unknown) as RestApiService['getDiffComments'],
+ getDiffRobotComments: (getCommentsStub as unknown) as RestApiService['getDiffRobotComments'],
+ getDiffDrafts: (getCommentsStub as unknown) as RestApiService['getDiffDrafts'],
+ _fetchSharedCacheURL() {
+ return Promise.resolve({} as ParsedJSON);
+ },
+ });
+ element = fixture.instantiate();
+ element._changeNum = 1 as NumericChangeId;
+ sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
+ getPluginLoader().loadPlugins([]);
+ pluginApi.install(
+ plugin => {
+ plugin.registerDynamicCustomComponent(
+ 'change-view-tab-header',
+ 'gr-checks-change-view-tab-header-view'
+ );
+ plugin.registerDynamicCustomComponent(
+ 'change-view-tab-content',
+ 'gr-checks-view'
+ );
+ },
+ '0.1',
+ 'http://some/plugins/url.html'
+ );
+ });
+
+ teardown(done => {
+ flush(() => {
+ done();
+ });
+ });
+
+ const getCustomCssValue = (cssParam: string) =>
+ getComputedStyleValue(cssParam, element);
+
+ test('_handleMessageAnchorTap', () => {
+ element._changeNum = 1 as NumericChangeId;
+ element._patchRange = {
+ basePatchNum: ParentPatchSetNum,
+ patchNum: 1 as PatchSetNum,
+ };
+ element._change = createChange();
+ const getUrlStub = sinon.stub(GerritNav, 'getUrlForChange');
+ const replaceStateStub = sinon.stub(history, 'replaceState');
+ element._handleMessageAnchorTap(
+ new CustomEvent('message-anchor-tap', {detail: {id: 'a12345'}})
+ );
+
+ assert.equal(getUrlStub.lastCall.args[4], '#message-a12345');
+ assert.isTrue(replaceStateStub.called);
+ });
+
+ test('_handleDiffAgainstBase', () => {
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(10),
+ };
+ element._patchRange = {
+ patchNum: 3 as PatchSetNum,
+ basePatchNum: 1 as PatchSetNum,
+ };
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ element._handleDiffAgainstBase(new CustomEvent('') as CustomKeyboardEvent);
+ assert(navigateToChangeStub.called);
+ const args = navigateToChangeStub.getCall(0).args;
+ assert.equal(args[0], element._change);
+ assert.equal(args[1], 3 as PatchSetNum);
+ });
+
+ test('_handleDiffAgainstLatest', () => {
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(10),
+ };
+ element._patchRange = {
+ basePatchNum: 1 as PatchSetNum,
+ patchNum: 3 as PatchSetNum,
+ };
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ element._handleDiffAgainstLatest(
+ new CustomEvent('') as CustomKeyboardEvent
+ );
+ assert(navigateToChangeStub.called);
+ const args = navigateToChangeStub.getCall(0).args;
+ assert.equal(args[0], element._change);
+ assert.equal(args[1], 10 as PatchSetNum);
+ assert.equal(args[2], 1 as PatchSetNum);
+ });
+
+ test('_handleDiffBaseAgainstLeft', () => {
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(10),
+ };
+ element._patchRange = {
+ patchNum: 3 as PatchSetNum,
+ basePatchNum: 1 as PatchSetNum,
+ };
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ element._handleDiffBaseAgainstLeft(
+ new CustomEvent('') as CustomKeyboardEvent
+ );
+ assert(navigateToChangeStub.called);
+ const args = navigateToChangeStub.getCall(0).args;
+ assert.equal(args[0], element._change);
+ assert.equal(args[1], 1 as PatchSetNum);
+ });
+
+ test('_handleDiffRightAgainstLatest', () => {
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(10),
+ };
+ element._patchRange = {
+ basePatchNum: 1 as PatchSetNum,
+ patchNum: 3 as PatchSetNum,
+ };
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ element._handleDiffRightAgainstLatest(
+ new CustomEvent('') as CustomKeyboardEvent
+ );
+ assert(navigateToChangeStub.called);
+ const args = navigateToChangeStub.getCall(0).args;
+ assert.equal(args[1], 10 as PatchSetNum);
+ assert.equal(args[2], 3 as PatchSetNum);
+ });
+
+ test('_handleDiffBaseAgainstLatest', () => {
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(10),
+ };
+ element._patchRange = {
+ basePatchNum: 1 as PatchSetNum,
+ patchNum: 3 as PatchSetNum,
+ };
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ element._handleDiffBaseAgainstLatest(
+ new CustomEvent('') as CustomKeyboardEvent
+ );
+ assert(navigateToChangeStub.called);
+ const args = navigateToChangeStub.getCall(0).args;
+ assert.equal(args[1], 10 as PatchSetNum);
+ assert.isNotOk(args[2]);
+ });
+
+ suite('plugins adding to file tab', () => {
+ setup(done => {
+ element._changeNum = 1 as NumericChangeId;
+ // Resolving it here instead of during setup() as other tests depend
+ // on flush() not being called during setup.
+ flush(() => done());
+ });
+
+ test('plugin added tab shows up as a dynamic endpoint', () => {
+ assert(
+ element._dynamicTabHeaderEndpoints.includes(
+ 'change-view-tab-header-url'
+ )
+ );
+ const primaryTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+ const paperTabs = primaryTabs.querySelectorAll<HTMLElement>('paper-tab');
+ // 4 Tabs are : Files, Comment Threads, Plugin, Findings
+ assert.equal(primaryTabs.querySelectorAll('paper-tab').length, 4);
+ assert.equal(paperTabs[2].dataset.name, 'change-view-tab-header-url');
+ });
+
+ test('_setActivePrimaryTab switched tab correctly', done => {
+ element._setActivePrimaryTab(
+ new CustomEvent('', {
+ detail: {tab: 'change-view-tab-header-url'},
+ })
+ );
+ flush(() => {
+ assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+ done();
+ });
+ });
+
+ test('show-primary-tab switched primary tab correctly', done => {
+ element.dispatchEvent(
+ new CustomEvent('show-primary-tab', {
+ composed: true,
+ bubbles: true,
+ detail: {
+ tab: 'change-view-tab-header-url',
+ },
+ })
+ );
+ flush(() => {
+ assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
+ done();
+ });
+ });
+
+ test('param change should switch primary tab correctly', done => {
+ assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+ const queryMap = new Map<string, string>();
+ queryMap.set('tab', PrimaryTab.FINDINGS);
+ // view is required
+ element.params = {
+ ...createAppElementChangeViewParams(),
+ ...element.params,
+ queryMap,
+ };
+ flush(() => {
+ assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
+ done();
+ });
+ });
+
+ test('invalid param change should not switch primary tab', done => {
+ assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+ const queryMap = new Map<string, string>();
+ queryMap.set('tab', 'random');
+ // view is required
+ element.params = {
+ ...createAppElementChangeViewParams(),
+ ...element.params,
+ queryMap,
+ };
+ flush(() => {
+ assert.equal(element._activeTabs[0], PrimaryTab.FILES);
+ done();
+ });
+ });
+
+ test('switching tab sets _selectedTabPluginEndpoint', done => {
+ const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+ tap(paperTabs.querySelectorAll('paper-tab')[2]);
+ flush(() => {
+ assert.equal(
+ element._selectedTabPluginEndpoint,
+ 'change-view-tab-content-url'
+ );
+ done();
+ });
+ });
+ });
+
+ suite('keyboard shortcuts', () => {
+ let clock: SinonFakeTimers;
+ setup(() => {
+ clock = sinon.useFakeTimers();
+ });
+
+ teardown(() => {
+ clock.restore();
+ sinon.restore();
+ });
+
+ test('t to add topic', () => {
+ const editStub = sinon.stub(element.$.metadata, 'editTopic');
+ pressAndReleaseKeyOn(element, 83, null, 't');
+ assert(editStub.called);
+ });
+
+ test('S should toggle the CL star', () => {
+ const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
+ pressAndReleaseKeyOn(element, 83, null, 's');
+ assert(starStub.called);
+ });
+
+ test('toggle star is throttled', () => {
+ const starStub = sinon.stub(element.$.changeStar, 'toggleStar');
+ pressAndReleaseKeyOn(element, 83, null, 's');
+ assert(starStub.called);
+ pressAndReleaseKeyOn(element, 83, null, 's');
+ assert.equal(starStub.callCount, 1);
+ clock.tick(1000);
+ pressAndReleaseKeyOn(element, 83, null, 's');
+ assert.equal(starStub.callCount, 2);
+ });
+
+ test('U should navigate to root if no backPage set', () => {
+ const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+ pressAndReleaseKeyOn(element, 85, null, 'u');
+ assert.isTrue(relativeNavStub.called);
+ assert.isTrue(
+ relativeNavStub.lastCall.calledWithExactly(GerritNav.getUrlForRoot())
+ );
+ });
+
+ test('U should navigate to backPage if set', () => {
+ const relativeNavStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+ element.backPage = '/dashboard/self';
+ pressAndReleaseKeyOn(element, 85, null, 'u');
+ assert.isTrue(relativeNavStub.called);
+ assert.isTrue(
+ relativeNavStub.lastCall.calledWithExactly('/dashboard/self')
+ );
+ });
+
+ test('A fires an error event when not logged in', done => {
+ sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+ const loggedInErrorSpy = sinon.spy();
+ element.addEventListener('show-auth-required', loggedInErrorSpy);
+ pressAndReleaseKeyOn(element, 65, null, 'a');
+ flush(() => {
+ assert.isFalse(element.$.replyOverlay.opened);
+ assert.isTrue(loggedInErrorSpy.called);
+ done();
+ });
+ });
+
+ test('shift A does not open reply overlay', done => {
+ sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+ pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+ flush(() => {
+ assert.isFalse(element.$.replyOverlay.opened);
+ done();
+ });
+ });
+
+ test('A toggles overlay when logged in', done => {
+ sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(1),
+ messages: createChangeMessages(1),
+ };
+ element._change.labels = {};
+ sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ // element has latest info
+ revisions: createRevisions(1),
+ messages: createChangeMessages(1),
+ current_revision: 'rev1' as CommitId,
+ })
+ );
+
+ const openSpy = sinon.spy(element, '_openReplyDialog');
+
+ pressAndReleaseKeyOn(element, 65, null, 'a');
+ flush(() => {
+ assert.isTrue(element.$.replyOverlay.opened);
+ element.$.replyOverlay.close();
+ assert.isFalse(element.$.replyOverlay.opened);
+ assert(
+ openSpy.lastCall.calledWithExactly(
+ element.$.replyDialog.FocusTarget.ANY
+ ),
+ '_openReplyDialog should have been passed ANY'
+ );
+ assert.equal(openSpy.callCount, 1);
+ done();
+ });
+ });
+
+ test('fullscreen-overlay-opened hides content', () => {
+ element._loggedIn = true;
+ element._loading = false;
+ element._change = {
+ ...createChange(),
+ labels: {},
+ actions: {
+ abandon: {
+ enabled: true,
+ label: 'Abandon',
+ method: HttpMethod.POST,
+ title: 'Abandon',
+ },
+ },
+ };
+ const handlerSpy = sinon.spy(element, '_handleHideBackgroundContent');
+ element.$.replyDialog.dispatchEvent(
+ new CustomEvent('fullscreen-overlay-opened', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ assert.isTrue(handlerSpy.called);
+ assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+ assert.equal(getComputedStyle(element.$.actions).display, 'flex');
+ });
+
+ test('fullscreen-overlay-closed shows content', () => {
+ element._loggedIn = true;
+ element._loading = false;
+ element._change = {
+ ...createChange(),
+ labels: {},
+ actions: {
+ abandon: {
+ enabled: true,
+ label: 'Abandon',
+ method: HttpMethod.POST,
+ title: 'Abandon',
+ },
+ },
+ };
+ const handlerSpy = sinon.spy(element, '_handleShowBackgroundContent');
+ element.$.replyDialog.dispatchEvent(
+ new CustomEvent('fullscreen-overlay-closed', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ assert.isTrue(handlerSpy.called);
+ assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+ });
+
+ test('expand all messages when expand-diffs fired', () => {
+ const handleExpand = sinon.stub(element.$.fileList, 'expandAllDiffs');
+ element.$.fileListHeader.dispatchEvent(
+ new CustomEvent('expand-diffs', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ assert.isTrue(handleExpand.called);
+ });
+
+ test('collapse all messages when collapse-diffs fired', () => {
+ const handleCollapse = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+ element.$.fileListHeader.dispatchEvent(
+ new CustomEvent('collapse-diffs', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ assert.isTrue(handleCollapse.called);
+ });
+
+ test('X should expand all messages', done => {
+ flush(() => {
+ const handleExpand = sinon.stub(
+ element.messagesList!,
+ 'handleExpandCollapse'
+ );
+ pressAndReleaseKeyOn(element, 88, null, 'x');
+ assert(handleExpand.calledWith(true));
+ done();
+ });
+ });
+
+ test('Z should collapse all messages', done => {
+ flush(() => {
+ const handleExpand = sinon.stub(
+ element.messagesList!,
+ 'handleExpandCollapse'
+ );
+ pressAndReleaseKeyOn(element, 90, null, 'z');
+ assert(handleExpand.calledWith(false));
+ done();
+ });
+ });
+
+ test('reload event from reply dialog is processed', () => {
+ const handleReloadStub = sinon.stub(element, '_reload');
+ element.$.replyDialog.dispatchEvent(
+ new CustomEvent('reload', {
+ detail: {clearPatchset: true},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ assert.isTrue(handleReloadStub.called);
+ });
+
+ test('shift + R should fetch and navigate to the latest patch set', done => {
+ element._changeNum = TEST_NUMERIC_CHANGE_ID;
+ element._patchRange = {
+ basePatchNum: ParentPatchSetNum,
+ patchNum: 1 as PatchSetNum,
+ };
+ element._change = {
+ ...createChange(),
+ revisions: {
+ rev1: createRevision(),
+ },
+ current_revision: 'rev1' as CommitId,
+ status: ChangeStatus.NEW,
+ labels: {},
+ actions: {},
+ };
+
+ const reloadChangeStub = sinon.stub(element, '_reload');
+ pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+ flush(() => {
+ assert.isTrue(reloadChangeStub.called);
+ done();
+ });
+ });
+
+ test('d should open download overlay', () => {
+ const stub = sinon
+ .stub(element.$.downloadOverlay, 'open')
+ .returns(Promise.resolve());
+ pressAndReleaseKeyOn(element, 68, null, 'd');
+ assert.isTrue(stub.called);
+ });
+
+ test(', should open diff preferences', () => {
+ const stub = sinon.stub(
+ element.$.fileList.$.diffPreferencesDialog,
+ 'open'
+ );
+ element._loggedIn = false;
+ element.disableDiffPrefs = true;
+ pressAndReleaseKeyOn(element, 188, null, ',');
+ assert.isFalse(stub.called);
+
+ element._loggedIn = true;
+ pressAndReleaseKeyOn(element, 188, null, ',');
+ assert.isFalse(stub.called);
+
+ element.disableDiffPrefs = false;
+ pressAndReleaseKeyOn(element, 188, null, ',');
+ assert.isTrue(stub.called);
+ });
+
+ test('m should toggle diff mode', () => {
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ const setModeStub = sinon.stub(
+ element.$.fileListHeader,
+ 'setDiffViewMode'
+ );
+ const e = {preventDefault: () => {}} as CustomKeyboardEvent;
+ flush();
+
+ element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
+ element._handleToggleDiffMode(e);
+ assert.isTrue(setModeStub.calledWith(DiffViewMode.UNIFIED));
+
+ element.viewState.diffMode = DiffViewMode.UNIFIED;
+ element._handleToggleDiffMode(e);
+ assert.isTrue(setModeStub.calledWith(DiffViewMode.SIDE_BY_SIDE));
+ });
+ });
+
+ suite('reloading drafts', () => {
+ let reloadStub: SinonStubbedMember<typeof element.$.commentAPI.reloadDrafts>;
+ const drafts: {[path: string]: UIDraft[]} = {
+ 'testfile.txt': [
+ {
+ patch_set: 5 as PatchSetNum,
+ id: 'dd2982f5_c01c9e6a' as UrlEncodedCommentId,
+ line: 1,
+ updated: '2017-11-08 18:47:45.000000000' as Timestamp,
+ message: 'test',
+ unresolved: true,
+ },
+ ],
+ };
+ setup(() => {
+ // Fake computeDraftCount as its required for ChangeComments,
+ // see gr-comment-api#reloadDrafts.
+ reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
+ Promise.resolve({
+ drafts,
+ getAllThreadsForChange: () => [] as CommentThread[],
+ computeDraftCount: () => 1,
+ } as ChangeComments)
+ );
+ element._changeNum = 1 as NumericChangeId;
+ });
+
+ test('drafts are reloaded when reload-drafts fired', done => {
+ element.$.fileList.dispatchEvent(
+ new CustomEvent('reload-drafts', {
+ detail: {
+ resolve: () => {
+ assert.isTrue(reloadStub.called);
+ assert.deepEqual(element._diffDrafts, drafts);
+ done();
+ },
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+
+ test('drafts are reloaded when comment-refresh fired', () => {
+ element.dispatchEvent(
+ new CustomEvent('comment-refresh', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ assert.isTrue(reloadStub.called);
+ });
+ });
+
+ suite('_recomputeComments', () => {
+ setup(() => {
+ element._changeNum = TEST_NUMERIC_CHANGE_ID;
+ element._change = createChange();
+ flush();
+ // Fake computeDraftCount as its required for ChangeComments,
+ // see gr-comment-api#reloadDrafts.
+ sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
+ Promise.resolve({
+ drafts: {},
+ getAllThreadsForChange: () => THREADS,
+ computeDraftCount: () => 0,
+ } as ChangeComments)
+ );
+ element._change = createChange();
+ element._changeNum = element._change._number;
+ });
+
+ test('draft threads should be a new copy with correct states', done => {
+ element.$.fileList.dispatchEvent(
+ new CustomEvent('reload-drafts', {
+ detail: {
+ resolve: () => {
+ assert.equal(element._draftCommentThreads!.length, 2);
+ assert.equal(
+ element._draftCommentThreads![0].rootId,
+ THREADS[0].rootId
+ );
+ assert.notEqual(
+ element._draftCommentThreads![0].comments,
+ THREADS[0].comments
+ );
+ assert.notEqual(
+ element._draftCommentThreads![0].comments[0],
+ THREADS[0].comments[0]
+ );
+ assert.isTrue(
+ element
+ ._draftCommentThreads![0].comments.slice(0, 2)
+ .every(c => c.collapsed === true)
+ );
+
+ assert.isTrue(
+ element._draftCommentThreads![0].comments[2].collapsed === false
+ );
+ done();
+ },
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ });
+
+ test('diff comments modified', () => {
+ const reloadThreadsSpy = sinon.spy(element, '_handleReloadCommentThreads');
+ return element._reloadComments().then(() => {
+ element.dispatchEvent(
+ new CustomEvent('diff-comments-modified', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ assert.isTrue(reloadThreadsSpy.called);
+ });
+ });
+
+ test('thread list modified', () => {
+ const reloadDiffSpy = sinon.spy(element, '_handleReloadDiffComments');
+ element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
+ flush();
+
+ return element._reloadComments().then(() => {
+ element.threadList!.dispatchEvent(
+ new CustomEvent('thread-list-modified', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ assert.isTrue(reloadDiffSpy.called);
+
+ let draftStub = sinon
+ .stub(element._changeComments!, 'computeDraftCount')
+ .returns(1);
+ assert.equal(
+ element._computeTotalCommentCounts(5, element._changeComments!),
+ '5 unresolved, 1 draft'
+ );
+ assert.equal(
+ element._computeTotalCommentCounts(0, element._changeComments!),
+ '1 draft'
+ );
+ draftStub.restore();
+ draftStub = sinon
+ .stub(element._changeComments!, 'computeDraftCount')
+ .returns(0);
+ assert.equal(
+ element._computeTotalCommentCounts(0, element._changeComments!),
+ ''
+ );
+ assert.equal(
+ element._computeTotalCommentCounts(1, element._changeComments!),
+ '1 unresolved'
+ );
+ draftStub.restore();
+ draftStub = sinon
+ .stub(element._changeComments!, 'computeDraftCount')
+ .returns(2);
+ assert.equal(
+ element._computeTotalCommentCounts(1, element._changeComments!),
+ '1 unresolved, 2 drafts'
+ );
+ draftStub.restore();
+ });
+ });
+
+ suite('thread list and change log tabs', () => {
+ setup(() => {
+ element._changeNum = TEST_NUMERIC_CHANGE_ID;
+ element._patchRange = {
+ basePatchNum: ParentPatchSetNum,
+ patchNum: 1 as PatchSetNum,
+ };
+ element._change = {
+ ...createChange(),
+ revisions: {
+ rev2: createRevision(2),
+ rev1: createRevision(1),
+ rev13: createRevision(13),
+ rev3: createRevision(3),
+ },
+ current_revision: 'rev3' as CommitId,
+ status: ChangeStatus.NEW,
+ labels: {
+ test: {
+ all: [],
+ default_value: 0,
+ values: {},
+ approved: {},
+ },
+ },
+ };
+ sinon.stub(element.$.relatedChanges, 'reload');
+ sinon.stub(element, '_reload').returns(Promise.resolve([]));
+ sinon.spy(element, '_paramsChanged');
+ element.params = createAppElementChangeViewParams();
+ });
+ });
+
+ suite('Findings comment tab', () => {
+ setup(done => {
+ element._changeNum = TEST_NUMERIC_CHANGE_ID;
+ element._change = {
+ ...createChange(),
+ revisions: {
+ rev2: createRevision(2),
+ rev1: createRevision(1),
+ rev13: createRevision(13),
+ rev3: createRevision(3),
+ rev4: createRevision(4),
+ },
+ current_revision: 'rev4' as CommitId,
+ };
+ element._commentThreads = THREADS;
+ const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
+ tap(paperTabs.querySelectorAll('paper-tab')[3]);
+ flush(() => {
+ done();
+ });
+ });
+
+ test('robot comments count per patchset', () => {
+ const count = element._robotCommentCountPerPatchSet(THREADS);
+ const expectedCount = {
+ 2: 1,
+ 3: 1,
+ 4: 2,
+ };
+ assert.deepEqual(count, expectedCount);
+ assert.equal(
+ element._computeText(createRevision(2), THREADS),
+ 'Patchset 2 (1 finding)'
+ );
+ assert.equal(
+ element._computeText(createRevision(4), THREADS),
+ 'Patchset 4 (2 findings)'
+ );
+ assert.equal(
+ element._computeText(createRevision(5), THREADS),
+ 'Patchset 5'
+ );
+ });
+
+ test('only robot comments are rendered', () => {
+ assert.equal(element._robotCommentThreads!.length, 2);
+ assert.equal(
+ (element._robotCommentThreads![0].comments[0] as UIRobot).robot_id,
+ 'rc1'
+ );
+ assert.equal(
+ (element._robotCommentThreads![1].comments[0] as UIRobot).robot_id,
+ 'rc2'
+ );
+ });
+
+ test('changing patchsets resets robot comments', done => {
+ element.set('_change.current_revision', 'rev3');
+ flush(() => {
+ assert.equal(element._robotCommentThreads!.length, 1);
+ done();
+ });
+ });
+
+ test('Show more button is hidden', () => {
+ assert.isNull(element.shadowRoot!.querySelector('.show-robot-comments'));
+ });
+
+ suite('robot comments show more button', () => {
+ setup(done => {
+ const arr = [];
+ for (let i = 0; i <= 30; i++) {
+ arr.push(...THREADS);
+ }
+ element._commentThreads = arr;
+ flush(() => {
+ done();
+ });
+ });
+
+ test('Show more button is rendered', () => {
+ assert.isOk(element.shadowRoot!.querySelector('.show-robot-comments'));
+ assert.equal(
+ element._robotCommentThreads!.length,
+ ROBOT_COMMENTS_LIMIT
+ );
+ });
+
+ test('Clicking show more button renders all comments', done => {
+ tap(element.shadowRoot!.querySelector('.show-robot-comments')!);
+ flush(() => {
+ assert.equal(element._robotCommentThreads!.length, 62);
+ done();
+ });
+ });
+ });
+ });
+
+ test('reply button is not visible when logged out', () => {
+ assert.equal(getComputedStyle(element.$.replyBtn).display, 'none');
+ element._loggedIn = true;
+ assert.notEqual(getComputedStyle(element.$.replyBtn).display, 'none');
+ });
+
+ test('download tap calls _handleOpenDownloadDialog', () => {
+ const openDialogStub = sinon.stub(element, '_handleOpenDownloadDialog');
+ element.$.actions.dispatchEvent(
+ new CustomEvent('download-tap', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ assert.isTrue(openDialogStub.called);
+ });
+
+ test('fetches the server config on attached', done => {
+ flush(() => {
+ assert.equal(
+ element._serverConfig!.user.anonymous_coward_name,
+ 'test coward name'
+ );
+ done();
+ });
+ });
+
+ test('_changeStatuses', () => {
+ element._loading = false;
+ element._change = {
+ ...createChange(),
+ revisions: {
+ rev2: createRevision(2),
+ rev1: createRevision(1),
+ rev13: createRevision(13),
+ rev3: createRevision(3),
+ },
+ current_revision: 'rev3' as CommitId,
+ status: ChangeStatus.MERGED,
+ work_in_progress: true,
+ labels: {
+ test: {
+ all: [],
+ default_value: 0,
+ values: {},
+ approved: {},
+ },
+ },
+ };
+ element._mergeable = true;
+ const expectedStatuses = ['Merged', 'WIP'];
+ assert.deepEqual(element._changeStatuses, expectedStatuses);
+ assert.equal(element._changeStatus, expectedStatuses.join(', '));
+ flush();
+ const statusChips = element.shadowRoot!.querySelectorAll(
+ 'gr-change-status'
+ );
+ assert.equal(statusChips.length, 2);
+ });
+
+ test('diff preferences open when open-diff-prefs is fired', () => {
+ const overlayOpenStub = sinon.stub(element.$.fileList, 'openDiffPrefs');
+ element.$.fileListHeader.dispatchEvent(
+ new CustomEvent('open-diff-prefs', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ assert.isTrue(overlayOpenStub.called);
+ });
+
+ test('_prepareCommitMsgForLinkify', () => {
+ let commitMessage = 'R=test@google.com';
+ let result = element._prepareCommitMsgForLinkify(commitMessage);
+ assert.equal(result, 'R=\u200Btest@google.com');
+
+ commitMessage = 'R=test@google.com\nR=test@google.com';
+ result = element._prepareCommitMsgForLinkify(commitMessage);
+ assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
+
+ commitMessage = 'CC=test@google.com';
+ result = element._prepareCommitMsgForLinkify(commitMessage);
+ assert.equal(result, 'CC=\u200Btest@google.com');
+ });
+
+ test('_isSubmitEnabled', () => {
+ assert.isFalse(element._isSubmitEnabled({}));
+ assert.isFalse(element._isSubmitEnabled({submit: {}}));
+ assert.isTrue(element._isSubmitEnabled({submit: {enabled: true}}));
+ });
+
+ test('_reload is called when an approved label is removed', () => {
+ const vote: ApprovalInfo = {
+ ...createApproval(),
+ _account_id: 1 as AccountId,
+ name: 'bojack',
+ value: 1,
+ };
+ element._changeNum = TEST_NUMERIC_CHANGE_ID;
+ element._patchRange = {
+ basePatchNum: ParentPatchSetNum,
+ patchNum: 1 as PatchSetNum,
+ };
+ const change = {
+ ...createChange(),
+ owner: createAccountWithIdNameAndEmail(),
+ revisions: {
+ rev2: createRevision(2),
+ rev1: createRevision(1),
+ rev13: createRevision(13),
+ rev3: createRevision(3),
+ },
+ current_revision: 'rev3' as CommitId,
+ status: ChangeStatus.NEW,
+ labels: {
+ test: {
+ all: [vote],
+ default_value: 0,
+ values: {},
+ approved: {},
+ },
+ },
+ };
+ element._change = change;
+ flush();
+ const reloadStub = sinon.stub(element, '_reload');
+ element.splice('_change.labels.test.all', 0, 1);
+ assert.isFalse(reloadStub.called);
+ change.labels.test.all.push(vote);
+ change.labels.test.all.push(vote);
+ change.labels.test.approved = vote;
+ flush();
+ element.splice('_change.labels.test.all', 0, 2);
+ assert.isTrue(reloadStub.called);
+ assert.isTrue(reloadStub.calledOnce);
+ });
+
+ test('reply button has updated count when there are drafts', () => {
+ const getLabel = element._computeReplyButtonLabel;
+
+ assert.equal(getLabel(null, false), 'Reply');
+ assert.equal(getLabel(null, true), 'Start Review');
+
+ const changeRecord: ElementPropertyDeepChange<
+ GrChangeView,
+ '_diffDrafts'
+ > = {base: undefined, path: '', value: undefined};
+ assert.equal(getLabel(changeRecord, false), 'Reply');
+
+ changeRecord.base = {};
+ assert.equal(getLabel(changeRecord, false), 'Reply');
+
+ changeRecord.base = {
+ 'file1.txt': [{}],
+ 'file2.txt': [{}, {}],
+ };
+ assert.equal(getLabel(changeRecord, false), 'Reply (3)');
+ assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
+ });
+
+ test('comment events properly update diff drafts', () => {
+ element._patchRange = {
+ basePatchNum: ParentPatchSetNum,
+ patchNum: 2 as PatchSetNum,
+ };
+ const draft: DraftInfo = {
+ __draft: true,
+ id: 'id1' as UrlEncodedCommentId,
+ path: '/foo/bar.txt',
+ message: 'hello',
+ };
+ element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
+ draft.patch_set = 2 as PatchSetNum;
+ assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+ draft.patch_set = undefined;
+ draft.message = 'hello, there';
+ element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
+ draft.patch_set = 2 as PatchSetNum;
+ assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
+ const draft2: DraftInfo = {
+ __draft: true,
+ id: 'id2' as UrlEncodedCommentId,
+ path: '/foo/bar.txt',
+ message: 'hola',
+ };
+ element._handleCommentSave(
+ new CustomEvent('', {detail: {comment: draft2}})
+ );
+ draft2.patch_set = 2 as PatchSetNum;
+ assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
+ draft.patch_set = undefined;
+ element._handleCommentDiscard(
+ new CustomEvent('', {detail: {comment: draft}})
+ );
+ draft.patch_set = 2 as PatchSetNum;
+ assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
+ element._handleCommentDiscard(
+ new CustomEvent('', {detail: {comment: draft2}})
+ );
+ assert.deepEqual(element._diffDrafts, {});
+ });
+
+ test('change num change', () => {
+ element._changeNum = undefined;
+ element._patchRange = {
+ basePatchNum: ParentPatchSetNum,
+ patchNum: 2 as PatchSetNum,
+ };
+ element._change = {
+ ...createChange(),
+ labels: {},
+ };
+ element.viewState.changeNum = null;
+ element.viewState.diffMode = DiffViewMode.UNIFIED;
+ assert.equal(element.viewState.numFilesShown, 200);
+ assert.equal(element._numFilesShown, 200);
+ element._numFilesShown = 150;
+ flush();
+ assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+ assert.equal(element.viewState.numFilesShown, 150);
+
+ element._changeNum = 1 as NumericChangeId;
+ element.params = {
+ ...createAppElementChangeViewParams(),
+ changeNum: 1 as NumericChangeId,
+ };
+ flush();
+ assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+ assert.equal(element.viewState.changeNum, 1);
+
+ element._changeNum = 2 as NumericChangeId;
+ element.params = {
+ ...createAppElementChangeViewParams(),
+ changeNum: 2 as NumericChangeId,
+ };
+ flush();
+ assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+ assert.equal(element.viewState.changeNum, 2);
+ assert.equal(element.viewState.numFilesShown, 200);
+ assert.equal(element._numFilesShown, 200);
+ });
+
+ test('_setDiffViewMode is called with reset when new change is loaded', () => {
+ const setDiffViewModeStub = sinon.stub(element, '_setDiffViewMode');
+ element.viewState = {changeNum: 1 as NumericChangeId};
+ element._changeNum = 2 as NumericChangeId;
+ element._resetFileListViewState();
+ assert.isTrue(setDiffViewModeStub.calledWithExactly(true));
+ });
+
+ test('diffViewMode is propagated from file list header', () => {
+ element.viewState = {diffMode: DiffViewMode.UNIFIED};
+ element.$.fileListHeader.diffViewMode = DiffViewMode.SIDE_BY_SIDE;
+ assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
+ });
+
+ test('diffMode defaults to side by side without preferences', done => {
+ sinon
+ .stub(element.$.restAPI, 'getPreferences')
+ .returns(Promise.resolve(createPreferences()));
+ // No user prefs or diff view mode set.
+
+ element._setDiffViewMode()!.then(() => {
+ assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
+ done();
+ });
+ });
+
+ test('diffMode defaults to preference when not already set', done => {
+ sinon.stub(element.$.restAPI, 'getPreferences').returns(
+ Promise.resolve({
+ ...createPreferences(),
+ default_diff_view: DiffViewMode.UNIFIED,
+ })
+ );
+
+ element._setDiffViewMode()!.then(() => {
+ assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
+ done();
+ });
+ });
+
+ test('existing diffMode overrides preference', done => {
+ element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
+ sinon.stub(element.$.restAPI, 'getPreferences').returns(
+ Promise.resolve({
+ ...createPreferences(),
+ default_diff_view: DiffViewMode.UNIFIED,
+ })
+ );
+ element._setDiffViewMode()!.then(() => {
+ assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
+ done();
+ });
+ });
+
+ test('don’t reload entire page when patchRange changes', () => {
+ const reloadStub = sinon
+ .stub(element, '_reload')
+ .callsFake(() => Promise.resolve([]));
+ const reloadPatchDependentStub = sinon
+ .stub(element, '_reloadPatchNumDependentResources')
+ .callsFake(() => Promise.resolve([undefined, undefined]));
+ const relatedClearSpy = sinon.spy(element.$.relatedChanges, 'clear');
+ const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+
+ const value: AppElementChangeViewParams = {
+ ...createAppElementChangeViewParams(),
+ view: GerritView.CHANGE,
+ patchNum: 1 as PatchSetNum,
+ };
+ element._paramsChanged(value);
+ assert.isTrue(reloadStub.calledOnce);
+ assert.isTrue(relatedClearSpy.calledOnce);
+
+ element._initialLoadComplete = true;
+
+ value.basePatchNum = 1 as PatchSetNum;
+ value.patchNum = 2 as PatchSetNum;
+ element._paramsChanged(value);
+ assert.isFalse(reloadStub.calledTwice);
+ assert.isTrue(reloadPatchDependentStub.calledOnce);
+ assert.isTrue(relatedClearSpy.calledOnce);
+ assert.isTrue(collapseStub.calledTwice);
+ });
+
+ test('reload entire page when patchRange doesnt change', () => {
+ const reloadStub = sinon
+ .stub(element, '_reload')
+ .callsFake(() => Promise.resolve([]));
+ const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
+ const value: AppElementChangeViewParams = createAppElementChangeViewParams();
+ element._paramsChanged(value);
+ assert.isTrue(reloadStub.calledOnce);
+ element._initialLoadComplete = true;
+ element._paramsChanged(value);
+ assert.isTrue(reloadStub.calledTwice);
+ assert.isTrue(collapseStub.calledTwice);
+ });
+
+ test('related changes are not updated after other action', done => {
+ sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
+ sinon.stub(element.$.relatedChanges, 'reload');
+ element._reload(true).then(() => {
+ assert.isFalse(navigateToChangeStub.called);
+ done();
+ });
+ });
+
+ test('_computeMergedCommitInfo', () => {
+ const dummyRevs: {[revisionId: string]: RevisionInfo} = {
+ 1: createRevision(1),
+ 2: createRevision(2),
+ };
+ assert.deepEqual(
+ element._computeMergedCommitInfo('0' as CommitId, dummyRevs),
+ {}
+ );
+ assert.deepEqual(
+ element._computeMergedCommitInfo('1' as CommitId, dummyRevs),
+ dummyRevs[1].commit
+ );
+
+ // Regression test for issue 5337.
+ const commit = element._computeMergedCommitInfo('2' as CommitId, dummyRevs);
+ assert.notDeepEqual(commit, dummyRevs[2]);
+ assert.deepEqual(commit, dummyRevs[2].commit);
+ });
+
+ test('_computeCopyTextForTitle', () => {
+ const change: ChangeInfo = {
+ ...createChange(),
+ _number: 123 as NumericChangeId,
+ subject: 'test subject',
+ revisions: {
+ rev1: createRevision(1),
+ rev3: createRevision(3),
+ },
+ current_revision: 'rev3' as CommitId,
+ };
+ sinon.stub(GerritNav, 'getUrlForChange').returns('/change/123');
+ assert.equal(
+ element._computeCopyTextForTitle(change),
+ `123: test subject | http://${location.host}/change/123`
+ );
+ });
+
+ test('get latest revision', () => {
+ let change: ChangeInfo = {
+ ...createChange(),
+ revisions: {
+ rev1: createRevision(1),
+ rev3: createRevision(3),
+ },
+ current_revision: 'rev3' as CommitId,
+ };
+ assert.equal(element._getLatestRevisionSHA(change), 'rev3');
+ change = {
+ ...createChange(),
+ revisions: {
+ rev1: createRevision(1),
+ },
+ };
+ assert.equal(element._getLatestRevisionSHA(change), 'rev1');
+ });
+
+ test('show commit message edit button', () => {
+ const change = createChange();
+ const mergedChanged: ChangeInfo = {
+ ...createChange(),
+ status: ChangeStatus.MERGED,
+ };
+ assert.isTrue(element._computeHideEditCommitMessage(false, false, change));
+ assert.isTrue(element._computeHideEditCommitMessage(true, true, change));
+ assert.isTrue(element._computeHideEditCommitMessage(false, true, change));
+ assert.isFalse(element._computeHideEditCommitMessage(true, false, change));
+ assert.isTrue(
+ element._computeHideEditCommitMessage(true, false, mergedChanged)
+ );
+ assert.isTrue(
+ element._computeHideEditCommitMessage(true, false, change, true)
+ );
+ assert.isFalse(
+ element._computeHideEditCommitMessage(true, false, change, false)
+ );
+ });
+
+ test('_handleCommitMessageSave trims trailing whitespace', () => {
+ element._change = createChange();
+ // Response code is 500, because we want to avoid window reloading
+ const putStub = sinon
+ .stub(element.$.restAPI, 'putChangeCommitMessage')
+ .returns(Promise.resolve(new Response(null, {status: 500})));
+
+ const mockEvent = (content: string) => {
+ return new CustomEvent('', {detail: {content}});
+ };
+
+ element._handleCommitMessageSave(mockEvent('test \n test '));
+ assert.equal(putStub.lastCall.args[1], 'test\n test');
+
+ element._handleCommitMessageSave(mockEvent(' test\ntest'));
+ assert.equal(putStub.lastCall.args[1], ' test\ntest');
+
+ element._handleCommitMessageSave(mockEvent('\n\n\n\n\n\n\n\n'));
+ assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
+ });
+
+ test('_computeChangeIdCommitMessageError', () => {
+ let commitMessage = 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
+ let change: ChangeInfo = {
+ ...createChange(),
+ change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+ };
+ assert.equal(
+ element._computeChangeIdCommitMessageError(commitMessage, change),
+ null
+ );
+
+ change = {
+ ...createChange(),
+ change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+ };
+ assert.equal(
+ element._computeChangeIdCommitMessageError(commitMessage, change),
+ 'mismatch'
+ );
+
+ commitMessage = 'This is the greatest change.';
+ assert.equal(
+ element._computeChangeIdCommitMessageError(commitMessage, change),
+ 'missing'
+ );
+ });
+
+ test('multiple change Ids in commit message picks last', () => {
+ const commitMessage = [
+ 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+ 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+ ].join('\n');
+ let change: ChangeInfo = {
+ ...createChange(),
+ change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+ };
+ assert.equal(
+ element._computeChangeIdCommitMessageError(commitMessage, change),
+ null
+ );
+ change = {
+ ...createChange(),
+ change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+ };
+ assert.equal(
+ element._computeChangeIdCommitMessageError(commitMessage, change),
+ 'mismatch'
+ );
+ });
+
+ test('does not count change Id that starts mid line', () => {
+ const commitMessage = [
+ 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+ 'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+ ].join(' and ');
+ let change: ChangeInfo = {
+ ...createChange(),
+ change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484' as ChangeId,
+ };
+ assert.equal(
+ element._computeChangeIdCommitMessageError(commitMessage, change),
+ null
+ );
+ change = {
+ ...createChange(),
+ change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483' as ChangeId,
+ };
+ assert.equal(
+ element._computeChangeIdCommitMessageError(commitMessage, change),
+ 'mismatch'
+ );
+ });
+
+ test('_computeTitleAttributeWarning', () => {
+ let changeIdCommitMessageError = 'missing';
+ assert.equal(
+ element._computeTitleAttributeWarning(changeIdCommitMessageError),
+ 'No Change-Id in commit message'
+ );
+
+ changeIdCommitMessageError = 'mismatch';
+ assert.equal(
+ element._computeTitleAttributeWarning(changeIdCommitMessageError),
+ 'Change-Id mismatch'
+ );
+ });
+
+ test('_computeChangeIdClass', () => {
+ let changeIdCommitMessageError = 'missing';
+ assert.equal(element._computeChangeIdClass(changeIdCommitMessageError), '');
+
+ changeIdCommitMessageError = 'mismatch';
+ assert.equal(
+ element._computeChangeIdClass(changeIdCommitMessageError),
+ 'warning'
+ );
+ });
+
+ test('topic is coalesced to null', done => {
+ sinon.stub(element, '_changeChanged');
+ sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ labels: {},
+ current_revision: 'foo' as CommitId,
+ revisions: {foo: createRevision()},
+ })
+ );
+
+ element._getChangeDetail().then(() => {
+ assert.isNull(element._change!.topic);
+ done();
+ });
+ });
+
+ test('commit sha is populated from getChangeDetail', done => {
+ sinon.stub(element, '_changeChanged');
+ sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ labels: {},
+ current_revision: 'foo' as CommitId,
+ revisions: {foo: createRevision()},
+ })
+ );
+
+ element._getChangeDetail().then(() => {
+ assert.equal('foo', element._commitInfo!.commit);
+ done();
+ });
+ });
+
+ test('edit is added to change', () => {
+ sinon.stub(element, '_changeChanged');
+ const changeRevision = createRevision();
+ sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ labels: {},
+ current_revision: 'foo' as CommitId,
+ revisions: {foo: {...changeRevision}},
+ })
+ );
+ const editCommit: CommitInfo = {
+ ...createCommit(),
+ commit: 'bar' as CommitId,
+ };
+ sinon.stub(element, '_getEdit').callsFake(() =>
+ Promise.resolve({
+ base_patch_set_number: 1 as PatchSetNum,
+ commit: {...editCommit},
+ base_revision: 'abc',
+ ref: 'some/ref' as GitRef,
+ })
+ );
+ element._patchRange = {};
+
+ return element._getChangeDetail().then(() => {
+ const revs = element._change!.revisions!;
+ assert.equal(Object.keys(revs).length, 2);
+ assert.deepEqual(revs['foo'], changeRevision);
+ assert.deepEqual(revs['bar'], {
+ ...createEditRevision(),
+ commit: editCommit,
+ fetch: undefined,
+ });
+ });
+ });
+
+ test('_getBasePatchNum', () => {
+ const _change: ChangeInfo = {
+ ...createChange(),
+ revisions: {
+ '98da160735fb81604b4c40e93c368f380539dd0e': createRevision(),
+ },
+ };
+ const _patchRange: ChangeViewPatchRange = {
+ basePatchNum: ParentPatchSetNum,
+ };
+ assert.equal(element._getBasePatchNum(_change, _patchRange), 'PARENT');
+
+ element._prefs = {
+ ...createPreferences(),
+ default_base_for_merges: DefaultBase.FIRST_PARENT,
+ };
+
+ const _change2: ChangeInfo = {
+ ...createChange(),
+ revisions: {
+ '98da160735fb81604b4c40e93c368f380539dd0e': {
+ ...createRevision(1),
+ commit: {
+ ...createCommit(),
+ parents: [
+ {
+ commit: '6e12bdf1176eb4ab24d8491ba3b6d0704409cde8' as CommitId,
+ subject: 'test',
+ },
+ {
+ commit: '22f7db4754b5d9816fc581f3d9a6c0ef8429c841' as CommitId,
+ subject: 'test3',
+ },
+ ],
+ },
+ },
+ },
+ };
+ assert.equal(element._getBasePatchNum(_change2, _patchRange), -1);
+
+ _patchRange.patchNum = 1 as PatchSetNum;
+ assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
+ });
+
+ test('_openReplyDialog called with `ANY` when coming from tap event', done => {
+ flush(() => {
+ const openStub = sinon.stub(element, '_openReplyDialog');
+ tap(element.$.replyBtn);
+ assert(
+ openStub.lastCall.calledWithExactly(
+ element.$.replyDialog.FocusTarget.ANY
+ ),
+ '_openReplyDialog should have been passed ANY'
+ );
+ assert.equal(openStub.callCount, 1);
+ done();
+ });
+ });
+
+ test(
+ '_openReplyDialog called with `BODY` when coming from message reply' +
+ 'event',
+ done => {
+ flush(() => {
+ const openStub = sinon.stub(element, '_openReplyDialog');
+ element.messagesList!.dispatchEvent(
+ new CustomEvent('reply', {
+ detail: {message: {message: 'text'}},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ assert(
+ openStub.lastCall.calledWithExactly(
+ element.$.replyDialog.FocusTarget.BODY
+ ),
+ '_openReplyDialog should have been passed BODY'
+ );
+ assert.equal(openStub.callCount, 1);
+ done();
+ });
+ }
+ );
+
+ test('reply dialog focus can be controlled', () => {
+ const FocusTarget = element.$.replyDialog.FocusTarget;
+ const openStub = sinon.stub(element, '_openReplyDialog');
+
+ const e = new CustomEvent('show-reply-dialog', {
+ detail: {value: {ccsOnly: false}},
+ });
+ element._handleShowReplyDialog(e);
+ assert(
+ openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
+ '_openReplyDialog should have been passed REVIEWERS'
+ );
+ assert.equal(openStub.callCount, 1);
+
+ e.detail.value = {ccsOnly: true};
+ element._handleShowReplyDialog(e);
+ assert(
+ openStub.lastCall.calledWithExactly(FocusTarget.CCS),
+ '_openReplyDialog should have been passed CCS'
+ );
+ assert.equal(openStub.callCount, 2);
+ });
+
+ test('getUrlParameter functionality', () => {
+ const locationStub = sinon.stub(element, '_getLocationSearch');
+
+ locationStub.returns('?test');
+ assert.equal(element._getUrlParameter('test'), 'test');
+ locationStub.returns('?test2=12&test=3');
+ assert.equal(element._getUrlParameter('test'), 'test');
+ locationStub.returns('');
+ assert.isNull(element._getUrlParameter('test'));
+ locationStub.returns('?');
+ assert.isNull(element._getUrlParameter('test'));
+ locationStub.returns('?test2');
+ assert.isNull(element._getUrlParameter('test'));
+ });
+
+ test('revert dialog opened with revert param', done => {
+ sinon
+ .stub(element.$.restAPI, 'getLoggedIn')
+ .callsFake(() => Promise.resolve(true));
+ const awaitPluginsLoadedStub = sinon
+ .stub(getPluginLoader(), 'awaitPluginsLoaded')
+ .callsFake(() => Promise.resolve());
+
+ element._patchRange = {
+ basePatchNum: ParentPatchSetNum,
+ patchNum: 2 as PatchSetNum,
+ };
+ element._change = {
+ ...createChange(),
+ revisions: {
+ rev1: createRevision(1),
+ rev2: createRevision(2),
+ },
+ current_revision: 'rev1' as CommitId,
+ status: ChangeStatus.MERGED,
+ labels: {},
+ actions: {},
+ };
+
+ sinon.stub(element, '_getUrlParameter').callsFake(param => {
+ assert.equal(param, 'revert');
+ return param;
+ });
+
+ sinon.stub(element.$.actions, 'showRevertDialog').callsFake(done);
+
+ element._maybeShowRevertDialog();
+ assert.isTrue(awaitPluginsLoadedStub.called);
+ });
+
+ suite('scroll related tests', () => {
+ test('document scrolling calls function to set scroll height', done => {
+ const originalHeight = document.body.scrollHeight;
+ const scrollStub = sinon.stub(element, '_handleScroll').callsFake(() => {
+ assert.isTrue(scrollStub.called);
+ document.body.style.height = `${originalHeight}px`;
+ scrollStub.restore();
+ done();
+ });
+ document.body.style.height = '10000px';
+ element._handleScroll();
+ });
+
+ test('scrollTop is set correctly', () => {
+ element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
+
+ sinon.stub(element, '_reload').callsFake(() => {
+ // When element is reloaded, ensure that the history
+ // state has the scrollTop set earlier. This will then
+ // be reset.
+ assert.isTrue(element.viewState.scrollTop === TEST_SCROLL_TOP_PX);
+ return Promise.resolve([]);
+ });
+
+ // simulate reloading component, which is done when route
+ // changes to match a regex of change view type.
+ element._paramsChanged({...createAppElementChangeViewParams()});
+ });
+
+ test('scrollTop is reset when new change is loaded', () => {
+ element._resetFileListViewState();
+ assert.equal(element.viewState.scrollTop, 0);
+ });
+ });
+
+ suite('reply dialog tests', () => {
+ setup(() => {
+ sinon.stub(element.$.replyDialog, '_draftChanged');
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(1),
+ messages: createChangeMessages(1),
+ };
+ element._change.labels = {};
+ sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ // element has latest info
+ revisions: {rev1: createRevision()},
+ messages: createChangeMessages(1),
+ current_revision: 'rev1' as CommitId,
+ })
+ );
+ });
+
+ test('show reply dialog on open-reply-dialog event', done => {
+ const openReplyDialogStub = sinon.stub(element, '_openReplyDialog');
+ element.dispatchEvent(
+ new CustomEvent('open-reply-dialog', {
+ composed: true,
+ bubbles: true,
+ detail: {},
+ })
+ );
+ flush(() => {
+ assert.isTrue(openReplyDialogStub.calledOnce);
+ done();
+ });
+ });
+
+ test('reply from comment adds quote text', () => {
+ const e = new CustomEvent('', {
+ detail: {message: {message: 'quote text'}},
+ });
+ element._handleMessageReply(e);
+ assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+ });
+
+ test('reply from comment replaces quote text', () => {
+ element.$.replyDialog.draft = '> old quote text\n\n some draft text';
+ element.$.replyDialog.quote = '> old quote text\n\n';
+ const e = new CustomEvent('', {
+ detail: {message: {message: 'quote text'}},
+ });
+ element._handleMessageReply(e);
+ assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+ });
+
+ test('reply from same comment preserves quote text', () => {
+ element.$.replyDialog.draft = '> quote text\n\n some draft text';
+ element.$.replyDialog.quote = '> quote text\n\n';
+ const e = new CustomEvent('', {
+ detail: {message: {message: 'quote text'}},
+ });
+ element._handleMessageReply(e);
+ assert.equal(
+ element.$.replyDialog.draft,
+ '> quote text\n\n some draft text'
+ );
+ assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+ });
+
+ test('reply from top of page contains previous draft', () => {
+ const div = document.createElement('div');
+ element.$.replyDialog.draft = '> quote text\n\n some draft text';
+ element.$.replyDialog.quote = '> quote text\n\n';
+ const e = ({
+ target: div,
+ preventDefault: sinon.spy(),
+ } as unknown) as MouseEvent;
+ element._handleReplyTap(e);
+ assert.equal(
+ element.$.replyDialog.draft,
+ '> quote text\n\n some draft text'
+ );
+ assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+ });
+ });
+
+ test('reply button is disabled until server config is loaded', done => {
+ assert.isTrue(element._replyDisabled);
+ // fetches the server config on attached
+ flush(() => {
+ assert.isFalse(element._replyDisabled);
+ done();
+ });
+ });
+
+ suite('commit message expand/collapse', () => {
+ setup(() => {
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(1),
+ messages: createChangeMessages(1),
+ };
+ element._change.labels = {};
+ sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ // new patchset was uploaded
+ revisions: createRevisions(2),
+ current_revision: getCurrentRevision(2),
+ messages: createChangeMessages(1),
+ })
+ );
+ });
+
+ test('commitCollapseToggle hidden for short commit message', () => {
+ element._latestCommitMessage = '';
+ assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
+ });
+
+ test('commitCollapseToggle shown for long commit message', () => {
+ element._latestCommitMessage = _.times(31, String).join('\n');
+ assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
+ });
+
+ test('commitCollapseToggle functions', () => {
+ element._latestCommitMessage = _.times(35, String).join('\n');
+ assert.isTrue(element._commitCollapsed);
+ assert.isTrue(element._commitCollapsible);
+ assert.isTrue(element.$.commitMessageEditor.hasAttribute('collapsed'));
+ tap(element.$.commitCollapseToggleButton);
+ assert.isFalse(element._commitCollapsed);
+ assert.isTrue(element._commitCollapsible);
+ assert.isFalse(element.$.commitMessageEditor.hasAttribute('collapsed'));
+ });
+ });
+
+ suite('related changes expand/collapse', () => {
+ let updateHeightSpy: SinonSpyMember<typeof element._updateRelatedChangeMaxHeight>;
+ setup(() => {
+ updateHeightSpy = sinon.spy(element, '_updateRelatedChangeMaxHeight');
+ });
+
+ test('relatedChangesToggle shown height greater than changeInfo height', () => {
+ assert.isFalse(
+ element.$.relatedChangesToggle.classList.contains('showToggle')
+ );
+ sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+ sinon.stub(element, '_getScrollHeight').callsFake(() => 60);
+ sinon.stub(element, '_getLineHeight').callsFake(() => 5);
+ sinon.stub(window, 'matchMedia').callsFake(() => {
+ return {matches: true} as MediaQueryList;
+ });
+ element.$.relatedChanges.dispatchEvent(
+ new CustomEvent('new-section-loaded')
+ );
+ assert.isTrue(
+ element.$.relatedChangesToggle.classList.contains('showToggle')
+ );
+ assert.equal(updateHeightSpy.callCount, 1);
+ });
+
+ test('relatedChangesToggle hidden height less than changeInfo height', () => {
+ assert.isFalse(
+ element.$.relatedChangesToggle.classList.contains('showToggle')
+ );
+ sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+ sinon.stub(element, '_getScrollHeight').callsFake(() => 40);
+ sinon.stub(element, '_getLineHeight').callsFake(() => 5);
+ sinon.stub(window, 'matchMedia').callsFake(() => {
+ return {matches: true} as MediaQueryList;
+ });
+ element.$.relatedChanges.dispatchEvent(
+ new CustomEvent('new-section-loaded')
+ );
+ assert.isFalse(
+ element.$.relatedChangesToggle.classList.contains('showToggle')
+ );
+ assert.equal(updateHeightSpy.callCount, 1);
+ });
+
+ test('relatedChangesToggle functions', () => {
+ sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+ sinon.stub(window, 'matchMedia').callsFake(() => {
+ return {matches: false} as MediaQueryList;
+ });
+ assert.isTrue(element._relatedChangesCollapsed);
+ assert.isTrue(element.$.relatedChanges.classList.contains('collapsed'));
+ tap(element.$.relatedChangesToggleButton);
+ assert.isFalse(element._relatedChangesCollapsed);
+ assert.isFalse(element.$.relatedChanges.classList.contains('collapsed'));
+ });
+
+ test('_updateRelatedChangeMaxHeight without commit toggle', () => {
+ sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+ sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+ sinon.stub(window, 'matchMedia').callsFake(() => {
+ return {matches: false} as MediaQueryList;
+ });
+
+ // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
+ // 20 (max existing height) % 12 (line height) = 6 (remainder).
+ // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
+
+ element._updateRelatedChangeMaxHeight();
+ assert.equal(getCustomCssValue('--relation-chain-max-height'), '12px');
+ assert.equal(getCustomCssValue('--related-change-btn-top-padding'), '');
+ });
+
+ test('_updateRelatedChangeMaxHeight with commit toggle', () => {
+ element._latestCommitMessage = _.times(31, String).join('\n');
+ sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+ sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+ sinon.stub(window, 'matchMedia').callsFake(() => {
+ return {matches: false} as MediaQueryList;
+ });
+
+ // 50 (existing height) % 12 (line height) = 2 (remainder).
+ // 50 (existing height) - 2 (remainder) = 48 (max height to set).
+
+ element._updateRelatedChangeMaxHeight();
+ assert.equal(getCustomCssValue('--relation-chain-max-height'), '48px');
+ assert.equal(
+ getCustomCssValue('--related-change-btn-top-padding'),
+ '2px'
+ );
+ });
+
+ test('_updateRelatedChangeMaxHeight in small screen mode', () => {
+ element._latestCommitMessage = _.times(31, String).join('\n');
+ sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+ sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+ sinon.stub(window, 'matchMedia').callsFake(() => {
+ return {matches: true} as MediaQueryList;
+ });
+
+ element._updateRelatedChangeMaxHeight();
+
+ // 400 (new height) % 12 (line height) = 4 (remainder).
+ // 400 (new height) - 4 (remainder) = 396.
+
+ assert.equal(getCustomCssValue('--relation-chain-max-height'), '396px');
+ });
+
+ test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
+ element._latestCommitMessage = _.times(31, String).join('\n');
+ sinon.stub(element, '_getOffsetHeight').callsFake(() => 50);
+ sinon.stub(element, '_getLineHeight').callsFake(() => 12);
+ const matchMediaStub = sinon.stub(window, 'matchMedia').callsFake(() => {
+ if (matchMediaStub.lastCall.args[0] === '(max-width: 75em)') {
+ return {matches: true} as MediaQueryList;
+ } else {
+ return {matches: false} as MediaQueryList;
+ }
+ });
+
+ // 100 (new height) % 12 (line height) = 4 (remainder).
+ // 100 (new height) - 4 (remainder) = 96.
+ element._updateRelatedChangeMaxHeight();
+ assert.equal(getCustomCssValue('--relation-chain-max-height'), '96px');
+ });
+
+ suite('update checks', () => {
+ let startUpdateCheckTimerSpy: SinonSpyMember<typeof element._startUpdateCheckTimer>;
+ let asyncStub: SinonStubbedMember<typeof element.async>;
+ setup(() => {
+ startUpdateCheckTimerSpy = sinon.spy(element, '_startUpdateCheckTimer');
+ asyncStub = sinon.stub(element, 'async').callsFake(f => {
+ // Only fire the async callback one time.
+ if (asyncStub.callCount > 1) {
+ return 1;
+ }
+ f.call(element);
+ return 1;
+ });
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(1),
+ messages: createChangeMessages(1),
+ };
+ });
+
+ test('_startUpdateCheckTimer negative delay', () => {
+ const getChangeDetailStub = sinon
+ .stub(element.$.restAPI, 'getChangeDetail')
+ .callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ // element has latest info
+ revisions: {rev1: createRevision()},
+ messages: createChangeMessages(1),
+ current_revision: 'rev1' as CommitId,
+ })
+ );
+
+ element._serverConfig = {
+ ...createServerInfo(),
+ change: {...createChangeConfig(), update_delay: -1},
+ };
+
+ assert.isTrue(startUpdateCheckTimerSpy.called);
+ assert.isFalse(getChangeDetailStub.called);
+ });
+
+ test('_startUpdateCheckTimer up-to-date', async () => {
+ const getChangeDetailStub = sinon
+ .stub(element.$.restAPI, 'getChangeDetail')
+ .callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ // element has latest info
+ revisions: {rev1: createRevision()},
+ messages: createChangeMessages(1),
+ current_revision: 'rev1' as CommitId,
+ })
+ );
+
+ element._serverConfig = {
+ ...createServerInfo(),
+ change: {...createChangeConfig(), update_delay: 12345},
+ };
+ await flush();
+
+ assert.equal(startUpdateCheckTimerSpy.callCount, 2);
+ assert.isTrue(getChangeDetailStub.called);
+ assert.equal(asyncStub.lastCall.args[1], 12345 * 1000);
+ });
+
+ test('_startUpdateCheckTimer out-of-date shows an alert', done => {
+ sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ // new patchset was uploaded
+ revisions: createRevisions(2),
+ current_revision: getCurrentRevision(2),
+ messages: createChangeMessages(1),
+ })
+ );
+
+ element.addEventListener('show-alert', e => {
+ assert.equal(e.detail.message, 'A newer patch set has been uploaded');
+ done();
+ });
+ element._serverConfig = {
+ ...createServerInfo(),
+ change: {...createChangeConfig(), update_delay: 12345},
+ };
+
+ assert.equal(startUpdateCheckTimerSpy.callCount, 1);
+ });
+
+ test('_startUpdateCheckTimer respects _loading', async () => {
+ sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ // new patchset was uploaded
+ revisions: createRevisions(2),
+ current_revision: getCurrentRevision(2),
+ messages: createChangeMessages(1),
+ })
+ );
+
+ element._loading = true;
+ element._serverConfig = {
+ ...createServerInfo(),
+ change: {...createChangeConfig(), update_delay: 12345},
+ };
+ await flush();
+
+ // No toast, instead a second call to _startUpdateCheckTimer().
+ assert.equal(startUpdateCheckTimerSpy.callCount, 2);
+ });
+
+ test('_startUpdateCheckTimer new status shows an alert', done => {
+ sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ // element has latest info
+ revisions: {rev1: createRevision()},
+ messages: createChangeMessages(1),
+ current_revision: 'rev1' as CommitId,
+ status: ChangeStatus.MERGED,
+ })
+ );
+
+ element.addEventListener('show-alert', e => {
+ assert.equal(e.detail.message, 'This change has been merged');
+ done();
+ });
+ element._serverConfig = {
+ ...createServerInfo(),
+ change: {...createChangeConfig(), update_delay: 12345},
+ };
+ });
+
+ test('_startUpdateCheckTimer new messages shows an alert', done => {
+ sinon.stub(element.$.restAPI, 'getChangeDetail').callsFake(() =>
+ Promise.resolve({
+ ...createChange(),
+ revisions: {rev1: createRevision()},
+ // element has new message
+ messages: createChangeMessages(2),
+ current_revision: 'rev1' as CommitId,
+ })
+ );
+ element.addEventListener('show-alert', e => {
+ assert.equal(
+ e.detail.message,
+ 'There are new messages on this change'
+ );
+ done();
+ });
+ element._serverConfig = {
+ ...createServerInfo(),
+ change: {...createChangeConfig(), update_delay: 12345},
+ };
+ });
+ });
+
+ test('canStartReview computation', () => {
+ const change1: ChangeInfo = createChange();
+ const change2: ChangeInfo = {
+ ...createChange(),
+ actions: {
+ ready: {
+ enabled: true,
+ },
+ },
+ };
+ const change3: ChangeInfo = {
+ ...createChange(),
+ actions: {
+ ready: {
+ label: 'Ready for Review',
+ },
+ },
+ };
+ assert.isFalse(element._computeCanStartReview(change1));
+ assert.isTrue(element._computeCanStartReview(change2));
+ assert.isFalse(element._computeCanStartReview(change3));
+ });
+ });
+
+ test('header class computation', () => {
+ assert.equal(element._computeHeaderClass(), 'header');
+ assert.equal(element._computeHeaderClass(true), 'header editMode');
+ });
+
+ test('_maybeScrollToMessage', done => {
+ flush(() => {
+ const scrollStub = sinon.stub(element.messagesList!, 'scrollToMessage');
+
+ element._maybeScrollToMessage('');
+ assert.isFalse(scrollStub.called);
+ element._maybeScrollToMessage('message');
+ assert.isFalse(scrollStub.called);
+ element._maybeScrollToMessage('#message-TEST');
+ assert.isTrue(scrollStub.called);
+ assert.equal(scrollStub.lastCall.args[0], 'TEST');
+ done();
+ });
+ });
+
+ test('topic update reloads related changes', () => {
+ const reloadStub = sinon.stub(element.$.relatedChanges, 'reload');
+ element.dispatchEvent(new CustomEvent('topic-changed'));
+ assert.isTrue(reloadStub.calledOnce);
+ });
+
+ test('_computeEditMode', () => {
+ const callCompute = (
+ range: PatchRange,
+ params: AppElementChangeViewParams
+ ) =>
+ element._computeEditMode(
+ {base: range, path: '', value: range},
+ {base: params, path: '', value: params}
+ );
+ assert.isTrue(
+ callCompute(
+ {basePatchNum: ParentPatchSetNum, patchNum: 1 as PatchSetNum},
+ {...createAppElementChangeViewParams(), edit: true}
+ )
+ );
+ assert.isFalse(
+ callCompute(
+ {basePatchNum: ParentPatchSetNum, patchNum: 1 as PatchSetNum},
+ createAppElementChangeViewParams()
+ )
+ );
+ assert.isFalse(
+ callCompute(
+ {basePatchNum: EditPatchSetNum, patchNum: 1 as PatchSetNum},
+ createAppElementChangeViewParams()
+ )
+ );
+ assert.isTrue(
+ callCompute(
+ {basePatchNum: 1 as PatchSetNum, patchNum: EditPatchSetNum},
+ createAppElementChangeViewParams()
+ )
+ );
+ });
+
+ test('_processEdit', () => {
+ element._patchRange = {};
+ const change: ParsedChangeInfo = {
+ ...createChange(),
+ current_revision: 'foo' as CommitId,
+ revisions: {
+ foo: {...createRevision(), actions: {cherrypick: {enabled: true}}},
+ },
+ };
+ let mockChange;
+
+ // With no edit, mockChange should be unmodified.
+ element._processEdit((mockChange = _.cloneDeep(change)), false);
+ assert.deepEqual(mockChange, change);
+
+ const editCommit: CommitInfo = {
+ ...createCommit(),
+ commit: 'bar' as CommitId,
+ };
+ // When edit is not based on the latest PS, current_revision should be
+ // unmodified.
+ const edit: EditInfo = {
+ ref: 'ref/test/abc' as GitRef,
+ base_revision: 'abc',
+ base_patch_set_number: 1 as PatchSetNum,
+ commit: {...editCommit},
+ fetch: {},
+ };
+ element._processEdit((mockChange = _.cloneDeep(change)), edit);
+ assert.notDeepEqual(mockChange, change);
+ assert.equal(mockChange.revisions.bar._number, EditPatchSetNum);
+ assert.equal(mockChange.current_revision, change.current_revision);
+ assert.deepEqual(mockChange.revisions.bar.commit, editCommit);
+ assert.notOk(mockChange.revisions.bar.actions);
+
+ edit.base_revision = 'foo';
+ element._processEdit((mockChange = _.cloneDeep(change)), edit);
+ assert.notDeepEqual(mockChange, change);
+ assert.equal(mockChange.current_revision, 'bar');
+ assert.deepEqual(
+ mockChange.revisions.bar.actions,
+ mockChange.revisions.foo.actions
+ );
+
+ // If _patchRange.patchNum is defined, do not load edit.
+ element._patchRange.patchNum = 5 as PatchSetNum;
+ change.current_revision = 'baz' as CommitId;
+ element._processEdit((mockChange = _.cloneDeep(change)), edit);
+ assert.equal(element._patchRange.patchNum, 5 as PatchSetNum);
+ assert.notOk(mockChange.revisions.bar.actions);
+ });
+
+ test('file-action-tap handling', () => {
+ element._patchRange = {
+ basePatchNum: ParentPatchSetNum,
+ patchNum: 1 as PatchSetNum,
+ };
+ element._change = {
+ ...createChange(),
+ };
+ const fileList = element.$.fileList;
+ const Actions = GrEditConstants.Actions;
+ element.$.fileListHeader.editMode = true;
+ flush();
+ const controls = element.$.fileListHeader.shadowRoot!.querySelector(
+ '#editControls'
+ ) as GrEditControls;
+ const openDeleteDialogStub = sinon.stub(controls, 'openDeleteDialog');
+ const openRenameDialogStub = sinon.stub(controls, 'openRenameDialog');
+ const openRestoreDialogStub = sinon.stub(controls, 'openRestoreDialog');
+ const getEditUrlForDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
+ const navigateToRelativeUrlStub = sinon.stub(
+ GerritNav,
+ 'navigateToRelativeUrl'
+ );
+
+ // Delete
+ fileList.dispatchEvent(
+ new CustomEvent('file-action-tap', {
+ detail: {action: Actions.DELETE.id, path: 'foo'},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ flush();
+
+ assert.isTrue(openDeleteDialogStub.called);
+ assert.equal(openDeleteDialogStub.lastCall.args[0], 'foo');
+
+ // Restore
+ fileList.dispatchEvent(
+ new CustomEvent('file-action-tap', {
+ detail: {action: Actions.RESTORE.id, path: 'foo'},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ flush();
+
+ assert.isTrue(openRestoreDialogStub.called);
+ assert.equal(openRestoreDialogStub.lastCall.args[0], 'foo');
+
+ // Rename
+ fileList.dispatchEvent(
+ new CustomEvent('file-action-tap', {
+ detail: {action: Actions.RENAME.id, path: 'foo'},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ flush();
+
+ assert.isTrue(openRenameDialogStub.called);
+ assert.equal(openRenameDialogStub.lastCall.args[0], 'foo');
+
+ // Open
+ fileList.dispatchEvent(
+ new CustomEvent('file-action-tap', {
+ detail: {action: Actions.OPEN.id, path: 'foo'},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ flush();
+
+ assert.isTrue(getEditUrlForDiffStub.called);
+ assert.equal(getEditUrlForDiffStub.lastCall.args[1], 'foo');
+ assert.equal(getEditUrlForDiffStub.lastCall.args[2], 1 as PatchSetNum);
+ assert.isTrue(navigateToRelativeUrlStub.called);
+ });
+
+ test('_selectedRevision updates when patchNum is changed', () => {
+ const revision1: RevisionInfo = createRevision(1);
+ const revision2: RevisionInfo = createRevision(2);
+ sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+ Promise.resolve({
+ ...createChange(),
+ revisions: {
+ aaa: revision1,
+ bbb: revision2,
+ },
+ labels: {},
+ actions: {},
+ current_revision: 'bbb' as CommitId,
+ })
+ );
+ sinon.stub(element, '_getEdit').returns(Promise.resolve(false));
+ sinon
+ .stub(element, '_getPreferences')
+ .returns(Promise.resolve(createPreferences()));
+ element._patchRange = {patchNum: 2 as PatchSetNum};
+ return element._getChangeDetail().then(() => {
+ assert.strictEqual(element._selectedRevision, revision2);
+
+ element.set('_patchRange.patchNum', '1');
+ assert.strictEqual(element._selectedRevision, revision1);
+ });
+ });
+
+ test('_selectedRevision is assigned when patchNum is edit', () => {
+ const revision1 = createRevision(1);
+ const revision2 = createRevision(2);
+ const revision3 = createEditRevision();
+ sinon.stub(element.$.restAPI, 'getChangeDetail').returns(
+ Promise.resolve({
+ ...createChange(),
+ revisions: {
+ aaa: revision1,
+ bbb: revision2,
+ ccc: revision3,
+ },
+ labels: {},
+ actions: {},
+ current_revision: 'ccc' as CommitId,
+ })
+ );
+ sinon.stub(element, '_getEdit').returns(Promise.resolve(undefined));
+ sinon
+ .stub(element, '_getPreferences')
+ .returns(Promise.resolve(createPreferences()));
+ element._patchRange = {patchNum: EditPatchSetNum};
+ return element._getChangeDetail().then(() => {
+ assert.strictEqual(element._selectedRevision, revision3);
+ });
+ });
+
+ test('_sendShowChangeEvent', () => {
+ const change = {...createChange(), labels: {}};
+ element._change = {...change};
+ element._patchRange = {patchNum: 4 as PatchSetNum};
+ element._mergeable = true;
+ const showStub = sinon.stub(element.$.jsAPI, 'handleEvent');
+ element._sendShowChangeEvent();
+ assert.isTrue(showStub.calledOnce);
+ assert.equal(showStub.lastCall.args[0], EventType.SHOW_CHANGE);
+ assert.deepEqual(showStub.lastCall.args[1], {
+ change,
+ patchNum: 4,
+ info: {mergeable: true},
+ });
+ });
+
+ suite('_handleEditTap', () => {
+ let fireEdit: () => void;
+
+ setup(() => {
+ fireEdit = () => {
+ element.$.actions.dispatchEvent(new CustomEvent('edit-tap'));
+ };
+ navigateToChangeStub.restore();
+
+ element._change = {
+ ...createChange(),
+ revisions: {rev1: createRevision()},
+ };
+ });
+
+ test('edit exists in revisions', done => {
+ sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+ assert.equal(args.length, 2);
+ assert.equal(args[1], EditPatchSetNum); // patchNum
+ done();
+ });
+
+ element.set('_change.revisions.rev2', {
+ _number: SPECIAL_PATCH_SET_NUM.EDIT,
+ });
+ flush();
+
+ fireEdit();
+ });
+
+ test('no edit exists in revisions, non-latest patchset', done => {
+ sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+ assert.equal(args.length, 4);
+ assert.equal(args[1], 1 as PatchSetNum); // patchNum
+ assert.equal(args[3], true); // opt_isEdit
+ done();
+ });
+
+ element.set('_change.revisions.rev2', {_number: 2});
+ element._patchRange = {patchNum: 1 as PatchSetNum};
+ flush();
+
+ fireEdit();
+ });
+
+ test('no edit exists in revisions, latest patchset', done => {
+ sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+ assert.equal(args.length, 4);
+ // No patch should be specified when patchNum == latest.
+ assert.isNotOk(args[1]); // patchNum
+ assert.equal(args[3], true); // opt_isEdit
+ done();
+ });
+
+ element.set('_change.revisions.rev2', {_number: 2});
+ element._patchRange = {patchNum: 2 as PatchSetNum};
+ flush();
+
+ fireEdit();
+ });
+ });
+
+ test('_handleStopEditTap', done => {
+ element._change = {
+ ...createChange(),
+ };
+ sinon.stub(element.$.metadata, '_computeLabelNames');
+ navigateToChangeStub.restore();
+ sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
+ assert.equal(args.length, 2);
+ assert.equal(args[1], 1 as PatchSetNum); // patchNum
+ done();
+ });
+
+ element._patchRange = {patchNum: 1 as PatchSetNum};
+ element.$.actions.dispatchEvent(
+ new CustomEvent('stop-edit-tap', {bubbles: false})
+ );
+ });
+
+ suite('plugin endpoints', () => {
+ test('endpoint params', done => {
+ element._change = {...createChange(), labels: {}};
+ element._selectedRevision = createRevision();
+ let hookEl: HTMLElement;
+ let plugin: PluginApi;
+ pluginApi.install(
+ p => {
+ plugin = p;
+ plugin
+ .hook('change-view-integration')
+ .getLastAttached()
+ .then(el => (hookEl = el));
+ },
+ '0.1',
+ 'http://some/plugins/url.html'
+ );
+ flush(() => {
+ assert.strictEqual((hookEl as any).plugin, plugin);
+ assert.strictEqual((hookEl as any).change, element._change);
+ assert.strictEqual((hookEl as any).revision, element._selectedRevision);
+ done();
+ });
+ });
+ });
+
+ suite('_getMergeability', () => {
+ let getMergeableStub: SinonStubbedMember<RestApiService['getMergeable']>;
+ setup(() => {
+ element._change = {...createChange(), labels: {}};
+ getMergeableStub = sinon
+ .stub(element.$.restAPI, 'getMergeable')
+ .returns(Promise.resolve({...createMergeable(), mergeable: true}));
+ });
+
+ test('merged change', () => {
+ element._mergeable = null;
+ element._change!.status = ChangeStatus.MERGED;
+ return element._getMergeability().then(() => {
+ assert.isFalse(element._mergeable);
+ assert.isFalse(getMergeableStub.called);
+ });
+ });
+
+ test('abandoned change', () => {
+ element._mergeable = null;
+ element._change!.status = ChangeStatus.ABANDONED;
+ return element._getMergeability().then(() => {
+ assert.isFalse(element._mergeable);
+ assert.isFalse(getMergeableStub.called);
+ });
+ });
+
+ test('open change', () => {
+ element._mergeable = null;
+ return element._getMergeability().then(() => {
+ assert.isTrue(element._mergeable);
+ assert.isTrue(getMergeableStub.called);
+ });
+ });
+ });
+
+ test('_paramsChanged sets in projectLookup', () => {
+ sinon.stub(element.$.relatedChanges, 'reload');
+ sinon.stub(element, '_reload').returns(Promise.resolve([]));
+ const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
+ element._paramsChanged({
+ view: GerritNav.View.CHANGE,
+ changeNum: 101 as NumericChangeId,
+ project: TEST_PROJECT_NAME,
+ });
+ assert.isTrue(setStub.calledOnce);
+ assert.isTrue(
+ setStub.calledWith(101 as NumericChangeId, TEST_PROJECT_NAME)
+ );
+ });
+
+ test('_handleToggleStar called when star is tapped', () => {
+ element._change = {
+ ...createChange(),
+ owner: {_account_id: 1 as AccountId},
+ starred: false,
+ };
+ element._loggedIn = true;
+ const stub = sinon.stub(element, '_handleToggleStar');
+ flush();
+
+ tap(element.$.changeStar.shadowRoot!.querySelector('button')!);
+ assert.isTrue(stub.called);
+ });
+
+ suite('gr-reporting tests', () => {
+ setup(() => {
+ element._patchRange = {
+ basePatchNum: ParentPatchSetNum,
+ patchNum: 1 as PatchSetNum,
+ };
+ sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(false));
+ sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
+ sinon.stub(element, '_reloadComments').returns(Promise.resolve());
+ sinon.stub(element, '_getMergeability').returns(Promise.resolve());
+ sinon.stub(element, '_getLatestCommitMessage').returns(Promise.resolve());
+ });
+
+ test("don't report changedDisplayed on reply", done => {
+ const changeDisplayStub = sinon.stub(
+ element.reporting,
+ 'changeDisplayed'
+ );
+ const changeFullyLoadedStub = sinon.stub(
+ element.reporting,
+ 'changeFullyLoaded'
+ );
+ element._handleReplySent();
+ flush(() => {
+ assert.isFalse(changeDisplayStub.called);
+ assert.isFalse(changeFullyLoadedStub.called);
+ done();
+ });
+ });
+
+ test('report changedDisplayed on _paramsChanged', done => {
+ const changeDisplayStub = sinon.stub(
+ element.reporting,
+ 'changeDisplayed'
+ );
+ const changeFullyLoadedStub = sinon.stub(
+ element.reporting,
+ 'changeFullyLoaded'
+ );
+ element._paramsChanged({
+ ...createAppElementChangeViewParams(),
+ changeNum: 101 as NumericChangeId,
+ project: TEST_PROJECT_NAME,
+ });
+ flush(() => {
+ assert.isTrue(changeDisplayStub.called);
+ assert.isTrue(changeFullyLoadedStub.called);
+ done();
+ });
+ });
+ });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
deleted file mode 100644
index 3ed48e7..0000000
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
+++ /dev/null
@@ -1,87 +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 '../../../styles/shared-styles.js';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard.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-commit-info_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/** @extends PolymerElement */
-class GrCommitInfo extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-commit-info'; }
-
- static get properties() {
- return {
- change: Object,
- /** @type {?} */
- commitInfo: Object,
- serverConfig: Object,
- _showWebLink: {
- type: Boolean,
- computed: '_computeShowWebLink(change, commitInfo, serverConfig)',
- },
- _webLink: {
- type: String,
- computed: '_computeWebLink(change, commitInfo, serverConfig)',
- },
- };
- }
-
- _getWeblink(change, commitInfo, config) {
- return GerritNav.getPatchSetWeblink(
- change.project,
- commitInfo.commit,
- {
- weblinks: commitInfo.web_links,
- config,
- });
- }
-
- _computeShowWebLink(change, commitInfo, serverConfig) {
- // Polymer 2: check for undefined
- if ([change, commitInfo, serverConfig].includes(undefined)) {
- return undefined;
- }
-
- const weblink = this._getWeblink(change, commitInfo, serverConfig);
- return !!weblink && !!weblink.url;
- }
-
- _computeWebLink(change, commitInfo, serverConfig) {
- // Polymer 2: check for undefined
- if ([change, commitInfo, serverConfig].includes(undefined)) {
- return undefined;
- }
-
- const {url} = this._getWeblink(change, commitInfo, serverConfig) || {};
- return url;
- }
-
- _computeShortHash(commitInfo) {
- const {name} =
- this._getWeblink(this.change, commitInfo, this.serverConfig) || {};
- return name;
- }
-}
-
-customElements.define(GrCommitInfo.is, GrCommitInfo);
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
new file mode 100644
index 0000000..18bd3a0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -0,0 +1,100 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+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-commit-info_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, computed} from '@polymer/decorators';
+import {ChangeInfo, CommitInfo, ServerInfo} from '../../../types/common';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-commit-info': GrCommitInfo;
+ }
+}
+
+@customElement('gr-commit-info')
+export class GrCommitInfo extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ // TODO(TS): can not use `?` here as @computed require dependencies as
+ // not optional
+ @property({type: Object})
+ change: ChangeInfo | undefined;
+
+ // TODO(TS): maybe limit to StandaloneCommitInfo if never pass in
+ // with commit inside RevisionInfo
+ @property({type: Object})
+ commitInfo: CommitInfo | undefined;
+
+ @property({type: Object})
+ serverConfig: ServerInfo | undefined;
+
+ @computed('change', 'commitInfo', 'serverConfig')
+ get _showWebLink(): boolean {
+ if (!this.change || !this.commitInfo || !this.serverConfig) {
+ return false;
+ }
+
+ const weblink = this._getWeblink(
+ this.change,
+ this.commitInfo,
+ this.serverConfig
+ );
+ return !!weblink && !!weblink.url;
+ }
+
+ @computed('change', 'commitInfo', 'serverConfig')
+ get _webLink(): string | undefined {
+ if (!this.change || !this.commitInfo || !this.serverConfig) {
+ return '';
+ }
+
+ // TODO(TS): if getPatchSetWeblink always return a valid WebLink,
+ // can remove the fallback here
+ const {url} =
+ this._getWeblink(this.change, this.commitInfo, this.serverConfig) || {};
+ return url;
+ }
+
+ _getWeblink(change: ChangeInfo, commitInfo: CommitInfo, config: ServerInfo) {
+ return GerritNav.getPatchSetWeblink(change.project, commitInfo.commit, {
+ weblinks: commitInfo.web_links,
+ config,
+ });
+ }
+
+ _computeShortHash(
+ change?: ChangeInfo,
+ commitInfo?: CommitInfo,
+ serverConfig?: ServerInfo
+ ) {
+ if (!change || !commitInfo || !serverConfig) {
+ return '';
+ }
+
+ const {name} = this._getWeblink(change, commitInfo, serverConfig) || {};
+ return name;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
index e350593..df0bb4a 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
@@ -26,11 +26,11 @@
<div class="container">
<template is="dom-if" if="[[_showWebLink]]">
<a target="_blank" rel="noopener" href$="[[_webLink]]"
- >[[_computeShortHash(commitInfo)]]</a
+ >[[_computeShortHash(change, commitInfo, serverConfig)]]</a
>
</template>
<template is="dom-if" if="[[!_showWebLink]]">
- [[_computeShortHash(commitInfo)]]
+ [[_computeShortHash(change, commitInfo, serverConfig)]]
</template>
<gr-copy-clipboard
has-tooltip=""
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js
index c120c33..ffaed23 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js
@@ -43,8 +43,7 @@
element.serverConfig = {};
element.change = {labels: [], project: ''};
- assert.isNotOk(element._computeShowWebLink(element.change,
- element.commitInfo, element.serverConfig));
+ assert.isNotOk(element._showWebLink);
});
test('use web link when available', () => {
@@ -57,10 +56,8 @@
{commit: 'commitsha', web_links: [{name: 'gitweb', url: 'link-url'}]};
element.serverConfig = {};
- assert.isOk(element._computeShowWebLink(element.change,
- element.commitInfo, element.serverConfig));
- assert.equal(element._computeWebLink(element.change, element.commitInfo,
- element.serverConfig), 'link-url');
+ assert.isOk(element._showWebLink);
+ assert.equal(element._webLink, 'link-url');
});
test('does not relativize web links that begin with scheme', () => {
@@ -75,10 +72,8 @@
};
element.serverConfig = {};
- assert.isOk(element._computeShowWebLink(element.change,
- element.commitInfo, element.serverConfig));
- assert.equal(element._computeWebLink(element.change, element.commitInfo,
- element.serverConfig), 'https://link-url');
+ assert.isOk(element._showWebLink);
+ assert.equal(element._webLink, 'https://link-url');
});
test('ignore web links that are neither gitweb nor gitiles', () => {
@@ -102,17 +97,21 @@
};
element.serverConfig = {};
- assert.isOk(element._computeShowWebLink(element.change,
- element.commitInfo, element.serverConfig));
- assert.equal(element._computeWebLink(element.change, element.commitInfo,
- element.serverConfig), 'https://link-url');
+ assert.isOk(element._showWebLink);
+ assert.equal(element._webLink, 'https://link-url');
// Remove gitiles link.
- element.commitInfo.web_links.splice(1, 1);
- assert.isNotOk(element._computeShowWebLink(element.change,
- element.commitInfo, element.serverConfig));
- assert.isNotOk(element._computeWebLink(element.change, element.commitInfo,
- element.serverConfig));
+ element.commitInfo = {
+ commit: 'commit-sha',
+ web_links: [
+ {
+ name: 'ignore',
+ url: 'ignore',
+ },
+ ],
+ };
+ assert.isNotOk(element._showWebLink);
+ assert.isNotOk(element._webLink);
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
deleted file mode 100644
index a8f1903..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ /dev/null
@@ -1,90 +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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '../../shared/gr-dialog/gr-dialog.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-abandon-dialog_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-/**
- * @extends PolymerElement
- */
-class GrConfirmAbandonDialog extends KeyboardShortcutMixin(
- GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-confirm-abandon-dialog'; }
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- static get properties() {
- return {
- message: String,
- };
- }
-
- get keyBindings() {
- return {
- 'ctrl+enter meta+enter': '_handleEnterKey',
- };
- }
-
- resetFocus() {
- this.$.messageInput.textarea.focus();
- }
-
- _handleEnterKey(e) {
- this._confirm();
- }
-
- _handleConfirmTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this._confirm();
- }
-
- _confirm() {
- this.dispatchEvent(new CustomEvent('confirm', {
- detail: {reason: this.message},
- composed: true, bubbles: false,
- }));
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {
- composed: true, bubbles: false,
- }));
- }
-}
-
-customElements.define(GrConfirmAbandonDialog.is, GrConfirmAbandonDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
new file mode 100644
index 0000000..10563ee
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -0,0 +1,106 @@
+/**
+ * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../shared/gr-dialog/gr-dialog';
+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-abandon-dialog_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+
+export interface GrConfirmAbandonDialog {
+ $: {
+ messageInput: IronAutogrowTextareaElement;
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-confirm-abandon-dialog': GrConfirmAbandonDialog;
+ }
+}
+
+/**
+ * @extends PolymerElement
+ */
+@customElement('gr-confirm-abandon-dialog')
+export class GrConfirmAbandonDialog extends KeyboardShortcutMixin(
+ 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: String})
+ message?: string;
+
+ get keyBindings() {
+ return {
+ 'ctrl+enter meta+enter': '_handleEnterKey',
+ };
+ }
+
+ resetFocus() {
+ this.$.messageInput.textarea.focus();
+ }
+
+ _handleEnterKey() {
+ this._confirm();
+ }
+
+ _handleConfirmTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this._confirm();
+ }
+
+ _confirm() {
+ this.dispatchEvent(
+ new CustomEvent('confirm', {
+ detail: {reason: this.message},
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleCancelTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
deleted file mode 100644
index 34a3dcc..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.js
+++ /dev/null
@@ -1,63 +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 '../../../styles/shared-styles.js';
-import '../../shared/gr-dialog/gr-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-confirm-cherrypick-conflict-dialog_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrConfirmCherrypickConflictDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-confirm-cherrypick-conflict-dialog'; }
-
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- _handleConfirmTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm', {
- composed: true, bubbles: false,
- }));
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {
- composed: true, bubbles: false,
- }));
- }
-}
-
-customElements.define(GrConfirmCherrypickConflictDialog.is,
- GrConfirmCherrypickConflictDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
new file mode 100644
index 0000000..2f33858
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog.ts
@@ -0,0 +1,72 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../shared/gr-dialog/gr-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-confirm-cherrypick-conflict-dialog_html';
+import {customElement} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-confirm-cherrypick-conflict-dialog': GrConfirmCherrypickConflictDialog;
+ }
+}
+
+@customElement('gr-confirm-cherrypick-conflict-dialog')
+export class GrConfirmCherrypickConflictDialog 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
+ */
+
+ _handleConfirmTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('confirm', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleCancelTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
deleted file mode 100644
index e6d529c..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ /dev/null
@@ -1,313 +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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '@polymer/iron-input/iron-input.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-cherrypick-dialog_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {appContext} from '../../../services/app-context.js';
-
-const SUGGESTIONS_LIMIT = 15;
-const CHANGE_SUBJECT_LIMIT = 50;
-const CHERRY_PICK_TYPES = {
- SINGLE_CHANGE: 1,
- TOPIC: 2,
-};
-
-/**
- * @extends PolymerElement
- */
-class GrConfirmCherrypickDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-confirm-cherrypick-dialog'; }
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- static get properties() {
- return {
- branch: {
- type: String,
- observer: '_updateBranch',
- },
- baseCommit: String,
- changeStatus: String,
- commitMessage: String,
- commitNum: String,
- message: String,
- project: String,
- changes: Array,
- _query: {
- type: Function,
- value() {
- return this._getProjectBranchesSuggestions.bind(this);
- },
- },
- _showCherryPickTopic: {
- type: Boolean,
- value: false,
- },
- _changesCount: Number,
- _cherryPickType: {
- type: Number,
- value: CHERRY_PICK_TYPES.SINGLE_CHANGE,
- },
- _duplicateProjectChanges: {
- type: Boolean,
- value: false,
- },
- // Status of each change that is being cherry picked together
- _statuses: Object,
- _invalidBranch: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- static get observers() {
- return [
- '_computeMessage(changeStatus, commitNum, commitMessage)',
- ];
- }
-
- updateChanges(changes) {
- this.changes = changes;
- this._statuses = {};
- const projects = {};
- this._duplicateProjectChanges = false;
- changes.forEach(change => {
- if (projects[change.project]) {
- this._duplicateProjectChanges = true;
- }
- projects[change.project] = true;
- });
- this._changesCount = changes.length;
- this._showCherryPickTopic = changes.length > 1;
- }
-
- _updateBranch(branch) {
- const invalidChars = [',', ' '];
- this._invalidBranch = branch && invalidChars.some(c => branch.includes(c));
- }
-
- _computeTopicErrorMessage(duplicateProjectChanges) {
- if (duplicateProjectChanges) {
- return 'Two changes cannot be of the same project';
- }
- }
-
- updateStatus(change, status) {
- this._statuses = Object.assign({}, this._statuses, {[change.id]: status});
- }
-
- _computeStatus(change, statuses) {
- if (!change || !statuses || !statuses[change.id]) return 'NOT STARTED';
- return statuses[change.id].status;
- }
-
- _computeStatusClass(change, statuses) {
- if (!change || !statuses || !statuses[change.id]) return '';
- return statuses[change.id].status === 'FAILED' ? 'error': '';
- }
-
- _computeError(change, statuses) {
- if (!change || !statuses || !statuses[change.id]) return '';
- if (statuses[change.id].status === 'FAILED') {
- return statuses[change.id].msg;
- }
- }
-
- _getChangeId(change) {
- return change.change_id.substring(0, 10);
- }
-
- _getTrimmedChangeSubject(subject) {
- if (!subject) return '';
- if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
- return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
- }
-
- _computeCancelLabel(statuses) {
- const isRunningChange = Object.values(statuses).
- some(v => v.status === 'RUNNING');
- return isRunningChange ? 'Close' : 'Cancel';
- }
-
- _computeDisableCherryPick(cherryPickType, duplicateProjectChanges,
- statuses) {
- const duplicateProject = (cherryPickType === CHERRY_PICK_TYPES.TOPIC) &&
- duplicateProjectChanges;
- if (duplicateProject) return true;
- if (!statuses) return false;
- const isRunningChange = Object.values(statuses).
- some(v => v.status === 'RUNNING');
- return isRunningChange;
- }
-
- _computeIfSinglecherryPick(cherryPickType) {
- return cherryPickType === CHERRY_PICK_TYPES.SINGLE_CHANGE;
- }
-
- _computeIfCherryPickTopic(cherryPickType) {
- return cherryPickType === CHERRY_PICK_TYPES.TOPIC;
- }
-
- _handlecherryPickSingleChangeClicked(e) {
- this._cherryPickType = CHERRY_PICK_TYPES.SINGLE_CHANGE;
- }
-
- _handlecherryPickTopicClicked(e) {
- this._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
- }
-
- _computeMessage(changeStatus, commitNum, commitMessage) {
- // Polymer 2: check for undefined
- if ([
- changeStatus,
- commitNum,
- commitMessage,
- ].includes(undefined)) {
- return;
- }
-
- let newMessage = commitMessage;
-
- if (changeStatus === 'MERGED') {
- newMessage += '(cherry picked from commit ' + commitNum + ')';
- }
- this.message = newMessage;
- }
-
- _generateRandomCherryPickTopic(change) {
- const randomString = Math.random().toString(36)
- .substr(2, 10);
- const message = `cherrypick-${change.topic}-${randomString}`;
- return message;
- }
-
- _handleCherryPickFailed(change, response) {
- response.text().then(errText => {
- this.updateStatus(change,
- {status: 'FAILED', msg: errText});
- });
- }
-
- _handleCherryPickTopic() {
- const topic = this._generateRandomCherryPickTopic(
- this.changes[0]);
- this.changes.forEach(change => {
- this.updateStatus(change,
- {status: 'RUNNING'});
- const payload = {
- destination: this.branch,
- base: null,
- topic,
- allow_conflicts: true,
- allow_empty: true,
- };
- const handleError = response => {
- this._handleCherryPickFailed(change, response);
- };
- const patchNum = change.revisions[change.current_revision]._number;
- this.$.restAPI.executeChangeAction(change._number, 'POST', '/cherrypick',
- patchNum, payload, handleError).then(response => {
- this.updateStatus(change, {status: 'SUCCESSFUL'});
- const failedOrPending = Object.values(this._statuses).find(
- v => v.status !== 'SUCCESSFUL');
- if (!failedOrPending) {
- /* This needs some more work, as the new topic may not always be
- created, instead we may end up creating a new patchset */
- GerritNav.navigateToSearchQuery(`topic: "${topic}"`);
- }
- });
- });
- }
-
- _handleConfirmTap(e) {
- e.preventDefault();
- e.stopPropagation();
- if (this._cherryPickType === CHERRY_PICK_TYPES.TOPIC) {
- this.reporting.reportInteraction('cherry-pick-topic-clicked');
- this._handleCherryPickTopic();
- return;
- }
- // Cherry pick single change
- this.dispatchEvent(new CustomEvent('confirm', {
- composed: true, bubbles: false,
- }));
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {
- composed: true, bubbles: false,
- }));
- }
-
- resetFocus() {
- this.$.branchInput.focus();
- }
-
- _getProjectBranchesSuggestions(input) {
- if (input.startsWith('refs/heads/')) {
- input = input.substring('refs/heads/'.length);
- }
- return this.$.restAPI.getRepoBranches(
- input, this.project, SUGGESTIONS_LIMIT).then(response => {
- const branches = [];
- let branch;
- for (const key in response) {
- if (!response.hasOwnProperty(key)) { continue; }
- if (response[key].ref.startsWith('refs/heads/')) {
- branch = response[key].ref.substring('refs/heads/'.length);
- } else {
- branch = response[key].ref;
- }
- branches.push({
- name: branch,
- });
- }
- return branches;
- });
- }
-}
-
-customElements.define(GrConfirmCherrypickDialog.is,
- GrConfirmCherrypickDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
new file mode 100644
index 0000000..e05bac0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.ts
@@ -0,0 +1,391 @@
+/**
+ * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-rest-api-interface/gr-rest-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-cherrypick-dialog_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {
+ ChangeInfo,
+ BranchInfo,
+ RepoName,
+ BranchName,
+ CommitId,
+} from '../../../types/common';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ GrAutocomplete,
+ AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {HttpMethod, ChangeStatus} from '../../../constants/constants';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const SUGGESTIONS_LIMIT = 15;
+const CHANGE_SUBJECT_LIMIT = 50;
+enum CherryPickType {
+ SINGLE_CHANGE = 1,
+ TOPIC,
+}
+
+type Statuses = {[changeId: string]: Status};
+
+// TODO(TS): maybe convert status to an enum
+interface Status {
+ status: string;
+ msg?: string;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-confirm-cherrypick-dialog': GrConfirmCherrypickDialog;
+ }
+}
+
+// TODO(TS): add type after gr-autocomplete and gr-rest-api-interface
+// is converted
+export interface GrConfirmCherrypickDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ branchInput: GrAutocomplete;
+ };
+}
+
+@customElement('gr-confirm-cherrypick-dialog')
+export class GrConfirmCherrypickDialog 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: String})
+ branch?: BranchName;
+
+ @property({type: String})
+ baseCommit?: string;
+
+ @property({type: String})
+ changeStatus?: ChangeStatus;
+
+ @property({type: String})
+ commitMessage?: string;
+
+ @property({type: String})
+ commitNum?: CommitId;
+
+ @property({type: String})
+ message?: string;
+
+ @property({type: String})
+ project?: RepoName;
+
+ @property({type: Array})
+ changes: ChangeInfo[] = [];
+
+ @property({type: Object})
+ _query: (input: string) => Promise<AutocompleteSuggestion[]>;
+
+ @property({type: Boolean})
+ _showCherryPickTopic = false;
+
+ @property({type: Number})
+ _changesCount?: number;
+
+ @property({type: Number})
+ _cherryPickType = CherryPickType.SINGLE_CHANGE;
+
+ @property({type: Boolean})
+ _duplicateProjectChanges = false;
+
+ @property({type: Object})
+ // Status of each change that is being cherry picked together
+ _statuses: Statuses;
+
+ @property({type: Boolean})
+ _invalidBranch = false;
+
+ @property({type: Object})
+ reporting: ReportingService;
+
+ constructor() {
+ super();
+ this._statuses = {};
+ this.reporting = appContext.reportingService;
+ this._query = (text: string) => this._getProjectBranchesSuggestions(text);
+ }
+
+ updateChanges(changes: ChangeInfo[]) {
+ this.changes = changes;
+ this._statuses = {};
+ const projects: {[projectName: string]: boolean} = {};
+ this._duplicateProjectChanges = false;
+ changes.forEach(change => {
+ if (projects[change.project]) {
+ this._duplicateProjectChanges = true;
+ }
+ projects[change.project] = true;
+ });
+ this._changesCount = changes.length;
+ this._showCherryPickTopic = changes.length > 1;
+ }
+
+ @observe('branch')
+ _updateBranch(branch: string) {
+ const invalidChars = [',', ' '];
+ this._invalidBranch = !!(
+ branch && invalidChars.some(c => branch.includes(c))
+ );
+ }
+
+ _computeTopicErrorMessage(duplicateProjectChanges: boolean) {
+ if (duplicateProjectChanges) {
+ return 'Two changes cannot be of the same project';
+ }
+ return '';
+ }
+
+ updateStatus(change: ChangeInfo, status: Status) {
+ this._statuses = {...this._statuses, [change.id]: status};
+ }
+
+ _computeStatus(change: ChangeInfo, statuses: Statuses) {
+ if (!change || !statuses || !statuses[change.id]) return 'NOT STARTED';
+ return statuses[change.id].status;
+ }
+
+ _computeStatusClass(change: ChangeInfo, statuses: Statuses) {
+ if (!change || !statuses || !statuses[change.id]) return '';
+ return statuses[change.id].status === 'FAILED' ? 'error' : '';
+ }
+
+ _computeError(change: ChangeInfo, statuses: Statuses) {
+ if (!change || !statuses || !statuses[change.id]) return '';
+ if (statuses[change.id].status === 'FAILED') {
+ return statuses[change.id].msg;
+ }
+ return '';
+ }
+
+ _getChangeId(change: ChangeInfo) {
+ return change.change_id.substring(0, 10);
+ }
+
+ _getTrimmedChangeSubject(subject: string) {
+ if (!subject) return '';
+ if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+ return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+ }
+
+ _computeCancelLabel(statuses: Statuses) {
+ const isRunningChange = Object.values(statuses).some(
+ v => v.status === 'RUNNING'
+ );
+ return isRunningChange ? 'Close' : 'Cancel';
+ }
+
+ _computeDisableCherryPick(
+ cherryPickType: CherryPickType,
+ duplicateProjectChanges: boolean,
+ statuses: Statuses
+ ) {
+ const duplicateProject =
+ cherryPickType === CherryPickType.TOPIC && duplicateProjectChanges;
+ if (duplicateProject) return true;
+ if (!statuses) return false;
+ const isRunningChange = Object.values(statuses).some(
+ v => v.status === 'RUNNING'
+ );
+ return isRunningChange;
+ }
+
+ _computeIfSinglecherryPick(cherryPickType: CherryPickType) {
+ return cherryPickType === CherryPickType.SINGLE_CHANGE;
+ }
+
+ _computeIfCherryPickTopic(cherryPickType: CherryPickType) {
+ return cherryPickType === CherryPickType.TOPIC;
+ }
+
+ _handlecherryPickSingleChangeClicked() {
+ this._cherryPickType = CherryPickType.SINGLE_CHANGE;
+ }
+
+ _handlecherryPickTopicClicked() {
+ this._cherryPickType = CherryPickType.TOPIC;
+ }
+
+ @observe('changeStatus', 'commitNum', 'commitMessage')
+ _computeMessage(
+ changeStatus?: string,
+ commitNum?: number,
+ commitMessage?: string
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ changeStatus === undefined ||
+ commitNum === undefined ||
+ commitMessage === undefined
+ ) {
+ return;
+ }
+
+ let newMessage = commitMessage;
+
+ if (changeStatus === 'MERGED') {
+ newMessage += '(cherry picked from commit ' + commitNum.toString() + ')';
+ }
+ this.message = newMessage;
+ }
+
+ _generateRandomCherryPickTopic(change: ChangeInfo) {
+ const randomString = Math.random().toString(36).substr(2, 10);
+ const message = `cherrypick-${change.topic}-${randomString}`;
+ return message;
+ }
+
+ _handleCherryPickFailed(change: ChangeInfo, response?: Response | null) {
+ if (!response) return;
+ response.text().then((errText: string) => {
+ this.updateStatus(change, {status: 'FAILED', msg: errText});
+ });
+ }
+
+ _handleCherryPickTopic() {
+ const topic = this._generateRandomCherryPickTopic(this.changes[0]);
+ this.changes.forEach(change => {
+ this.updateStatus(change, {status: 'RUNNING'});
+ const payload = {
+ destination: this.branch,
+ base: null,
+ topic,
+ allow_conflicts: true,
+ allow_empty: true,
+ };
+ const handleError = (response?: Response | null) => {
+ this._handleCherryPickFailed(change, response);
+ };
+ // revisions and current_revision must exist hence casting
+ const patchNum = change.revisions![change.current_revision!]._number;
+ this.$.restAPI
+ .executeChangeAction(
+ change._number,
+ HttpMethod.POST,
+ '/cherrypick',
+ patchNum,
+ payload,
+ handleError
+ )
+ .then(() => {
+ this.updateStatus(change, {status: 'SUCCESSFUL'});
+ const failedOrPending = Object.values(this._statuses).find(
+ v => v.status !== 'SUCCESSFUL'
+ );
+ if (!failedOrPending) {
+ /* This needs some more work, as the new topic may not always be
+ created, instead we may end up creating a new patchset */
+ GerritNav.navigateToSearchQuery(`topic: "${topic}"`);
+ }
+ });
+ });
+ }
+
+ _handleConfirmTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (this._cherryPickType === CherryPickType.TOPIC) {
+ this.reporting.reportInteraction('cherry-pick-topic-clicked', {});
+ this._handleCherryPickTopic();
+ return;
+ }
+ // Cherry pick single change
+ this.dispatchEvent(
+ new CustomEvent('confirm', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleCancelTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ resetFocus() {
+ this.$.branchInput.focus();
+ }
+
+ _getProjectBranchesSuggestions(
+ input: string
+ ): Promise<AutocompleteSuggestion[]> {
+ if (!this.project) {
+ console.error('no project specified');
+ return Promise.resolve([]);
+ }
+ if (input.startsWith('refs/heads/')) {
+ input = input.substring('refs/heads/'.length);
+ }
+ return this.$.restAPI
+ .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+ .then((response: BranchInfo[] | undefined) => {
+ const branches = [];
+ if (!response) return [];
+ let branch;
+ for (const key in response) {
+ if (!hasOwnProperty(response, key)) {
+ continue;
+ }
+ if (response[key].ref.startsWith('refs/heads/')) {
+ branch = response[key].ref.substring('refs/heads/'.length);
+ } else {
+ branch = response[key].ref;
+ }
+ branches.push({
+ name: branch,
+ });
+ }
+ return branches;
+ });
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
index 900412a..07f8f63 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.js
@@ -52,7 +52,7 @@
element.commitMessage = 'message\n';
element.commitNum = '123';
element.branch = 'master';
- flushAsynchronousOperations();
+ flush();
const expectedMessage = 'message\n(cherry picked from commit 123)';
assert.equal(element.message, expectedMessage);
});
@@ -62,7 +62,7 @@
element.commitMessage = 'message\n';
element.commitNum = '123';
element.branch = 'master';
- flushAsynchronousOperations();
+ flush();
const expectedMessage = 'message\n';
assert.equal(element.message, expectedMessage);
});
@@ -74,7 +74,7 @@
element.branch = 'master';
const myNewMessage = 'updated commit message';
element.message = myNewMessage;
- flushAsynchronousOperations();
+ flush();
assert.equal(element.message, myNewMessage);
});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
deleted file mode 100644
index 61cd78d2b3..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
+++ /dev/null
@@ -1,111 +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/shared-styles.js';
-import '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-move-dialog_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-const SUGGESTIONS_LIMIT = 15;
-
-/**
- * @extends PolymerElement
- */
-class GrConfirmMoveDialog extends KeyboardShortcutMixin(
- GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-confirm-move-dialog'; }
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- static get properties() {
- return {
- branch: String,
- message: String,
- project: String,
- _query: {
- type: Function,
- value() {
- return this._getProjectBranchesSuggestions.bind(this);
- },
- },
- };
- }
-
- get keyBindings() {
- return {
- 'ctrl+enter meta+enter': '_handleConfirmTap',
- };
- }
-
- _handleConfirmTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm', {
- composed: true, bubbles: false,
- }));
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {
- composed: true, bubbles: false,
- }));
- }
-
- _getProjectBranchesSuggestions(input) {
- if (input.startsWith('refs/heads/')) {
- input = input.substring('refs/heads/'.length);
- }
- return this.$.restAPI.getRepoBranches(
- input, this.project, SUGGESTIONS_LIMIT).then(response => {
- const branches = [];
- let branch;
- for (const key in response) {
- if (!response.hasOwnProperty(key)) { continue; }
- if (response[key].ref.startsWith('refs/heads/')) {
- branch = response[key].ref.substring('refs/heads/'.length);
- } else {
- branch = response[key].ref;
- }
- branches.push({
- name: branch,
- });
- }
- return branches;
- });
- }
-}
-
-customElements.define(GrConfirmMoveDialog.is, GrConfirmMoveDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
new file mode 100644
index 0000000..e9cf19e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.ts
@@ -0,0 +1,138 @@
+/**
+ * @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/shared-styles';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-rest-api-interface/gr-rest-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-move-dialog_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {RepoName, BranchName} from '../../../types/common';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+const SUGGESTIONS_LIMIT = 15;
+
+export interface GrConfirmMoveDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-confirm-move-dialog')
+export class GrConfirmMoveDialog extends KeyboardShortcutMixin(
+ 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: String})
+ branch?: BranchName;
+
+ @property({type: String})
+ message?: string;
+
+ @property({type: String})
+ project?: RepoName;
+
+ @property({type: Object})
+ _query?: (_text?: string) => Promise<AutocompleteSuggestion[]>;
+
+ get keyBindings() {
+ return {
+ 'ctrl+enter meta+enter': '_handleConfirmTap',
+ };
+ }
+
+ constructor() {
+ super();
+ this._query = () => this._getProjectBranchesSuggestions();
+ }
+
+ _handleConfirmTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('confirm', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleCancelTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _getProjectBranchesSuggestions(
+ input?: string
+ ): Promise<AutocompleteSuggestion[]> {
+ if (!this.project) return Promise.reject(new Error('Missing project'));
+ if (!input) return Promise.reject(new Error('Missing input'));
+ if (input.startsWith('refs/heads/')) {
+ input = input.substring('refs/heads/'.length);
+ }
+ return this.$.restAPI
+ .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
+ .then(response => {
+ const branches: AutocompleteSuggestion[] = [];
+ let branch;
+ if (response) {
+ response.forEach(value => {
+ if (value.ref.startsWith('refs/heads/')) {
+ branch = value.ref.substring('refs/heads/'.length);
+ } else {
+ branch = value.ref;
+ }
+ branches.push({
+ name: branch,
+ });
+ });
+ }
+
+ return branches;
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-confirm-move-dialog': GrConfirmMoveDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
index 0241112..43bde75 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
@@ -35,7 +35,7 @@
},
]);
} else {
- return Promise.resolve({});
+ return Promise.resolve(undefined);
}
},
});
@@ -47,7 +47,7 @@
element.branch = 'master';
const myNewMessage = 'updated commit message';
element.message = myNewMessage;
- flushAsynchronousOperations();
+ flush();
assert.equal(element.message, myNewMessage);
});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
deleted file mode 100644
index bfcc477..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
+++ /dev/null
@@ -1,177 +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-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.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-rebase-dialog_html.js';
-
-/** @extends PolymerElement */
-class GrConfirmRebaseDialog extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-confirm-rebase-dialog'; }
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- static get properties() {
- return {
- branch: String,
- changeNumber: Number,
- hasParent: Boolean,
- rebaseOnCurrent: Boolean,
- _text: String,
- _query: {
- type: Function,
- value() {
- return this._getChangeSuggestions.bind(this);
- },
- },
- _recentChanges: Array,
- };
- }
-
- static get observers() {
- return [
- '_updateSelectedOption(rebaseOnCurrent, hasParent)',
- ];
- }
-
- // This is called by gr-change-actions every time the rebase dialog is
- // re-opened. Unlike other autocompletes that make a request with each
- // updated input, this one gets all recent changes once and then filters
- // them by the input. The query is re-run each time the dialog is opened
- // in case there are new/updated changes in the generic query since the
- // last time it was run.
- fetchRecentChanges() {
- return this.$.restAPI.getChanges(null, `is:open -age:90d`)
- .then(response => {
- const changes = [];
- for (const key in response) {
- if (!response.hasOwnProperty(key)) { continue; }
- changes.push({
- name: `${response[key]._number}: ${response[key].subject}`,
- value: response[key]._number,
- });
- }
- this._recentChanges = changes;
- return this._recentChanges;
- });
- }
-
- _getRecentChanges() {
- if (this._recentChanges) {
- return Promise.resolve(this._recentChanges);
- }
- return this.fetchRecentChanges();
- }
-
- _getChangeSuggestions(input) {
- return this._getRecentChanges().then(changes =>
- this._filterChanges(input, changes));
- }
-
- _filterChanges(input, changes) {
- return changes.filter(change => change.name.includes(input) &&
- change.value !== this.changeNumber);
- }
-
- _displayParentOption(rebaseOnCurrent, hasParent) {
- return hasParent && rebaseOnCurrent;
- }
-
- _displayParentUpToDateMsg(rebaseOnCurrent, hasParent) {
- return hasParent && !rebaseOnCurrent;
- }
-
- _displayTipOption(rebaseOnCurrent, hasParent) {
- return !(!rebaseOnCurrent && !hasParent);
- }
-
- /**
- * There is a subtle but important difference between setting the base to an
- * empty string and omitting it entirely from the payload. An empty string
- * implies that the parent should be cleared and the change should be
- * rebased on top of the target branch. Leaving out the base implies that it
- * should be rebased on top of its current parent.
- */
- _getSelectedBase() {
- if (this.$.rebaseOnParentInput.checked) { return null; }
- if (this.$.rebaseOnTipInput.checked) { return ''; }
- // Change numbers will have their description appended by the
- // autocomplete.
- return this._text.split(':')[0];
- }
-
- _handleConfirmTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm',
- {detail: {base: this._getSelectedBase()}}));
- this._text = '';
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel'));
- this._text = '';
- }
-
- _handleRebaseOnOther() {
- this.$.parentInput.focus();
- }
-
- _handleEnterChangeNumberClick() {
- this.$.rebaseOnOtherInput.checked = true;
- }
-
- /**
- * Sets the default radio button based on the state of the app and
- * the corresponding value to be submitted.
- */
- _updateSelectedOption(rebaseOnCurrent, hasParent) {
- // Polymer 2: check for undefined
- if ([rebaseOnCurrent, hasParent].includes(undefined)) {
- return;
- }
-
- if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
- this.$.rebaseOnParentInput.checked = true;
- } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
- this.$.rebaseOnTipInput.checked = true;
- } else {
- this.$.rebaseOnOtherInput.checked = true;
- }
- }
-}
-
-customElements.define(GrConfirmRebaseDialog.is, GrConfirmRebaseDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
new file mode 100644
index 0000000..db0e1ff
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.ts
@@ -0,0 +1,241 @@
+/**
+ * @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-autocomplete/gr-autocomplete';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+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-rebase-dialog_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {NumericChangeId, BranchName} from '../../../types/common';
+import {
+ GrAutocomplete,
+ AutocompleteQuery,
+ AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+interface RebaseChange {
+ name: string;
+ value: NumericChangeId;
+}
+
+export interface ConfirmRebaseEventDetail {
+ base: string | null;
+}
+
+export interface GrConfirmRebaseDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ parentInput: GrAutocomplete;
+ rebaseOnParentInput: HTMLInputElement;
+ rebaseOnOtherInput: HTMLInputElement;
+ rebaseOnTipInput: HTMLInputElement;
+ };
+}
+
+@customElement('gr-confirm-rebase-dialog')
+export class GrConfirmRebaseDialog 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: String})
+ branch?: BranchName;
+
+ @property({type: Number})
+ changeNumber?: NumericChangeId;
+
+ @property({type: Boolean})
+ hasParent?: boolean;
+
+ @property({type: Boolean})
+ rebaseOnCurrent?: boolean;
+
+ @property({type: String})
+ _text?: string;
+
+ @property({type: Object})
+ _query?: AutocompleteQuery;
+
+ @property({type: Array})
+ _recentChanges?: RebaseChange[];
+
+ constructor() {
+ super();
+ this._query = input => this._getChangeSuggestions(input);
+ }
+
+ // This is called by gr-change-actions every time the rebase dialog is
+ // re-opened. Unlike other autocompletes that make a request with each
+ // updated input, this one gets all recent changes once and then filters
+ // them by the input. The query is re-run each time the dialog is opened
+ // in case there are new/updated changes in the generic query since the
+ // last time it was run.
+ fetchRecentChanges() {
+ return this.$.restAPI
+ .getChanges(undefined, 'is:open -age:90d')
+ .then(response => {
+ if (!response) return [];
+ const changes: RebaseChange[] = [];
+ for (const key in response) {
+ if (!hasOwnProperty(response, key)) {
+ continue;
+ }
+ changes.push({
+ name: `${response[key]._number}: ${response[key].subject}`,
+ value: response[key]._number,
+ });
+ }
+ this._recentChanges = changes;
+ return this._recentChanges;
+ });
+ }
+
+ _getRecentChanges() {
+ if (this._recentChanges) {
+ return Promise.resolve(this._recentChanges);
+ }
+ return this.fetchRecentChanges();
+ }
+
+ _getChangeSuggestions(input: string) {
+ return this._getRecentChanges().then(changes =>
+ this._filterChanges(input, changes)
+ );
+ }
+
+ _filterChanges(
+ input: string,
+ changes: RebaseChange[]
+ ): AutocompleteSuggestion[] {
+ return changes
+ .filter(
+ change =>
+ change.name.includes(input) && change.value !== this.changeNumber
+ )
+ .map(
+ change =>
+ ({
+ name: change.name,
+ value: `${change.value}`,
+ } as AutocompleteSuggestion)
+ );
+ }
+
+ _displayParentOption(rebaseOnCurrent: boolean, hasParent: boolean) {
+ return hasParent && rebaseOnCurrent;
+ }
+
+ _displayParentUpToDateMsg(rebaseOnCurrent: boolean, hasParent: boolean) {
+ return hasParent && !rebaseOnCurrent;
+ }
+
+ _displayTipOption(rebaseOnCurrent: boolean, hasParent: boolean) {
+ return !(!rebaseOnCurrent && !hasParent);
+ }
+
+ /**
+ * There is a subtle but important difference between setting the base to an
+ * empty string and omitting it entirely from the payload. An empty string
+ * implies that the parent should be cleared and the change should be
+ * rebased on top of the target branch. Leaving out the base implies that it
+ * should be rebased on top of its current parent.
+ */
+ _getSelectedBase() {
+ if (this.$.rebaseOnParentInput.checked) {
+ return null;
+ }
+ if (this.$.rebaseOnTipInput.checked) {
+ return '';
+ }
+ if (!this._text) {
+ return '';
+ }
+ // Change numbers will have their description appended by the
+ // autocomplete.
+ return this._text.split(':')[0];
+ }
+
+ _handleConfirmTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ const detail: ConfirmRebaseEventDetail = {
+ base: this._getSelectedBase(),
+ };
+ this.dispatchEvent(new CustomEvent('confirm', {detail}));
+ this._text = '';
+ }
+
+ _handleCancelTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(new CustomEvent('cancel'));
+ this._text = '';
+ }
+
+ _handleRebaseOnOther() {
+ this.$.parentInput.focus();
+ }
+
+ _handleEnterChangeNumberClick() {
+ this.$.rebaseOnOtherInput.checked = true;
+ }
+
+ /**
+ * Sets the default radio button based on the state of the app and
+ * the corresponding value to be submitted.
+ */
+ @observe('rebaseOnCurrent', 'hasParent')
+ _updateSelectedOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
+ // Polymer 2: check for undefined
+ if (rebaseOnCurrent === undefined || hasParent === undefined) {
+ return;
+ }
+
+ if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
+ this.$.rebaseOnParentInput.checked = true;
+ } else if (this._displayTipOption(rebaseOnCurrent, hasParent)) {
+ this.$.rebaseOnTipInput.checked = true;
+ } else {
+ this.$.rebaseOnOtherInput.checked = true;
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-confirm-rebase-dialog': GrConfirmRebaseDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
index 498d31c..8bce572 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
@@ -30,7 +30,7 @@
test('controls with parent and rebase on current available', () => {
element.rebaseOnCurrent = true;
element.hasParent = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.rebaseOnParentInput.checked);
assert.isFalse(element.$.rebaseOnParent.hasAttribute('hidden'));
assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
@@ -41,7 +41,7 @@
test('controls with parent rebase on current not available', () => {
element.rebaseOnCurrent = false;
element.hasParent = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.rebaseOnTipInput.checked);
assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
assert.isFalse(element.$.parentUpToDateMsg.hasAttribute('hidden'));
@@ -52,7 +52,7 @@
test('controls without parent and rebase on current available', () => {
element.rebaseOnCurrent = true;
element.hasParent = false;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.rebaseOnTipInput.checked);
assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
@@ -63,7 +63,7 @@
test('controls without parent rebase on current not available', () => {
element.rebaseOnCurrent = false;
element.hasParent = false;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.rebaseOnOtherInput.checked);
assert.isTrue(element.$.rebaseOnParent.hasAttribute('hidden'));
assert.isTrue(element.$.parentUpToDateMsg.hasAttribute('hidden'));
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..5c0b19f
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -0,0 +1,257 @@
+/**
+ * @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
+export enum RevertType {
+ REVERT_SINGLE_CHANGE = 1,
+ REVERT_SUBMISSION = 2,
+}
+
+export interface ConfirmRevertEventDetail {
+ revertType: RevertType;
+ message?: string;
+}
+
+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 = RevertType.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 === RevertType.REVERT_SINGLE_CHANGE;
+ }
+
+ _computeIfRevertSubmission(revertType: number) {
+ return revertType === RevertType.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 = RevertType.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 = RevertType.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[RevertType.REVERT_SUBMISSION] = this._message;
+ this._message = this._revertMessages[RevertType.REVERT_SINGLE_CHANGE];
+ this._revertType = RevertType.REVERT_SINGLE_CHANGE;
+ }
+
+ _handleRevertSubmissionClicked() {
+ this._showErrorMessage = false;
+ this._revertType = RevertType.REVERT_SUBMISSION;
+ if (this._message)
+ this._revertMessages[RevertType.REVERT_SINGLE_CHANGE] = this._message;
+ this._message = this._revertMessages[RevertType.REVERT_SUBMISSION];
+ }
+
+ _handleConfirmTap(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (this._message === this._originalRevertMessages[this._revertType]) {
+ this._showErrorMessage = true;
+ return;
+ }
+ const detail: ConfirmRevertEventDetail = {
+ revertType: this._revertType,
+ message: this._message,
+ };
+ this.dispatchEvent(
+ new CustomEvent('confirm', {
+ detail,
+ 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-revert-submission-dialog/gr-confirm-revert-submission-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
deleted file mode 100644
index a639a61..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.js
+++ /dev/null
@@ -1,114 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 '../../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-submission-dialog_html.js';
-
-const ERR_COMMIT_NOT_FOUND =
- 'Unable to find the commit hash of this change.';
-const CHANGE_SUBJECT_LIMIT = 50;
-
-/**
- * @extends PolymerElement
- */
-class GrConfirmRevertSubmissionDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-confirm-revert-submission-dialog'; }
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- static get properties() {
- return {
- message: String,
- commitMessage: String,
- };
- }
-
- _getTrimmedChangeSubject(subject) {
- if (!subject) return '';
- if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
- return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
- }
-
- _modifyRevertSubmissionMsg(change) {
- return this.$.jsAPI.modifyRevertSubmissionMsg(change,
- this.message, this.commitMessage);
- }
-
- _populateRevertSubmissionMessage(message, change, changes) {
- if (change === undefined) {
- return;
- }
- // 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;
- }
- const submissionId = change.submission_id;
- const revertTitle = 'Revert submission ' + submissionId;
- this.changes = changes;
- this.message = revertTitle + '\n\n' +
- 'Reason for revert: <INSERT REASONING HERE>\n';
- changes = changes || [];
- if (changes.length) {
- 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);
- }
-
- _handleConfirmTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm', {
- composed: true, bubbles: false,
- }));
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {
- composed: true, bubbles: false,
- }));
- }
-}
-
-customElements.define(GrConfirmRevertSubmissionDialog.is,
- GrConfirmRevertSubmissionDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
new file mode 100644
index 0000000..9754e89
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
@@ -0,0 +1,140 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 '../../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-submission-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {ChangeInfo} from '../../../types/common';
+
+const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
+const CHANGE_SUBJECT_LIMIT = 50;
+
+export interface GrConfirmRevertSubmissionDialog {
+ $: {
+ jsAPI: JsApiService & Element;
+ };
+}
+@customElement('gr-confirm-revert-submission-dialog')
+export class GrConfirmRevertSubmissionDialog 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: String})
+ message?: string;
+
+ @property({type: String})
+ commitMessage?: string;
+
+ _getTrimmedChangeSubject(subject: string) {
+ if (!subject) return '';
+ if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
+ return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
+ }
+
+ _modifyRevertSubmissionMsg(change?: ChangeInfo) {
+ if (!change || !this.message || !this.commitMessage) {
+ return this.message;
+ }
+ return this.$.jsAPI.modifyRevertSubmissionMsg(
+ change,
+ this.message,
+ this.commitMessage
+ );
+ }
+
+ _populateRevertSubmissionMessage(
+ change?: ChangeInfo,
+ changes?: ChangeInfo[]
+ ) {
+ if (change === undefined) {
+ return;
+ }
+ // 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;
+ }
+ const revertTitle = `Revert submission ${change.submission_id}`;
+ this.message =
+ revertTitle + '\n\n' + 'Reason for revert: <INSERT REASONING HERE>\n';
+ changes = changes || [];
+ if (changes.length) {
+ 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);
+ }
+
+ _handleConfirmTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('confirm', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleCancelTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-confirm-revert-submission-dialog': GrConfirmRevertSubmissionDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
index e2f2e9e..1ed799f 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
@@ -40,7 +40,6 @@
test('single line', () => {
assert.isNotOk(element.message);
element._populateRevertSubmissionMessage(
- 'one line commit\n\nChange-Id: abcdefg\n',
{current_revision: 'abcd123', submission_id: '111'});
const expected = 'Revert submission 111\n\n' +
'Reason for revert: <INSERT REASONING HERE>\n';
@@ -50,7 +49,6 @@
test('multi line', () => {
assert.isNotOk(element.message);
element._populateRevertSubmissionMessage(
- 'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
{current_revision: 'abcd123', submission_id: '111'});
const expected = 'Revert submission 111\n\n' +
'Reason for revert: <INSERT REASONING HERE>\n';
@@ -60,7 +58,6 @@
test('issue above change id', () => {
assert.isNotOk(element.message);
element._populateRevertSubmissionMessage(
- 'test \nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
{current_revision: 'abcd123', submission_id: '111'});
const expected = 'Revert submission 111\n\n' +
'Reason for revert: <INSERT REASONING HERE>\n';
@@ -70,7 +67,6 @@
test('revert a revert', () => {
assert.isNotOk(element.message);
element._populateRevertSubmissionMessage(
- 'Revert "one line commit"\n\nChange-Id: abcdefg\n',
{current_revision: 'abcd123', submission_id: '111'});
const expected = 'Revert submission 111\n\n' +
'Reason for revert: <INSERT REASONING HERE>\n';
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-confirm-submit-dialog/gr-confirm-submit-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
index 77331f7..e16ffdb 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.js
@@ -33,7 +33,7 @@
subject: 'my-subject',
revisions: {},
};
- flushAsynchronousOperations();
+ flush();
const header = element.shadowRoot
.querySelector('.header');
assert.equal(header.textContent.trim(), 'my-label');
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
deleted file mode 100644
index 8c11129..0000000
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ /dev/null
@@ -1,228 +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 '../../../styles/shared-styles.js';
-import '../../shared/gr-download-commands/gr-download-commands.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-download-dialog_html.js';
-import {patchNumEquals} from '../../../utils/patch-set-util.js';
-import {changeBaseURL} from '../../../utils/change-util.js';
-
-/**
- * @extends PolymerElement
- */
-class GrDownloadDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-download-dialog'; }
- /**
- * Fired when the user presses the close button.
- *
- * @event close
- */
-
- static get properties() {
- return {
- /** @type {{ revisions: Array }} */
- change: Object,
- patchNum: String,
- /** @type {?} */
- config: Object,
-
- _schemes: {
- type: Array,
- value() { return []; },
- computed: '_computeSchemes(change, patchNum)',
- observer: '_schemesChanged',
- },
- _selectedScheme: String,
- };
- }
-
- /** @override */
- ready() {
- super.ready();
- this._ensureAttribute('role', 'dialog');
- }
-
- focus() {
- if (this._schemes.length) {
- this.$.downloadCommands.focusOnCopy();
- } else {
- this.$.download.focus();
- }
- }
-
- getFocusStops() {
- const links = this.shadowRoot
- .querySelector('#archives').querySelectorAll('a');
- return {
- start: this.$.closeButton,
- end: links[links.length - 1],
- };
- }
-
- _computeDownloadCommands(change, patchNum, _selectedScheme) {
- let commandObj;
- if (!change) return [];
- for (const rev of Object.values(change.revisions || {})) {
- if (patchNumEquals(rev._number, patchNum) &&
- rev && rev.fetch && rev.fetch.hasOwnProperty(_selectedScheme)) {
- commandObj = rev.fetch[_selectedScheme].commands;
- break;
- }
- }
- const commands = [];
- for (const title in commandObj) {
- if (!commandObj || !commandObj.hasOwnProperty(title)) { continue; }
- commands.push({
- title,
- command: commandObj[title],
- });
- }
- return commands;
- }
-
- /**
- * @param {!Object} change
- * @param {number|string} patchNum
- *
- * @return {string}
- */
- _computeZipDownloadLink(change, patchNum) {
- return this._computeDownloadLink(change, patchNum, true);
- }
-
- /**
- * @param {!Object} change
- * @param {number|string} patchNum
- *
- * @return {string}
- */
- _computeZipDownloadFilename(change, patchNum) {
- return this._computeDownloadFilename(change, patchNum, true);
- }
-
- /**
- * @param {!Object} change
- * @param {number|string} patchNum
- * @param {boolean=} opt_zip
- *
- * @return {string} Not sure why there was a mismatch
- */
- _computeDownloadLink(change, patchNum, opt_zip) {
- // Polymer 2: check for undefined
- if ([change, patchNum].includes(undefined)) {
- return '';
- }
- return changeBaseURL(change.project, change._number, patchNum) +
- '/patch?' + (opt_zip ? 'zip' : 'download');
- }
-
- /**
- * @param {!Object} change
- * @param {number|string} patchNum
- * @param {boolean=} opt_zip
- *
- * @return {string}
- */
- _computeDownloadFilename(change, patchNum, opt_zip) {
- // Polymer 2: check for undefined
- if ([change, patchNum].includes(undefined)) {
- return '';
- }
-
- let shortRev = '';
- for (const rev in change.revisions) {
- if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
- shortRev = rev.substr(0, 7);
- break;
- }
- }
- return shortRev + '.diff.' + (opt_zip ? 'zip' : 'base64');
- }
-
- _computeHidePatchFile(change, patchNum) {
- // Polymer 2: check for undefined
- if ([change, patchNum].includes(undefined)) {
- return false;
- }
- for (const rev of Object.values(change.revisions || {})) {
- if (patchNumEquals(rev._number, patchNum)) {
- const parentLength = rev.commit && rev.commit.parents ?
- rev.commit.parents.length : 0;
- return parentLength == 0;
- }
- }
- return false;
- }
-
- _computeArchiveDownloadLink(change, patchNum, format) {
- // Polymer 2: check for undefined
- if ([change, patchNum, format].includes(undefined)) {
- return '';
- }
- return changeBaseURL(change.project, change._number, patchNum) +
- '/archive?format=' + format;
- }
-
- _computeSchemes(change, patchNum) {
- // Polymer 2: check for undefined
- if ([change, patchNum].includes(undefined)) {
- return [];
- }
-
- for (const rev of Object.values(change.revisions || {})) {
- if (patchNumEquals(rev._number, patchNum)) {
- const fetch = rev.fetch;
- if (fetch) {
- return Object.keys(fetch).sort();
- }
- break;
- }
- }
- return [];
- }
-
- _computePatchSetQuantity(revisions) {
- if (!revisions) { return 0; }
- return Object.keys(revisions).length;
- }
-
- _handleCloseTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('close', {
- composed: true, bubbles: false,
- }));
- }
-
- _schemesChanged(schemes) {
- if (schemes.length === 0) { return; }
- if (!schemes.includes(this._selectedScheme)) {
- this._selectedScheme = schemes.sort()[0];
- }
- }
-
- _computeShowDownloadCommands(schemes) {
- return schemes.length ? '' : 'hidden';
- }
-}
-
-customElements.define(GrDownloadDialog.is, GrDownloadDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
new file mode 100644
index 0000000..27d9756
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -0,0 +1,254 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../shared/gr-download-commands/gr-download-commands';
+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-download-dialog_html';
+import {patchNumEquals} from '../../../utils/patch-set-util';
+import {changeBaseURL} from '../../../utils/change-util';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {ChangeInfo, ServerInfo, PatchSetNum} from '../../../types/common';
+import {RevisionInfo} from '../../shared/revision-info/revision-info';
+import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
+
+export interface GrDownloadDialog {
+ $: {
+ download: HTMLAnchorElement;
+ downloadCommands: GrDownloadCommands;
+ closeButton: GrButton;
+ };
+}
+
+@customElement('gr-download-dialog')
+export class GrDownloadDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the user presses the close button.
+ *
+ * @event close
+ */
+
+ @property({type: Object})
+ change: ChangeInfo | undefined;
+
+ @property({type: Object})
+ config?: ServerInfo;
+
+ @property({type: String})
+ patchNum: PatchSetNum | undefined;
+
+ @property({type: String})
+ _selectedScheme?: string;
+
+ @computed('change', 'patchNum')
+ get _schemes() {
+ // Polymer 2: check for undefined
+ if (this.change === undefined || this.patchNum === undefined) {
+ return [];
+ }
+
+ for (const rev of Object.values(this.change.revisions || {})) {
+ if (patchNumEquals(rev._number, this.patchNum)) {
+ const fetch = rev.fetch;
+ if (fetch) {
+ return Object.keys(fetch).sort();
+ }
+ break;
+ }
+ }
+ return [];
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._ensureAttribute('role', 'dialog');
+ }
+
+ focus() {
+ if (this._schemes.length) {
+ this.$.downloadCommands.focusOnCopy();
+ } else {
+ this.$.download.focus();
+ }
+ }
+
+ getFocusStops(): GrOverlayStops {
+ return {
+ start: this.$.downloadCommands.$.downloadTabs,
+ end: this.$.closeButton,
+ };
+ }
+
+ _computeDownloadCommands(
+ change?: ChangeInfo,
+ patchNum?: PatchSetNum,
+ selectedScheme?: string
+ ) {
+ let commandObj;
+ if (!change || !selectedScheme) return [];
+ for (const rev of Object.values(change.revisions || {})) {
+ if (
+ patchNumEquals(rev._number, patchNum) &&
+ rev &&
+ rev.fetch &&
+ hasOwnProperty(rev.fetch, selectedScheme)
+ ) {
+ commandObj = rev.fetch[selectedScheme].commands;
+ break;
+ }
+ }
+ const commands = [];
+ for (const title in commandObj) {
+ if (!commandObj || !hasOwnProperty(commandObj, title)) {
+ continue;
+ }
+ commands.push({
+ title,
+ command: commandObj[title],
+ });
+ }
+ return commands;
+ }
+
+ _computeZipDownloadLink(change?: ChangeInfo, patchNum?: PatchSetNum) {
+ return this._computeDownloadLink(change, patchNum, true);
+ }
+
+ _computeZipDownloadFilename(change?: ChangeInfo, patchNum?: PatchSetNum) {
+ return this._computeDownloadFilename(change, patchNum, true);
+ }
+
+ _computeDownloadLink(
+ change?: ChangeInfo,
+ patchNum?: PatchSetNum,
+ zip?: boolean
+ ) {
+ // Polymer 2: check for undefined
+ if (change === undefined || patchNum === undefined) {
+ return '';
+ }
+ return (
+ changeBaseURL(change.project, change._number, patchNum) +
+ '/patch?' +
+ (zip ? 'zip' : 'download')
+ );
+ }
+
+ _computeDownloadFilename(
+ change?: ChangeInfo,
+ patchNum?: PatchSetNum,
+ zip?: boolean
+ ) {
+ // Polymer 2: check for undefined
+ if (change === undefined || patchNum === undefined) {
+ return '';
+ }
+
+ let shortRev = '';
+ for (const rev in change.revisions) {
+ if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
+ shortRev = rev.substr(0, 7);
+ break;
+ }
+ }
+ return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
+ }
+
+ _computeHidePatchFile(change?: ChangeInfo, patchNum?: PatchSetNum) {
+ // Polymer 2: check for undefined
+ if (change === undefined || patchNum === undefined) {
+ return false;
+ }
+ for (const rev of Object.values(change.revisions || {})) {
+ if (patchNumEquals(rev._number, patchNum)) {
+ const parentLength =
+ rev.commit && rev.commit.parents ? rev.commit.parents.length : 0;
+ return parentLength === 0 || parentLength > 1;
+ }
+ }
+ return false;
+ }
+
+ _computeArchiveDownloadLink(
+ change?: ChangeInfo,
+ patchNum?: PatchSetNum,
+ format?: string
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ change === undefined ||
+ patchNum === undefined ||
+ format === undefined
+ ) {
+ return '';
+ }
+ return (
+ changeBaseURL(change.project, change._number, patchNum) +
+ '/archive?format=' +
+ format
+ );
+ }
+
+ _computePatchSetQuantity(revisions?: {[revisionId: string]: RevisionInfo}) {
+ if (!revisions) {
+ return 0;
+ }
+ return Object.keys(revisions).length;
+ }
+
+ _handleCloseTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('close', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ @observe('_schemes')
+ _schemesChanged(schemes: string[]) {
+ if (schemes.length === 0) {
+ return;
+ }
+ if (!this._selectedScheme || !schemes.includes(this._selectedScheme)) {
+ this._selectedScheme = schemes.sort()[0];
+ }
+ }
+
+ _computeShowDownloadCommands(schemes: string[]) {
+ return schemes.length ? '' : 'hidden';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-download-dialog': GrDownloadDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
index 5dd5de7..7401026 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-download-dialog.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-download-dialog');
@@ -115,19 +114,19 @@
archives: ['tgz', 'tar', 'tbz2', 'txz'],
};
- flushAsynchronousOperations();
+ flush();
});
test('anchors use download attribute', () => {
const anchors = Array.from(
- dom(element.root).querySelectorAll('a'));
+ element.root.querySelectorAll('a'));
assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
});
suite('gr-download-dialog tests with no fetch options', () => {
setup(() => {
element.change = getChangeObjectNoFetch();
- flushAsynchronousOperations();
+ flush();
});
test('focuses on first download link if no copy links', () => {
@@ -141,13 +140,13 @@
suite('gr-download-dialog with fetch options', () => {
setup(() => {
element.change = getChangeObject();
- flushAsynchronousOperations();
+ flush();
});
test('focuses on first copy link', () => {
const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
element.focus();
- flushAsynchronousOperations();
+ flush();
assert.isTrue(focusStub.called);
focusStub.restore();
});
@@ -175,21 +174,33 @@
test('_computeHidePatchFile', () => {
const patchNum = '1';
- const change1 = {
+ const changeWithNoParent = {
revisions: {
r1: {_number: 1, commit: {parents: []}},
},
};
- assert.isTrue(element._computeHidePatchFile(change1, patchNum));
+ assert.isTrue(element._computeHidePatchFile(changeWithNoParent, patchNum));
- const change2 = {
+ const changeWithOneParent = {
revisions: {
r1: {_number: 1, commit: {parents: [
{commit: 'p1'},
]}},
},
};
- assert.isFalse(element._computeHidePatchFile(change2, patchNum));
+ assert.isFalse(
+ element._computeHidePatchFile(changeWithOneParent, patchNum));
+
+ const changeWithMultipleParents = {
+ revisions: {
+ r1: {_number: 1, commit: {parents: [
+ {commit: 'p1'},
+ {commit: 'p2'},
+ ]}},
+ },
+ };
+ assert.isTrue(
+ element._computeHidePatchFile(changeWithMultipleParents, patchNum));
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.js b/polygerrit-ui/app/elements/change/gr-file-list-constants.js
deleted file mode 100644
index 5bba786..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-constants.js
+++ /dev/null
@@ -1,25 +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.
- */
-
-export const GrFileListConstants = {
- FilesExpandedState: {
- ALL: 'all',
- NONE: 'none',
- SOME: 'some',
- },
-};
-
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-constants.ts b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
new file mode 100644
index 0000000..0e55494
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-constants.ts
@@ -0,0 +1,22 @@
+/**
+ * @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.
+ */
+
+export enum FilesExpandedState {
+ ALL = 'all',
+ NONE = 'none',
+ SOME = 'some',
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
deleted file mode 100644
index 05f2ab0..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
+++ /dev/null
@@ -1,302 +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/shared-styles.js';
-import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector.js';
-import '../../diff/gr-patch-range-select/gr-patch-range-select.js';
-import '../../edit/gr-edit-controls/gr-edit-controls.js';
-import '../../shared/gr-editable-label/gr-editable-label.js';
-import '../../shared/gr-linked-chip/gr-linked-chip.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-commit-info/gr-commit-info.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-file-list-header_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {
- computeLatestPatchNum,
- getRevisionByPatchNum,
- patchNumEquals,
-} from '../../../utils/patch-set-util.js';
-
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
-const MERGED_STATUS = 'MERGED';
-
-/**
- * @extends PolymerElement
- */
-class GrFileListHeader extends KeyboardShortcutMixin(
- GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-file-list-header'; }
- /**
- * @event expand-diffs
- */
-
- /**
- * @event collapse-diffs
- */
-
- /**
- * @event open-diff-prefs
- */
-
- /**
- * @event open-included-in-dialog
- */
-
- /**
- * @event open-download-dialog
- */
-
- /**
- * @event open-upload-help-dialog
- */
-
- static get properties() {
- return {
- account: Object,
- allPatchSets: Array,
- /** @type {?} */
- change: Object,
- changeNum: String,
- changeUrl: String,
- changeComments: Object,
- commitInfo: Object,
- editMode: Boolean,
- loggedIn: Boolean,
- serverConfig: Object,
- shownFileCount: Number,
- diffPrefs: Object,
- diffPrefsDisabled: Boolean,
- diffViewMode: {
- type: String,
- notify: true,
- },
- patchNum: String,
- basePatchNum: String,
- filesExpanded: String,
- // Caps the number of files that can be shown and have the 'show diffs' /
- // 'hide diffs' buttons still be functional.
- _maxFilesForBulkActions: {
- type: Number,
- readOnly: true,
- value: 225,
- },
- _patchsetDescription: {
- type: String,
- value: '',
- },
- _descriptionReadOnly: {
- type: Boolean,
- computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
- },
- revisionInfo: Object,
- };
- }
-
- static get observers() {
- return [
- '_computePatchSetDescription(change, patchNum)',
- ];
- }
-
- setDiffViewMode(mode) {
- this.$.modeSelect.setMode(mode);
- }
-
- _expandAllDiffs() {
- this._expanded = true;
- this.dispatchEvent(new CustomEvent('expand-diffs', {
- composed: true, bubbles: true,
- }));
- }
-
- _collapseAllDiffs() {
- this._expanded = false;
- this.dispatchEvent(new CustomEvent('collapse-diffs', {
- composed: true, bubbles: true,
- }));
- }
-
- _computeExpandedClass(filesExpanded) {
- const classes = [];
- if (filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
- classes.push('expanded');
- }
- if (filesExpanded === GrFileListConstants.FilesExpandedState.SOME ||
- filesExpanded === GrFileListConstants.FilesExpandedState.ALL) {
- classes.push('openFile');
- }
- return classes.join(' ');
- }
-
- _computeDescriptionPlaceholder(readOnly) {
- return (readOnly ? 'No' : 'Add') + ' patchset description';
- }
-
- _computeDescriptionReadOnly(loggedIn, change, account) {
- // Polymer 2: check for undefined
- if ([loggedIn, change, account].includes(undefined)) {
- return undefined;
- }
-
- return !(loggedIn && (account._account_id === change.owner._account_id));
- }
-
- _computePatchSetDescription(change, patchNum) {
- // Polymer 2: check for undefined
- if ([change, patchNum].includes(undefined)) {
- return;
- }
-
- const rev = getRevisionByPatchNum(change.revisions, patchNum);
- this._patchsetDescription = (rev && rev.description) ?
- rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
- }
-
- _handleDescriptionRemoved(e) {
- return this._updateDescription('', e);
- }
-
- /**
- * @param {!Object} revisions The revisions object keyed by revision hashes
- * @param {?Object} patchSet A revision already fetched from {revisions}
- * @return {string|undefined} the SHA hash corresponding to the revision.
- */
- _getPatchsetHash(revisions, patchSet) {
- for (const rev in revisions) {
- if (revisions.hasOwnProperty(rev) &&
- revisions[rev] === patchSet) {
- return rev;
- }
- }
- }
-
- _handleDescriptionChanged(e) {
- const desc = e.detail.trim();
- this._updateDescription(desc, e);
- }
-
- /**
- * Update the patchset description with the rest API.
- *
- * @param {string} desc
- * @param {?(Event|Node)} e
- * @return {!Promise}
- */
- _updateDescription(desc, e) {
- const target = dom(e).rootTarget;
- if (target) { target.disabled = true; }
- const rev = getRevisionByPatchNum(this.change.revisions,
- this.patchNum);
- const sha = this._getPatchsetHash(this.change.revisions, rev);
- return this.$.restAPI.setDescription(this.changeNum, this.patchNum, desc)
- .then(res => {
- if (res.ok) {
- if (target) { target.disabled = false; }
- this.set(['change', 'revisions', sha, 'description'], desc);
- this._patchsetDescription = desc;
- }
- })
- .catch(err => {
- if (target) { target.disabled = false; }
- return;
- });
- }
-
- _computePrefsButtonHidden(prefs, diffPrefsDisabled) {
- return diffPrefsDisabled || !prefs;
- }
-
- _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
- return shownFileCount <= maxFilesForBulkActions;
- }
-
- _handlePatchChange(e) {
- const {basePatchNum, patchNum} = e.detail;
- if (patchNumEquals(basePatchNum, this.basePatchNum) &&
- patchNumEquals(patchNum, this.patchNum)) { return; }
- GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
- }
-
- _handlePrefsTap(e) {
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('open-diff-prefs', {
- composed: true, bubbles: true,
- }));
- }
-
- _handleIncludedInTap(e) {
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('open-included-in-dialog', {
- composed: true, bubbles: true,
- }));
- }
-
- _handleDownloadTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('open-download-dialog', {bubbles: false}));
- }
-
- _computeEditModeClass(editMode) {
- return editMode ? 'editMode' : '';
- }
-
- _computePatchInfoClass(patchNum, allPatchSets) {
- const latestNum = computeLatestPatchNum(allPatchSets);
- if (patchNumEquals(patchNum, latestNum)) {
- return '';
- }
- return 'patchInfoOldPatchSet';
- }
-
- _hideIncludedIn(change) {
- return change && change.status === MERGED_STATUS ? '' : 'hide';
- }
-
- _handleUploadTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(
- new CustomEvent('open-upload-help-dialog', {bubbles: false}));
- }
-
- _computeUploadHelpContainerClass(change, account) {
- const changeIsMerged = change && change.status === MERGED_STATUS;
- const ownerId = change && change.owner && change.owner._account_id ?
- change.owner._account_id : null;
- const userId = account && account._account_id;
- const userIsOwner = ownerId && userId && ownerId === userId;
- const hideContainer = !userIsOwner || changeIsMerged;
- return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
- }
-}
-
-customElements.define(GrFileListHeader.is, GrFileListHeader);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
new file mode 100644
index 0000000..b86dd90
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.ts
@@ -0,0 +1,402 @@
+/**
+ * @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/shared-styles';
+import '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import '../../diff/gr-patch-range-select/gr-patch-range-select';
+import '../../edit/gr-edit-controls/gr-edit-controls';
+import '../../shared/gr-editable-label/gr-editable-label';
+import '../../shared/gr-linked-chip/gr-linked-chip';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-commit-info/gr-commit-info';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-file-list-header_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+ computeLatestPatchNum,
+ getRevisionByPatchNum,
+ patchNumEquals,
+ PatchSet,
+} from '../../../utils/patch-set-util';
+import {property, computed, observe, customElement} from '@polymer/decorators';
+import {
+ AccountInfo,
+ ChangeInfo,
+ PatchSetNum,
+ CommitInfo,
+ ServerInfo,
+ DiffPreferencesInfo,
+ RevisionInfo,
+ NumericChangeId,
+} from '../../../types/common';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {GrDiffModeSelector} from '../../diff/gr-diff-mode-selector/gr-diff-mode-selector';
+import {DiffViewMode} from '../../../constants/constants';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
+const MERGED_STATUS = 'MERGED';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-file-list-header': GrFileListHeader;
+ }
+}
+
+export interface GrFileListHeader {
+ $: {
+ modeSelect: GrDiffModeSelector;
+ restAPI: RestApiService & Element;
+ expandBtn: GrButton;
+ collapseBtn: GrButton;
+ };
+}
+
+@customElement('gr-file-list-header')
+export class GrFileListHeader extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * @event expand-diffs
+ */
+
+ /**
+ * @event collapse-diffs
+ */
+
+ /**
+ * @event open-diff-prefs
+ */
+
+ /**
+ * @event open-included-in-dialog
+ */
+
+ /**
+ * @event open-download-dialog
+ */
+
+ /**
+ * @event open-upload-help-dialog
+ */
+
+ @property({type: Object})
+ account: AccountInfo | undefined;
+
+ @property({type: Array})
+ allPatchSets?: PatchSet[];
+
+ @property({type: Object})
+ change: ChangeInfo | undefined;
+
+ @property({type: String})
+ changeNum?: NumericChangeId;
+
+ @property({type: String})
+ changeUrl?: string;
+
+ @property({type: Object})
+ changeComments?: ChangeComments;
+
+ @property({type: Object})
+ commitInfo?: CommitInfo;
+
+ @property({type: Boolean})
+ editMode?: boolean;
+
+ @property({type: Boolean})
+ loggedIn: boolean | undefined;
+
+ @property({type: Object})
+ serverConfig?: ServerInfo;
+
+ @property({type: Number})
+ shownFileCount?: number;
+
+ @property({type: Object})
+ diffPrefs?: DiffPreferencesInfo;
+
+ @property({type: Boolean})
+ diffPrefsDisabled?: boolean;
+
+ @property({type: String, notify: true})
+ diffViewMode?: DiffViewMode;
+
+ @property({type: String})
+ patchNum?: PatchSetNum;
+
+ @property({type: String})
+ basePatchNum?: PatchSetNum;
+
+ @property({type: String})
+ filesExpanded?: FilesExpandedState;
+
+ // Caps the number of files that can be shown and have the 'show diffs' /
+ // 'hide diffs' buttons still be functional.
+ @property({type: Number})
+ readonly _maxFilesForBulkActions = 225;
+
+ @property({type: String})
+ _patchsetDescription = '';
+
+ @property({type: Object})
+ revisionInfo?: RevisionInfo;
+
+ @computed('loggedIn', 'change', 'account')
+ get _descriptionReadOnly(): boolean {
+ if (
+ this.loggedIn === undefined ||
+ this.change === undefined ||
+ this.account === undefined
+ ) {
+ return true;
+ }
+
+ return !(
+ this.loggedIn &&
+ this.account._account_id === this.change.owner._account_id
+ );
+ }
+
+ setDiffViewMode(mode: DiffViewMode) {
+ this.$.modeSelect.setMode(mode);
+ }
+
+ _expandAllDiffs() {
+ this.dispatchEvent(
+ new CustomEvent('expand-diffs', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _collapseAllDiffs() {
+ this.dispatchEvent(
+ new CustomEvent('collapse-diffs', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _computeExpandedClass(filesExpanded: FilesExpandedState) {
+ const classes = [];
+ if (filesExpanded === FilesExpandedState.ALL) {
+ classes.push('expanded');
+ }
+ if (
+ filesExpanded === FilesExpandedState.SOME ||
+ filesExpanded === FilesExpandedState.ALL
+ ) {
+ classes.push('openFile');
+ }
+ return classes.join(' ');
+ }
+
+ _computeDescriptionPlaceholder(readOnly: boolean) {
+ return (readOnly ? 'No' : 'Add') + ' patchset description';
+ }
+
+ @observe('change', 'patchNum')
+ _computePatchSetDescription(change: ChangeInfo, patchNum: PatchSetNum) {
+ // Polymer 2: check for undefined
+ if (
+ change === undefined ||
+ change.revisions === undefined ||
+ patchNum === undefined
+ ) {
+ return;
+ }
+
+ const rev = getRevisionByPatchNum(
+ Object.values(change.revisions),
+ patchNum
+ );
+ this._patchsetDescription = rev?.description
+ ? rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
+ : '';
+ }
+
+ _handleDescriptionRemoved(e: CustomEvent) {
+ return this._updateDescription('', e);
+ }
+
+ /**
+ * @param revisions The revisions object keyed by revision hashes
+ * @param patchSet A revision already fetched from {revisions}
+ * @return the SHA hash corresponding to the revision.
+ */
+ _getPatchsetHash(
+ revisions: {[revisionId: string]: RevisionInfo},
+ patchSet: RevisionInfo
+ ) {
+ for (const sha of Object.keys(revisions)) {
+ if (revisions[sha] === patchSet) {
+ return sha;
+ }
+ }
+ throw new Error('patchset hash not found');
+ }
+
+ _handleDescriptionChanged(e: CustomEvent) {
+ const desc = e.detail.trim();
+ this._updateDescription(desc, e);
+ }
+
+ /**
+ * Update the patchset description with the rest API.
+ */
+ _updateDescription(desc: string, e: CustomEvent) {
+ if (
+ !this.change ||
+ !this.change.revisions ||
+ !this.patchNum ||
+ !this.changeNum
+ )
+ return;
+ // target can be either gr-editable-label or gr-linked-chip
+ const target = (dom(e) as EventApi).rootTarget as HTMLElement & {
+ disabled: boolean;
+ };
+ if (target) {
+ target.disabled = true;
+ }
+ const rev = getRevisionByPatchNum(
+ Object.values(this.change.revisions),
+ this.patchNum
+ )!;
+ const sha = this._getPatchsetHash(this.change.revisions, rev);
+ return this.$.restAPI
+ .setDescription(this.changeNum, this.patchNum, desc)
+ .then((res: Response) => {
+ if (res.ok) {
+ if (target) {
+ target.disabled = false;
+ }
+ this.set(['change', 'revisions', sha, 'description'], desc);
+ this._patchsetDescription = desc;
+ }
+ })
+ .catch(() => {
+ if (target) {
+ target.disabled = false;
+ }
+ return;
+ });
+ }
+
+ _computePrefsButtonHidden(
+ prefs: DiffPreferencesInfo,
+ diffPrefsDisabled: boolean
+ ) {
+ return diffPrefsDisabled || !prefs;
+ }
+
+ _fileListActionsVisible(
+ shownFileCount: number,
+ maxFilesForBulkActions: number
+ ) {
+ return shownFileCount <= maxFilesForBulkActions;
+ }
+
+ _handlePatchChange(e: CustomEvent) {
+ const {basePatchNum, patchNum} = e.detail;
+ if (
+ (patchNumEquals(basePatchNum, this.basePatchNum) &&
+ patchNumEquals(patchNum, this.patchNum)) ||
+ !this.change
+ ) {
+ return;
+ }
+ GerritNav.navigateToChange(this.change, patchNum, basePatchNum);
+ }
+
+ _handlePrefsTap(e: Event) {
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('open-diff-prefs', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _handleIncludedInTap(e: Event) {
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('open-included-in-dialog', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _handleDownloadTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('open-download-dialog', {bubbles: false})
+ );
+ }
+
+ _computeEditModeClass(editMode?: boolean) {
+ return editMode ? 'editMode' : '';
+ }
+
+ _computePatchInfoClass(patchNum?: PatchSetNum, allPatchSets?: PatchSet[]) {
+ const latestNum = computeLatestPatchNum(allPatchSets);
+ if (patchNumEquals(patchNum, latestNum)) {
+ return '';
+ }
+ return 'patchInfoOldPatchSet';
+ }
+
+ _hideIncludedIn(change?: ChangeInfo) {
+ return change?.status === MERGED_STATUS ? '' : 'hide';
+ }
+
+ _handleUploadTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('open-upload-help-dialog', {bubbles: false})
+ );
+ }
+
+ _computeUploadHelpContainerClass(change: ChangeInfo, account: AccountInfo) {
+ const changeIsMerged = change?.status === MERGED_STATUS;
+ const ownerId = change?.owner?._account_id || null;
+ const userId = account && account._account_id;
+ const userIsOwner = ownerId && userId && ownerId === userId;
+ const hideContainer = !userIsOwner || changeIsMerged;
+ return 'uploadContainer desktop' + (hideContainer ? ' hide' : '');
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
index beabeef..1355412 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_html.ts
@@ -225,12 +225,17 @@
<gr-button
id="expandBtn"
link=""
- title="[[createTitle(Shortcut.EXPAND_ALL_DIFF_CONTEXT,
- ShortcutSection.DIFFS)]]"
+ title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+ ShortcutSection.FILE_LIST)]]"
on-click="_expandAllDiffs"
>Expand All</gr-button
>
- <gr-button id="collapseBtn" link="" on-click="_collapseAllDiffs"
+ <gr-button
+ id="collapseBtn"
+ link=""
+ on-click="_collapseAllDiffs"
+ title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+ ShortcutSection.FILE_LIST)]]"
>Collapse All</gr-button
>
</template>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
index d0155d6..3469b3a 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.js
@@ -17,10 +17,10 @@
import '../../../test/common-test-setup-karma.js';
import './gr-file-list-header.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {FilesExpandedState} from '../gr-file-list-constants.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {generateChange} from '../../../test/test-utils.js';
+import 'lodash/lodash.js';
+import {createRevisions} from '../../../test/test-data-generators.js';
const basicFixture = fixtureFromElement('gr-file-list-header');
@@ -44,30 +44,38 @@
test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
element.diffPrefsDisabled = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.diffPrefsContainer.hidden);
element.diffPrefsDisabled = false;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.diffPrefsContainer.hidden);
element.diffPrefsDisabled = true;
element.diffPrefs = {font_size: '12'};
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.diffPrefsContainer.hidden);
element.diffPrefsDisabled = false;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.diffPrefsContainer.hidden);
});
test('_computeDescriptionReadOnly', () => {
- assert.equal(element._computeDescriptionReadOnly(false,
- {owner: {_account_id: 1}}, {_account_id: 1}), true);
- assert.equal(element._computeDescriptionReadOnly(true,
- {owner: {_account_id: 0}}, {_account_id: 1}), true);
- assert.equal(element._computeDescriptionReadOnly(true,
- {owner: {_account_id: 1}}, {_account_id: 1}), false);
+ element.loggedIn = false;
+ element.change = {owner: {_account_id: 1}};
+ element.account = {_account_id: 1};
+ assert.equal(element._descriptionReadOnly, true);
+
+ element.loggedIn = true;
+ element.change = {owner: {_account_id: 0}};
+ element.account = {_account_id: 1};
+ assert.equal(element._descriptionReadOnly, true);
+
+ element.loggedIn = true;
+ element.change = {owner: {_account_id: 1}};
+ element.account = {_account_id: 1};
+ assert.equal(element._descriptionReadOnly, false);
});
test('_computeDescriptionPlaceholder', () => {
@@ -97,14 +105,16 @@
owner: {_account_id: 1},
};
element.account = {_account_id: 1};
+ element.owner = {_account_id: 1};
element.loggedIn = true;
- flushAsynchronousOperations();
+ flush();
// The element has a description, so the account chip should be visible
+ element.owner = {_account_id: 1};
// and the description label should not exist.
- const chip = dom(element.root).querySelector('#descriptionChip');
- let label = dom(element.root).querySelector('#descriptionLabel');
+ const chip = element.root.querySelector('#descriptionChip');
+ let label = element.root.querySelector('#descriptionLabel');
assert.equal(chip.text, 'test');
assert.isNotOk(label);
@@ -118,9 +128,9 @@
assert.equal(putDescStub.lastCall.args[2], '');
assert.equal(element.change.revisions.rev1.description, '');
- flushAsynchronousOperations();
+ flush();
// The editable label should now be visible and the chip hidden.
- label = dom(element.root).querySelector('#descriptionLabel');
+ label = element.root.querySelector('#descriptionLabel');
assert.isOk(label);
assert.equal(getComputedStyle(chip).display, 'none');
assert.notEqual(getComputedStyle(label).display, 'none');
@@ -129,14 +139,14 @@
label.editing = true;
label._inputText = 'test2';
label._save();
- flushAsynchronousOperations();
+ flush();
// The API stub should be called with an `test2` for the new
// description.
assert.equal(putDescStub.callCount, 2);
assert.equal(putDescStub.lastCall.args[2], 'test2');
})
.then(() => {
- flushAsynchronousOperations();
+ flush();
// The chip should be visible again, and the label hidden.
assert.equal(element.change.revisions.rev1.description, 'test2');
assert.equal(getComputedStyle(label).display, 'none');
@@ -146,18 +156,18 @@
test('expandAllDiffs called when expand button clicked', () => {
element.shownFileCount = 1;
- flushAsynchronousOperations();
+ flush();
sinon.stub(element, '_expandAllDiffs');
- MockInteractions.tap(dom(element.root).querySelector(
+ MockInteractions.tap(element.root.querySelector(
'#expandBtn'));
assert.isTrue(element._expandAllDiffs.called);
});
test('collapseAllDiffs called when expand button clicked', () => {
element.shownFileCount = 1;
- flushAsynchronousOperations();
+ flush();
sinon.stub(element, '_collapseAllDiffs');
- MockInteractions.tap(dom(element.root).querySelector(
+ MockInteractions.tap(element.root.querySelector(
'#collapseBtn'));
assert.isTrue(element._collapseAllDiffs.called);
});
@@ -183,34 +193,34 @@
const actions = element.shadowRoot
.querySelector('.fileViewActions');
assert.equal(getComputedStyle(actions).display, 'none');
- element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
- flushAsynchronousOperations();
+ element.filesExpanded = FilesExpandedState.SOME;
+ flush();
assert.notEqual(getComputedStyle(actions).display, 'none');
- element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
- flushAsynchronousOperations();
+ element.filesExpanded = FilesExpandedState.ALL;
+ flush();
assert.notEqual(getComputedStyle(actions).display, 'none');
- element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
- flushAsynchronousOperations();
+ element.filesExpanded = FilesExpandedState.NONE;
+ flush();
assert.equal(getComputedStyle(actions).display, 'none');
});
test('expand/collapse buttons are toggled correctly', () => {
element.shownFileCount = 10;
- flushAsynchronousOperations();
+ flush();
const expandBtn = element.shadowRoot.querySelector('#expandBtn');
const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
assert.notEqual(getComputedStyle(expandBtn).display, 'none');
assert.equal(getComputedStyle(collapseBtn).display, 'none');
- element.filesExpanded = GrFileListConstants.FilesExpandedState.SOME;
- flushAsynchronousOperations();
+ element.filesExpanded = FilesExpandedState.SOME;
+ flush();
assert.notEqual(getComputedStyle(expandBtn).display, 'none');
assert.equal(getComputedStyle(collapseBtn).display, 'none');
- element.filesExpanded = GrFileListConstants.FilesExpandedState.ALL;
- flushAsynchronousOperations();
+ element.filesExpanded = FilesExpandedState.ALL;
+ flush();
assert.equal(getComputedStyle(expandBtn).display, 'none');
assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
- element.filesExpanded = GrFileListConstants.FilesExpandedState.NONE;
- flushAsynchronousOperations();
+ element.filesExpanded = FilesExpandedState.NONE;
+ flush();
assert.notEqual(getComputedStyle(expandBtn).display, 'none');
assert.equal(getComputedStyle(collapseBtn).display, 'none');
});
@@ -259,15 +269,15 @@
test('patch specific elements', () => {
element.editMode = true;
- element.allPatchSets = generateChange({revisionsCount: 2}).revisions;
- flushAsynchronousOperations();
+ element.allPatchSets = createRevisions(2);
+ flush();
assert.isFalse(isVisible(element.$.diffPrefsContainer));
assert.isFalse(isVisible(element.shadowRoot
.querySelector('.descriptionContainer')));
element.editMode = false;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(isVisible(element.shadowRoot
.querySelector('.descriptionContainer')));
@@ -276,19 +286,19 @@
test('edit-controls visibility', () => {
element.editMode = false;
- flushAsynchronousOperations();
+ flush();
// on the first render, when editMode is false, editControls are not
// in the DOM to reduce size of DOM and make first render faster.
assert.isNull(element.shadowRoot
.querySelector('#editControls'));
element.editMode = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(isVisible(element.shadowRoot
.querySelector('#editControls').parentElement));
element.editMode = false;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(isVisible(element.shadowRoot
.querySelector('#editControls').parentElement));
});
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
deleted file mode 100644
index 2ee8173..0000000
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ /dev/null
@@ -1,1627 +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/shared-styles.js';
-import '../../diff/gr-diff-cursor/gr-diff-cursor.js';
-import '../../diff/gr-diff-host/gr-diff-host.js';
-import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
-import '../../edit/gr-edit-file-controls/gr-edit-file-controls.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-cursor-manager/gr-cursor-manager.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-linked-text/gr-linked-text.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/gr-tooltip-content/gr-tooltip-content.js';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
-import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.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-file-list_html.js';
-import {asyncForeach} from '../../../utils/async-util.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {appContext} from '../../../services/app-context.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {descendedFromClass} from '../../../utils/dom-util.js';
-import {getRevisionByPatchNum} from '../../../utils/patch-set-util.js';
-import {
- addUnmodifiedFiles,
- computeDisplayPath,
- computeTruncatedPath,
- isMagicPath,
- specialFilePathCompare,
-} from '../../../utils/path-list-util.js';
-
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
-const WARN_SHOW_ALL_THRESHOLD = 1000;
-const LOADING_DEBOUNCE_INTERVAL = 100;
-
-const SIZE_BAR_MAX_WIDTH = 61;
-const SIZE_BAR_GAP_WIDTH = 1;
-const SIZE_BAR_MIN_WIDTH = 1.5;
-
-const RENDER_TIMING_LABEL = 'FileListRenderTime';
-const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
-const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
-const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
-
-const FileStatus = {
- A: 'Added',
- C: 'Copied',
- D: 'Deleted',
- M: 'Modified',
- R: 'Renamed',
- W: 'Rewritten',
- U: 'Unchanged',
-};
-
-const FILE_ROW_CLASS = 'file-row';
-
-/**
- * Type for FileInfo
- *
- * This should match with the type returned from `files` API plus
- * additional info like `__path`.
- *
- * @typedef {Object} FileInfo
- * @property {string} __path
- * @property {?string} old_path
- * @property {number} size
- * @property {number} size_delta - fallback to 0 if not present in api
- * @property {number} lines_deleted - fallback to 0 if not present in api
- * @property {number} lines_inserted - fallback to 0 if not present in api
- */
-
-/**
- * @extends PolymerElement
- */
-class GrFileList extends KeyboardShortcutMixin(
- GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-file-list'; }
- /**
- * Fired when a draft refresh should get triggered
- *
- * @event reload-drafts
- */
-
- static get properties() {
- return {
- /** @type {?} */
- patchRange: Object,
- patchNum: String,
- changeNum: String,
- /** @type {?} */
- changeComments: Object,
- drafts: Object,
- revisions: Array,
- projectConfig: Object,
- selectedIndex: {
- type: Number,
- notify: true,
- },
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- /** @type {?} */
- change: Object,
- diffViewMode: {
- type: String,
- notify: true,
- observer: '_updateDiffPreferences',
- },
- editMode: {
- type: Boolean,
- observer: '_editModeChanged',
- },
- filesExpanded: {
- type: String,
- value: GrFileListConstants.FilesExpandedState.NONE,
- notify: true,
- },
- _filesByPath: Object,
-
- /** @type {!Array<FileInfo>} */
- _files: {
- type: Array,
- observer: '_filesChanged',
- value() { return []; },
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- },
- _reviewed: {
- type: Array,
- value() { return []; },
- },
- diffPrefs: {
- type: Object,
- notify: true,
- observer: '_updateDiffPreferences',
- },
- /** @type {?} */
- _userPrefs: Object,
- _showInlineDiffs: Boolean,
- numFilesShown: {
- type: Number,
- notify: true,
- },
- /** @type {?} */
- _patchChange: {
- type: Object,
- computed: '_calculatePatchChange(_files)',
- },
- fileListIncrement: Number,
- _hideChangeTotals: {
- type: Boolean,
- computed: '_shouldHideChangeTotals(_patchChange)',
- },
- _hideBinaryChangeTotals: {
- type: Boolean,
- computed: '_shouldHideBinaryChangeTotals(_patchChange)',
- },
-
- _shownFiles: {
- type: Array,
- computed: '_computeFilesShown(numFilesShown, _files)',
- },
-
- /**
- * The amount of files added to the shown files list the last time it was
- * updated. This is used for reporting the average render time.
- */
- _reportinShownFilesIncrement: Number,
-
- /** @type {!Array<Gerrit.FileRange>} */
- _expandedFiles: {
- type: Array,
- value() { return []; },
- },
- _displayLine: Boolean,
- _loading: {
- type: Boolean,
- observer: '_loadingChanged',
- },
- /** @type {Gerrit.LayoutStats|undefined} */
- _sizeBarLayout: {
- type: Object,
- computed: '_computeSizeBarLayout(_shownFiles.*)',
- },
-
- _showSizeBars: {
- type: Boolean,
- value: true,
- computed: '_computeShowSizeBars(_userPrefs)',
- },
-
- /** @type {Function} */
- _cancelForEachDiff: Function,
-
- _showDynamicColumns: {
- type: Boolean,
- computed: '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
- '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
- },
- _showPrependedDynamicColumns: {
- type: Boolean,
- computed: '_computeShowPrependedDynamicColumns(' +
- '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
- },
- /** @type {Array<string>} */
- _dynamicHeaderEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicContentEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicSummaryEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicPrependedHeaderEndpoints: {
- type: Array,
- },
- /** @type {Array<string>} */
- _dynamicPrependedContentEndpoints: {
- type: Array,
- },
- };
- }
-
- static get observers() {
- return [
- '_expandedFilesChanged(_expandedFiles.splices)',
- '_computeFiles(_filesByPath, changeComments, patchRange, _reviewed, ' +
- '_loading)',
- ];
- }
-
- get keyBindings() {
- return {
- esc: '_handleEscKey',
- };
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.LEFT_PANE]: '_handleLeftPane',
- [Shortcut.RIGHT_PANE]: '_handleRightPane',
- [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
- [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
- [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
- '_handleToggleHideAllCommentThreads',
- [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
- [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
- [Shortcut.NEXT_LINE]: '_handleCursorNext',
- [Shortcut.PREV_LINE]: '_handleCursorPrev',
- [Shortcut.NEW_COMMENT]: '_handleNewComment',
- [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
- [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
- [Shortcut.OPEN_FILE]: '_handleOpenFile',
- [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
- [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
- [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
- [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-
- // Final two are actually handled by gr-comment-thread.
- [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
- [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('keydown',
- e => this._scopedKeydownHandler(e));
- }
-
- /** @override */
- attached() {
- super.attached();
- pluginLoader.awaitPluginsLoaded().then(() => {
- this._dynamicHeaderEndpoints = pluginEndpoints
- .getDynamicEndpoints('change-view-file-list-header');
- this._dynamicContentEndpoints = pluginEndpoints
- .getDynamicEndpoints('change-view-file-list-content');
- this._dynamicPrependedHeaderEndpoints = pluginEndpoints
- .getDynamicEndpoints('change-view-file-list-header-prepend');
- this._dynamicPrependedContentEndpoints = pluginEndpoints
- .getDynamicEndpoints('change-view-file-list-content-prepend');
- this._dynamicSummaryEndpoints = pluginEndpoints
- .getDynamicEndpoints('change-view-file-list-summary');
-
- if (this._dynamicHeaderEndpoints.length !==
- this._dynamicContentEndpoints.length) {
- console.warn(
- 'Different number of dynamic file-list header and content.');
- }
- if (this._dynamicPrependedHeaderEndpoints.length !==
- this._dynamicPrependedContentEndpoints.length) {
- console.warn(
- 'Different number of dynamic file-list header and content.');
- }
- if (this._dynamicHeaderEndpoints.length !==
- this._dynamicSummaryEndpoints.length) {
- console.warn(
- 'Different number of dynamic file-list headers and summary.');
- }
- });
- }
-
- /** @override */
- detached() {
- super.detached();
- this._cancelDiffs();
- }
-
- /**
- * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
- * events must be scoped to a component level (e.g. `enter`) in order to not
- * override native browser functionality.
- *
- * Context: Issue 7277
- */
- _scopedKeydownHandler(e) {
- if (e.keyCode === 13) {
- // Enter.
- this._handleOpenFile(e);
- }
- }
-
- reload() {
- if (!this.changeNum || !this.patchRange.patchNum) {
- return Promise.resolve();
- }
-
- this._loading = true;
-
- this.collapseAllDiffs();
- const promises = [];
-
- promises.push(this._getFiles().then(filesByPath => {
- this._filesByPath = filesByPath;
- }));
- promises.push(this._getLoggedIn()
- .then(loggedIn => this._loggedIn = loggedIn)
- .then(loggedIn => {
- if (!loggedIn) { return; }
-
- return this._getReviewedFiles().then(reviewed => {
- this._reviewed = reviewed;
- });
- }));
-
- promises.push(this._getDiffPreferences().then(prefs => {
- this.diffPrefs = prefs;
- }));
-
- promises.push(this._getPreferences().then(prefs => {
- this._userPrefs = prefs;
- }));
-
- return Promise.all(promises).then(() => {
- this._loading = false;
- this._detectChromiteButler();
- this.reporting.fileListDisplayed();
- });
- }
-
- _detectChromiteButler() {
- const hasButler = !!document.getElementById('butler-suggested-owners');
- if (hasButler) {
- this.reporting.reportExtension('butler');
- }
- }
-
- get diffs() {
- const diffs = dom(this.root).querySelectorAll('gr-diff-host');
- // It is possible that a bogus diff element is hanging around invisibly
- // from earlier with a different patch set choice and associated with a
- // different entry in the files array. So filter on visible items only.
- return Array.from(diffs).filter(
- el => !!el && !!el.style && el.style.display !== 'none');
- }
-
- openDiffPrefs() {
- this.$.diffPreferencesDialog.open();
- }
-
- _calculatePatchChange(files) {
- const magicFilesExcluded = files.filter(files =>
- !isMagicPath(files.__path)
- );
-
- return magicFilesExcluded.reduce((acc, obj) => {
- const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
- const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
- const total_size = (obj.size && obj.binary) ? obj.size : 0;
- const size_delta_inserted =
- obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
- const size_delta_deleted =
- obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
-
- return {
- inserted: acc.inserted + inserted,
- deleted: acc.deleted + deleted,
- size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
- size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
- total_size: acc.total_size + total_size,
- };
- }, {inserted: 0, deleted: 0, size_delta_inserted: 0,
- size_delta_deleted: 0, total_size: 0});
- }
-
- _getDiffPreferences() {
- return this.$.restAPI.getDiffPreferences();
- }
-
- _getPreferences() {
- return this.$.restAPI.getPreferences();
- }
-
- _toggleFileExpanded(file) {
- // Is the path in the list of expanded diffs? IF so remove it, otherwise
- // add it to the list.
- const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
- if (pathIndex === -1) {
- this.push('_expandedFiles', file);
- } else {
- this.splice('_expandedFiles', pathIndex, 1);
- }
- }
-
- _toggleFileExpandedByIndex(index) {
- this._toggleFileExpanded(this._computeFileRange(this._files[index]));
- }
-
- _updateDiffPreferences() {
- if (!this.diffs.length) { return; }
- // Re-render all expanded diffs sequentially.
- this.reporting.time(EXPAND_ALL_TIMING_LABEL);
- this._renderInOrder(this._expandedFiles, this.diffs,
- this._expandedFiles.length);
- }
-
- _forEachDiff(fn) {
- const diffs = this.diffs;
- for (let i = 0; i < diffs.length; i++) {
- fn(diffs[i]);
- }
- }
-
- expandAllDiffs() {
- this._showInlineDiffs = true;
-
- // Find the list of paths that are in the file list, but not in the
- // expanded list.
- const newFiles = [];
- let path;
- for (let i = 0; i < this._shownFiles.length; i++) {
- path = this._shownFiles[i].__path;
- if (!this._expandedFiles.some(f => f.path === path)) {
- newFiles.push(this._computeFileRange(this._shownFiles[i]));
- }
- }
-
- this.splice(...['_expandedFiles', 0, 0].concat(newFiles));
- }
-
- collapseAllDiffs() {
- this._showInlineDiffs = false;
- this._expandedFiles = [];
- this.filesExpanded = this._computeExpandedFiles(
- this._expandedFiles.length, this._files.length);
- this.$.diffCursor.handleDiffUpdate();
- }
-
- /**
- * Computes a string with the number of comments and unresolved comments.
- *
- * @param {!Object} changeComments
- * @param {!Object} patchRange
- * @param {string} path
- * @return {string}
- */
- _computeCommentsString(changeComments, patchRange, path) {
- if ([changeComments, patchRange, path].includes(undefined)) {
- return '';
- }
- const unresolvedCount =
- changeComments.computeUnresolvedNum({
- patchNum: patchRange.basePatchNum,
- path,
- }) +
- changeComments.computeUnresolvedNum({
- patchNum: patchRange.patchNum,
- path,
- });
- const commentCount =
- changeComments.computeCommentCount({
- patchNum: patchRange.basePatchNum,
- path,
- }) +
- changeComments.computeCommentCount({
- patchNum: patchRange.patchNum,
- path,
- });
- const commentString = GrCountStringFormatter.computePluralString(
- commentCount, 'comment');
- const unresolvedString = GrCountStringFormatter.computeString(
- unresolvedCount, 'unresolved');
-
- return commentString +
- // Add a space if both comments and unresolved
- (commentString && unresolvedString ? ' ' : '') +
- // Add parentheses around unresolved if it exists.
- (unresolvedString ? `(${unresolvedString})` : '');
- }
-
- /**
- * Computes a string with the number of drafts.
- *
- * @param {!Object} changeComments
- * @param {!Object} patchRange
- * @param {string} path
- * @return {string}
- */
- _computeDraftsString(changeComments, patchRange, path) {
- if ([changeComments, patchRange, path].includes(undefined)) {
- return '';
- }
- const draftCount =
- changeComments.computeDraftCount({
- patchNum: patchRange.basePatchNum,
- path,
- }) +
- changeComments.computeDraftCount({
- patchNum: patchRange.patchNum,
- path,
- });
- return GrCountStringFormatter.computePluralString(draftCount, 'draft');
- }
-
- /**
- * Computes a shortened string with the number of drafts.
- *
- * @param {!Object} changeComments
- * @param {!Object} patchRange
- * @param {string} path
- * @return {string}
- */
- _computeDraftsStringMobile(changeComments, patchRange, path) {
- if ([changeComments, patchRange, path].includes(undefined)) {
- return '';
- }
- const draftCount =
- changeComments.computeDraftCount({
- patchNum: patchRange.basePatchNum,
- path,
- }) +
- changeComments.computeDraftCount({
- patchNum: patchRange.patchNum,
- path,
- });
- return GrCountStringFormatter.computeShortString(draftCount, 'd');
- }
-
- /**
- * Computes a shortened string with the number of comments.
- *
- * @param {!Object} changeComments
- * @param {!Object} patchRange
- * @param {string} path
- * @return {string}
- */
- _computeCommentsStringMobile(changeComments, patchRange, path) {
- if ([changeComments, patchRange, path].includes(undefined)) {
- return '';
- }
- const commentCount =
- changeComments.computeCommentCount({
- patchNum: patchRange.basePatchNum,
- path,
- }) +
- changeComments.computeCommentCount({
- patchNum: patchRange.patchNum,
- path,
- });
- return GrCountStringFormatter.computeShortString(commentCount, 'c');
- }
-
- /**
- * @param {string} path
- * @param {boolean=} opt_reviewed
- */
- _reviewFile(path, opt_reviewed) {
- if (this.editMode) { return; }
- const index = this._files.findIndex(file => file.__path === path);
- const reviewed = opt_reviewed || !this._files[index].isReviewed;
-
- this.set(['_files', index, 'isReviewed'], reviewed);
- if (index < this._shownFiles.length) {
- this.notifyPath(`_shownFiles.${index}.isReviewed`);
- }
-
- this._saveReviewedState(path, reviewed);
- }
-
- _saveReviewedState(path, reviewed) {
- return this.$.restAPI.saveFileReviewed(this.changeNum,
- this.patchRange.patchNum, path, reviewed);
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _getReviewedFiles() {
- if (this.editMode) { return Promise.resolve([]); }
- return this.$.restAPI.getReviewedFiles(this.changeNum,
- this.patchRange.patchNum);
- }
-
- _getFiles() {
- return this.$.restAPI.getChangeOrEditFiles(
- this.changeNum, this.patchRange);
- }
-
- /**
- *
- * @returns {!Array<FileInfo>}
- */
- _normalizeChangeFilesResponse(response) {
- if (!response) { return []; }
- const paths = Object.keys(response).sort(specialFilePathCompare);
- const files = [];
- for (let i = 0; i < paths.length; i++) {
- const info = response[paths[i]];
- info.__path = paths[i];
- info.lines_inserted = info.lines_inserted || 0;
- info.lines_deleted = info.lines_deleted || 0;
- info.size_delta = info.size_delta || 0;
- files.push(info);
- }
- return files;
- }
-
- /**
- * Returns true if the event e is a click on an element.
- *
- * The click is: mouse click or pressing Enter or Space key
- * P.S> Screen readers sends click event as well
- */
- _isClickEvent(e) {
- if (e.type === 'click') {
- return true;
- }
- const isSpaceOrEnter = (e.key === 'Enter' || e.key === ' ');
- return e.type === 'keydown' && isSpaceOrEnter;
- }
-
- _fileActionClick(e, fileAction) {
- if (this._isClickEvent(e)) {
- const fileRow = this._getFileRowFromEvent(e);
- if (!fileRow) {
- return;
- }
- // Prevent default actions (e.g. scrolling for space key)
- e.preventDefault();
- // Prevent _handleFileListClick handler call
- e.stopPropagation();
- this.$.fileCursor.setCursor(fileRow.element);
- fileAction(fileRow.file);
- }
- }
-
- _reviewedClick(e) {
- this._fileActionClick(e,
- file => this._reviewFile(file.path));
- }
-
- _expandedClick(e) {
- this._fileActionClick(e,
- file => this._toggleFileExpanded(file));
- }
-
- /**
- * Handle all events from the file list dom-repeat so event handleers don't
- * have to get registered for potentially very long lists.
- */
- _handleFileListClick(e) {
- const fileRow = this._getFileRowFromEvent(e);
- if (!fileRow) {
- return;
- }
- const file = fileRow.file;
- const path = file.path;
- // If a path cannot be interpreted from the click target (meaning it's not
- // somewhere in the row, e.g. diff content) or if the user clicked the
- // link, defer to the native behavior.
- if (!path || descendedFromClass(e.target, 'pathLink')) { return; }
-
- // Disregard the event if the click target is in the edit controls.
- if (descendedFromClass(e.target, 'editFileControls')) { return; }
-
- e.preventDefault();
- this.$.fileCursor.setCursor(fileRow.element);
- this._toggleFileExpanded(file);
- }
-
- _getFileRowFromEvent(e) {
- // Traverse upwards to find the row element if the target is not the row.
- let row = e.target;
- while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
- row = row.parentElement;
- }
-
- // No action needed for item without a valid file
- if (!row.dataset.file) {
- return null;
- }
-
- return {
- file: JSON.parse(row.dataset.file),
- element: row,
- };
- }
-
- /**
- * Generates file range from file info object.
- *
- * @param {FileInfo} file
- * @returns {Gerrit.FileRange}
- */
- _computeFileRange(file) {
- const fileData = {
- path: file.__path,
- };
- if (file.old_path) {
- fileData.basePath = file.old_path;
- }
- return fileData;
- }
-
- _handleLeftPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
- return;
- }
-
- e.preventDefault();
- this.$.diffCursor.moveLeft();
- }
-
- _handleRightPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
- return;
- }
-
- e.preventDefault();
- this.$.diffCursor.moveRight();
- }
-
- _handleToggleInlineDiff(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e) ||
- this.$.fileCursor.index === -1) { return; }
-
- e.preventDefault();
- this._toggleFileExpandedByIndex(this.$.fileCursor.index);
- }
-
- _handleToggleAllInlineDiffs(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this._toggleInlineDiffs();
- }
-
- _handleToggleHideAllCommentThreads(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
- this.toggleClass('hideComments');
- }
-
- _handleCursorNext(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
- return;
- }
-
- if (this._showInlineDiffs) {
- e.preventDefault();
- this.$.diffCursor.moveDown();
- this._displayLine = true;
- } else {
- // Down key
- if (this.getKeyboardEvent(e).keyCode === 40) { return; }
- e.preventDefault();
- this.$.fileCursor.next();
- this.selectedIndex = this.$.fileCursor.index;
- }
- }
-
- _handleCursorPrev(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
- return;
- }
-
- if (this._showInlineDiffs) {
- e.preventDefault();
- this.$.diffCursor.moveUp();
- this._displayLine = true;
- } else {
- // Up key
- if (this.getKeyboardEvent(e).keyCode === 38) { return; }
- e.preventDefault();
- this.$.fileCursor.previous();
- this.selectedIndex = this.$.fileCursor.index;
- }
- }
-
- _handleNewComment(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- e.preventDefault();
- this.$.diffCursor.createCommentInPlace();
- }
-
- _handleOpenLastFile(e) {
- // Check for meta key to avoid overriding native chrome shortcut.
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.getKeyboardEvent(e).metaKey) { return; }
-
- e.preventDefault();
- this._openSelectedFile(this._files.length - 1);
- }
-
- _handleOpenFirstFile(e) {
- // Check for meta key to avoid overriding native chrome shortcut.
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.getKeyboardEvent(e).metaKey) { return; }
-
- e.preventDefault();
- this._openSelectedFile(0);
- }
-
- _handleOpenFile(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- e.preventDefault();
-
- if (this._showInlineDiffs) {
- this._openCursorFile();
- return;
- }
-
- this._openSelectedFile();
- }
-
- _handleNextChunk(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
- this._noDiffsExpanded()) {
- return;
- }
-
- e.preventDefault();
- if (this.isModifierPressed(e, 'shiftKey')) {
- this.$.diffCursor.moveToNextCommentThread();
- } else {
- this.$.diffCursor.moveToNextChunk();
- }
- }
-
- _handlePrevChunk(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- (this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) ||
- this._noDiffsExpanded()) {
- return;
- }
-
- e.preventDefault();
- if (this.isModifierPressed(e, 'shiftKey')) {
- this.$.diffCursor.moveToPreviousCommentThread();
- } else {
- this.$.diffCursor.moveToPreviousChunk();
- }
- }
-
- _handleToggleFileReviewed(e) {
- if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
- return;
- }
-
- e.preventDefault();
- if (!this._files[this.$.fileCursor.index]) { return; }
- this._reviewFile(this._files[this.$.fileCursor.index].__path);
- }
-
- _handleToggleLeftPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this._forEachDiff(diff => {
- diff.toggleLeftDiff();
- });
- }
-
- _toggleInlineDiffs() {
- if (this._showInlineDiffs) {
- this.collapseAllDiffs();
- } else {
- this.expandAllDiffs();
- }
- }
-
- _openCursorFile() {
- const diff = this.$.diffCursor.getTargetDiffElement();
- GerritNav.navigateToDiff(this.change, diff.path,
- diff.patchRange.patchNum, this.patchRange.basePatchNum);
- }
-
- /**
- * @param {number=} opt_index
- */
- _openSelectedFile(opt_index) {
- if (opt_index != null) {
- this.$.fileCursor.setCursorAtIndex(opt_index);
- }
- if (!this._files[this.$.fileCursor.index]) { return; }
- GerritNav.navigateToDiff(this.change,
- this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
- this.patchRange.basePatchNum);
- }
-
- _addDraftAtTarget() {
- const diff = this.$.diffCursor.getTargetDiffElement();
- const target = this.$.diffCursor.getTargetLineElement();
- if (diff && target) {
- diff.addDraftAtLine(target);
- }
- }
-
- _shouldHideChangeTotals(_patchChange) {
- return _patchChange.inserted === 0 && _patchChange.deleted === 0;
- }
-
- _shouldHideBinaryChangeTotals(_patchChange) {
- return _patchChange.size_delta_inserted === 0 &&
- _patchChange.size_delta_deleted === 0;
- }
-
- _computeFileStatus(status) {
- return status || 'M';
- }
-
- _computeDiffURL(change, patchRange, path, editMode) {
- // Polymer 2: check for undefined
- if ([change, patchRange, path, editMode]
- .some(arg => arg === undefined)) {
- return;
- }
- if (editMode && path !== SpecialFilePath.MERGE_LIST) {
- return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum,
- patchRange.basePatchNum);
- }
- return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
- patchRange.basePatchNum);
- }
-
- _formatBytes(bytes) {
- if (bytes == 0) return '+/-0 B';
- const bits = 1024;
- const decimals = 1;
- const sizes =
- ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
- const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
- const prepend = bytes > 0 ? '+' : '';
- return prepend + parseFloat((bytes / Math.pow(bits, exponent))
- .toFixed(decimals)) + ' ' + sizes[exponent];
- }
-
- _formatPercentage(size, delta) {
- const oldSize = size - delta;
-
- if (oldSize === 0) { return ''; }
-
- const percentage = Math.round(Math.abs(delta * 100 / oldSize));
- return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
- }
-
- _computeBinaryClass(delta) {
- if (delta === 0) { return; }
- return delta >= 0 ? 'added' : 'removed';
- }
-
- /**
- * @param {string} baseClass
- * @param {string} path
- */
- _computeClass(baseClass, path) {
- const classes = [];
- if (baseClass) {
- classes.push(baseClass);
- }
- if (path === SpecialFilePath.COMMIT_MESSAGE ||
- path === SpecialFilePath.MERGE_LIST) {
- classes.push('invisible');
- }
- return classes.join(' ');
- }
-
- _computeStatusClass(file) {
- const classStr = this._computeClass('status', file.__path);
- return `${classStr} ${this._computeFileStatus(file.status)}`;
- }
-
- _computePathClass(path, expandedFilesRecord) {
- return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
- }
-
- _computeShowHideIcon(path, expandedFilesRecord) {
- return this._isFileExpanded(path, expandedFilesRecord) ?
- 'gr-icons:expand-less' : 'gr-icons:expand-more';
- }
-
- _computeFiles(filesByPath, changeComments, patchRange, reviewed, loading) {
- // Polymer 2: check for undefined
- if ([
- filesByPath,
- changeComments,
- patchRange,
- reviewed,
- loading,
- ].includes(undefined)) {
- return;
- }
-
- // Await all promises resolving from reload. @See Issue 9057
- if (loading || !changeComments) { return; }
-
- const commentedPaths = changeComments.getPaths(patchRange);
- const files = Object.assign({}, filesByPath);
- addUnmodifiedFiles(files, commentedPaths);
- const reviewedSet = new Set(reviewed || []);
- for (const filePath in files) {
- if (!files.hasOwnProperty(filePath)) { continue; }
- files[filePath].isReviewed = reviewedSet.has(filePath);
- }
-
- this._files = this._normalizeChangeFilesResponse(files);
- }
-
- _computeFilesShown(numFilesShown, files) {
- // Polymer 2: check for undefined
- if ([numFilesShown, files].includes(undefined)) {
- return undefined;
- }
-
- const previousNumFilesShown = this._shownFiles ?
- this._shownFiles.length : 0;
-
- const filesShown = files.slice(0, numFilesShown);
- this.dispatchEvent(new CustomEvent('files-shown-changed', {
- detail: {length: filesShown.length},
- composed: true, bubbles: true,
- }));
-
- // Start the timer for the rendering work hwere because this is where the
- // _shownFiles property is being set, and _shownFiles is used in the
- // dom-repeat binding.
- this.reporting.time(RENDER_TIMING_LABEL);
-
- // How many more files are being shown (if it's an increase).
- this._reportinShownFilesIncrement =
- Math.max(0, filesShown.length - previousNumFilesShown);
-
- return filesShown;
- }
-
- _updateDiffCursor() {
- // Overwrite the cursor's list of diffs:
- this.$.diffCursor.splice(
- ...['diffs', 0, this.$.diffCursor.diffs.length].concat(this.diffs));
- }
-
- _filesChanged() {
- if (this._files && this._files.length > 0) {
- flush();
- this.$.fileCursor.stops = Array.from(
- dom(this.root).querySelectorAll(`.${FILE_ROW_CLASS}`));
- this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
- }
- }
-
- _incrementNumFilesShown() {
- this.numFilesShown += this.fileListIncrement;
- }
-
- _computeFileListControlClass(numFilesShown, files) {
- return numFilesShown >= files.length ? 'invisible' : '';
- }
-
- _computeIncrementText(numFilesShown, files) {
- if (!files) { return ''; }
- const text =
- Math.min(this.fileListIncrement, files.length - numFilesShown);
- return 'Show ' + text + ' more';
- }
-
- _computeShowAllText(files) {
- if (!files) { return ''; }
- return 'Show all ' + files.length + ' files';
- }
-
- _computeWarnShowAll(files) {
- return files.length > WARN_SHOW_ALL_THRESHOLD;
- }
-
- _computeShowAllWarning(files) {
- if (!this._computeWarnShowAll(files)) { return ''; }
- return 'Warning: showing all ' + files.length +
- ' files may take several seconds.';
- }
-
- _showAllFiles() {
- this.numFilesShown = this._files.length;
- }
-
- _computePatchSetDescription(revisions, patchNum) {
- // Polymer 2: check for undefined
- if ([revisions, patchNum].includes(undefined)) {
- return '';
- }
-
- const rev = getRevisionByPatchNum(revisions, patchNum);
- return (rev && rev.description) ?
- rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
- }
-
- /**
- * Get a descriptive label for use in the status indicator's tooltip and
- * ARIA label.
- *
- * @param {string} status
- * @return {string}
- */
- _computeFileStatusLabel(status) {
- const statusCode = this._computeFileStatus(status);
- return FileStatus.hasOwnProperty(statusCode) ?
- FileStatus[statusCode] : 'Status Unknown';
- }
-
- /**
- * Converts any boolean-like variable to the string 'true' or 'false'
- *
- * This method is useful when you bind aria-checked attribute to a boolean
- * value. The aria-checked attribute is string attribute. Binding directly
- * to boolean variable causes problem on gerrit-CI.
- *
- * @param {object} val
- * @return {string} 'true' if val is true-like, otherwise false
- */
- _booleanToString(val) {
- return val ? 'true' : 'false';
- }
-
- _isFileExpanded(path, expandedFilesRecord) {
- return expandedFilesRecord.base.some(f => f.path === path);
- }
-
- _isFileExpandedStr(path, expandedFilesRecord) {
- return this._booleanToString(
- this._isFileExpanded(path, expandedFilesRecord));
- }
-
- _computeExpandedFiles(expandedCount, totalCount) {
- if (expandedCount === 0) {
- return GrFileListConstants.FilesExpandedState.NONE;
- } else if (expandedCount === totalCount) {
- return GrFileListConstants.FilesExpandedState.ALL;
- }
- return GrFileListConstants.FilesExpandedState.SOME;
- }
-
- /**
- * Handle splices to the list of expanded file paths. If there are any new
- * entries in the expanded list, then render each diff corresponding in
- * order by waiting for the previous diff to finish before starting the next
- * one.
- *
- * @param {!Array} record The splice record in the expanded paths list.
- */
- _expandedFilesChanged(record) {
- // Clear content for any diffs that are not open so if they get re-opened
- // the stale content does not flash before it is cleared and reloaded.
- const collapsedDiffs = this.diffs.filter(diff =>
- this._expandedFiles.findIndex(f => f.path === diff.path) === -1);
- this._clearCollapsedDiffs(collapsedDiffs);
-
- if (!record) { return; } // Happens after "Collapse all" clicked.
-
- this.filesExpanded = this._computeExpandedFiles(
- this._expandedFiles.length, this._files.length);
-
- // Find the paths introduced by the new index splices:
- const newFiles = record.indexSplices
- .map(splice => splice.object.slice(
- splice.index, splice.index + splice.addedCount))
- .reduce((acc, paths) => acc.concat(paths), []);
-
- // Required so that the newly created diff view is included in this.diffs.
- flush();
-
- this.reporting.time(EXPAND_ALL_TIMING_LABEL);
-
- if (newFiles.length) {
- this._renderInOrder(newFiles, this.diffs, newFiles.length);
- }
-
- this._updateDiffCursor();
- this.$.diffCursor.reInitAndUpdateStops();
- }
-
- _clearCollapsedDiffs(collapsedDiffs) {
- for (const diff of collapsedDiffs) {
- diff.cancel();
- diff.clearDiffContent();
- }
- }
-
- /**
- * Given an array of paths and a NodeList of diff elements, render the diff
- * for each path in order, awaiting the previous render to complete before
- * continuing.
- *
- * @param {!Array<Gerrit.FileRange>} files
- * @param {!NodeList<!Object>} diffElements (GrDiffHostElement)
- * @param {number} initialCount The total number of paths in the pass. This
- * is used to generate log messages.
- * @return {!Promise}
- */
- _renderInOrder(files, diffElements, initialCount) {
- let iter = 0;
-
- for (const file of files) {
- const path = file.path;
- const diffElem = this._findDiffByPath(path, diffElements);
- if (diffElem) {
- diffElem.prefetchDiff();
- }
- }
-
- return (new Promise(resolve => {
- this.dispatchEvent(new CustomEvent('reload-drafts', {
- detail: {resolve},
- composed: true, bubbles: true,
- }));
- })).then(() => asyncForeach(files, (file, cancel) => {
- const path = file.path;
- this._cancelForEachDiff = cancel;
-
- iter++;
- console.log('Expanding diff', iter, 'of', initialCount, ':',
- path);
- const diffElem = this._findDiffByPath(path, diffElements);
- if (!diffElem) {
- console.warn(`Did not find <gr-diff-host> element for ${path}`);
- return Promise.resolve();
- }
- diffElem.comments = this.changeComments.getCommentsBySideForFile(
- file, this.patchRange, this.projectConfig);
- const promises = [diffElem.reload()];
- if (this._loggedIn && !this.diffPrefs.manual_review) {
- promises.push(this._reviewFile(path, true));
- }
- return Promise.all(promises);
- }).then(() => {
- this._cancelForEachDiff = null;
- this._nextRenderParams = null;
- console.log('Finished expanding', initialCount, 'diff(s)');
- this.reporting.timeEndWithAverage(EXPAND_ALL_TIMING_LABEL,
- EXPAND_ALL_AVG_TIMING_LABEL, initialCount);
- /* Block diff cursor from auto scrolling after files are done rendering.
- * This prevents the bug where the screen jumps to the first diff chunk
- * after files are done being rendered after the user has already begun
- * scrolling.
- * This also however results in the fact that the cursor does not auto
- * focus on the first diff chunk on a small screen. This is however, a use
- * case we are willing to not support for now.
-
- * Using handleDiffUpdate resulted in diffCursor.row being set which
- * prevented the issue of scrolling to top when we expand the second
- * file individually.
- */
- this.$.diffCursor.reInitAndUpdateStops();
- }));
- }
-
- /** Cancel the rendering work of every diff in the list */
- _cancelDiffs() {
- if (this._cancelForEachDiff) { this._cancelForEachDiff(); }
- this._forEachDiff(d => d.cancel());
- }
-
- /**
- * In the given NodeList of diff elements, find the diff for the given path.
- *
- * @param {string} path
- * @param {!NodeList<!Object>} diffElements (GrDiffElement)
- * @return {!Object|undefined} (GrDiffElement)
- */
- _findDiffByPath(path, diffElements) {
- for (let i = 0; i < diffElements.length; i++) {
- if (diffElements[i].path === path) {
- return diffElements[i];
- }
- }
- }
-
- /**
- * Reset the comments of a modified thread
- *
- * @param {string} rootId
- * @param {string} path
- */
- reloadCommentsForThreadWithRootId(rootId, path) {
- // Don't bother continuing if we already know that the path that contains
- // the updated comment thread is not expanded.
- if (!this._expandedFiles.some(f => f.path === path)) { return; }
- const diff = this.diffs.find(d => d.path === path);
-
- const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
- if (!threadEl) { return; }
-
- const newComments = this.changeComments.getCommentsForThread(rootId);
-
- // If newComments is null, it means that a single draft was
- // removed from a thread in the thread view, and the thread should
- // no longer exist. Remove the existing thread element in the diff
- // view.
- if (!newComments) {
- threadEl.fireRemoveSelf();
- return;
- }
-
- // Comments are not returned with the commentSide attribute from
- // the api, but it's necessary to be stored on the diff's
- // comments due to use in the _handleCommentUpdate function.
- // The comment thread already has a side associated with it, so
- // set the comment's side to match.
- threadEl.comments = newComments.map(c => Object.assign(
- c, {__commentSide: threadEl.commentSide}
- ));
- flush();
- }
-
- _handleEscKey(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- e.preventDefault();
- this._displayLine = false;
- }
-
- /**
- * Update the loading class for the file list rows. The update is inside a
- * debouncer so that the file list doesn't flash gray when the API requests
- * are reasonably fast.
- *
- * @param {boolean} loading
- */
- _loadingChanged(loading) {
- this.debounce('loading-change', () => {
- // Only show set the loading if there have been files loaded to show. In
- // this way, the gray loading style is not shown on initial loads.
- this.classList.toggle('loading', loading && !!this._files.length);
- }, LOADING_DEBOUNCE_INTERVAL);
- }
-
- _editModeChanged(editMode) {
- this.classList.toggle('editMode', editMode);
- }
-
- _computeReviewedClass(isReviewed) {
- return isReviewed ? 'isReviewed' : '';
- }
-
- _computeReviewedText(isReviewed) {
- return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
- }
-
- /**
- * Given a file path, return whether that path should have visible size bars
- * and be included in the size bars calculation.
- *
- * @param {string} path
- * @return {boolean}
- */
- _showBarsForPath(path) {
- return path !== SpecialFilePath.COMMIT_MESSAGE &&
- path !== SpecialFilePath.MERGE_LIST;
- }
-
- /**
- * Compute size bar layout values from the file list.
- *
- * @return {Gerrit.LayoutStats|undefined}
- *
- */
- _computeSizeBarLayout(shownFilesRecord) {
- if (!shownFilesRecord || !shownFilesRecord.base) { return undefined; }
- const stats = {
- maxInserted: 0,
- maxDeleted: 0,
- maxAdditionWidth: 0,
- maxDeletionWidth: 0,
- deletionOffset: 0,
- };
- shownFilesRecord.base
- .filter(f => this._showBarsForPath(f.__path))
- .forEach(f => {
- if (f.lines_inserted) {
- stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
- }
- if (f.lines_deleted) {
- stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
- }
- });
- const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
- if (!isNaN(ratio)) {
- stats.maxAdditionWidth =
- (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
- stats.maxDeletionWidth =
- SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
- stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
- }
- return stats;
- }
-
- /**
- * Get the width of the addition bar for a file.
- *
- * @param {Object} file
- * @param {Gerrit.LayoutStats} stats
- * @return {number}
- */
- _computeBarAdditionWidth(file, stats) {
- if (stats.maxInserted === 0 ||
- !file.lines_inserted ||
- !this._showBarsForPath(file.__path)) {
- return 0;
- }
- const width =
- stats.maxAdditionWidth * file.lines_inserted / stats.maxInserted;
- return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
- }
-
- /**
- * Get the x-offset of the addition bar for a file.
- *
- * @param {Object} file
- * @param {Gerrit.LayoutStats} stats
- * @return {number}
- */
- _computeBarAdditionX(file, stats) {
- return stats.maxAdditionWidth -
- this._computeBarAdditionWidth(file, stats);
- }
-
- /**
- * Get the width of the deletion bar for a file.
- *
- * @param {Object} file
- * @param {Gerrit.LayoutStats} stats
- * @return {number}
- */
- _computeBarDeletionWidth(file, stats) {
- if (stats.maxDeleted === 0 ||
- !file.lines_deleted ||
- !this._showBarsForPath(file.__path)) {
- return 0;
- }
- const width =
- stats.maxDeletionWidth * file.lines_deleted / stats.maxDeleted;
- return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
- }
-
- /**
- * Get the x-offset of the deletion bar for a file.
- *
- * @param {Gerrit.LayoutStats} stats
- *
- * @return {number}
- */
- _computeBarDeletionX(stats) {
- return stats.deletionOffset;
- }
-
- _computeShowSizeBars(userPrefs) {
- return !!userPrefs.size_bar_in_change_table;
- }
-
- _computeSizeBarsClass(showSizeBars, path) {
- let hideClass = '';
- if (!showSizeBars) {
- hideClass = 'hide';
- } else if (!this._showBarsForPath(path)) {
- hideClass = 'invisible';
- }
- return `sizeBars desktop ${hideClass}`;
- }
-
- /**
- * Shows registered dynamic columns iff the 'header', 'content' and
- * 'summary' endpoints are registered the exact same number of times.
- * Ideally, there should be a better way to enforce the expectation of the
- * dependencies between dynamic endpoints.
- */
- _computeShowDynamicColumns(
- headerEndpoints, contentEndpoints, summaryEndpoints) {
- return headerEndpoints && contentEndpoints && summaryEndpoints &&
- headerEndpoints.length &&
- headerEndpoints.length === contentEndpoints.length &&
- headerEndpoints.length === summaryEndpoints.length;
- }
-
- /**
- * Shows registered dynamic prepended columns iff the 'header', 'content'
- * endpoints are registered the exact same number of times.
- */
- _computeShowPrependedDynamicColumns(
- headerEndpoints, contentEndpoints) {
- return headerEndpoints && contentEndpoints &&
- headerEndpoints.length &&
- headerEndpoints.length === contentEndpoints.length;
- }
-
- /**
- * Returns true if none of the inline diffs have been expanded.
- *
- * @return {boolean}
- */
- _noDiffsExpanded() {
- return this.filesExpanded === GrFileListConstants.FilesExpandedState.NONE;
- }
-
- /**
- * Method to call via binding when each file list row is rendered. This
- * allows approximate detection of when the dom-repeat has completed
- * rendering.
- *
- * @param {number} index The index of the row being rendered.
- * @return {string} an empty string.
- */
- _reportRenderedRow(index) {
- if (index === this._shownFiles.length - 1) {
- this.async(() => {
- this.reporting.timeEndWithAverage(RENDER_TIMING_LABEL,
- RENDER_AVG_TIMING_LABEL, this._reportinShownFilesIncrement);
- }, 1);
- }
- return '';
- }
-
- _reviewedTitle(reviewed) {
- if (reviewed) {
- return 'Mark as not reviewed (shortcut: r)';
- }
-
- return 'Mark as reviewed (shortcut: r)';
- }
-
- _handleReloadingDiffPreference() {
- this._getDiffPreferences().then(prefs => {
- this.diffPrefs = prefs;
- });
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeDisplayPath(path) {
- return computeDisplayPath(path);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeTruncatedPath(path) {
- return computeTruncatedPath(path);
- }
-}
-
-customElements.define(GrFileList.is, GrFileList);
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
new file mode 100644
index 0000000..e188254
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.ts
@@ -0,0 +1,1902 @@
+/**
+ * @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/shared-styles';
+import '../../diff/gr-diff-cursor/gr-diff-cursor';
+import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-linked-text/gr-linked-text';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/gr-tooltip-content/gr-tooltip-content';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-file-list_html';
+import {asyncForeach} from '../../../utils/async-util';
+import {
+ KeyboardShortcutMixin,
+ Modifier,
+ Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {FilesExpandedState} from '../gr-file-list-constants';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+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 {DiffViewMode, SpecialFilePath} from '../../../constants/constants';
+import {descendedFromClass} from '../../../utils/dom-util';
+import {
+ addUnmodifiedFiles,
+ computeDisplayPath,
+ computeTruncatedPath,
+ isMagicPath,
+ specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ ConfigInfo,
+ DiffPreferencesInfo,
+ ElementPropertyDeepChange,
+ FileInfo,
+ FileNameToFileInfoMap,
+ NumericChangeId,
+ PatchRange,
+ PreferencesInfo,
+ RevisionInfo,
+ UrlEncodedCommentId,
+} from '../../../types/common';
+import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
+import {GrDiffPreferencesDialog} from '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrDiffCursor} from '../../diff/gr-diff-cursor/gr-diff-cursor';
+import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {UIDraft} from '../../../utils/comment-util';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {PatchSetFile} from '../../../types/types';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+export const DEFAULT_NUM_FILES_SHOWN = 200;
+
+const WARN_SHOW_ALL_THRESHOLD = 1000;
+const LOADING_DEBOUNCE_INTERVAL = 100;
+
+const SIZE_BAR_MAX_WIDTH = 61;
+const SIZE_BAR_GAP_WIDTH = 1;
+const SIZE_BAR_MIN_WIDTH = 1.5;
+
+const RENDER_TIMING_LABEL = 'FileListRenderTime';
+const RENDER_AVG_TIMING_LABEL = 'FileListRenderTimePerFile';
+const EXPAND_ALL_TIMING_LABEL = 'ExpandAllDiffs';
+const EXPAND_ALL_AVG_TIMING_LABEL = 'ExpandAllPerDiff';
+
+const FileStatus = {
+ A: 'Added',
+ C: 'Copied',
+ D: 'Deleted',
+ M: 'Modified',
+ R: 'Renamed',
+ W: 'Rewritten',
+ U: 'Unchanged',
+};
+
+const FILE_ROW_CLASS = 'file-row';
+
+export interface GrFileList {
+ $: {
+ restAPI: RestApiService & Element;
+ diffPreferencesDialog: GrDiffPreferencesDialog;
+ diffCursor: GrDiffCursor;
+ fileCursor: GrCursorManager;
+ };
+}
+
+interface ReviewedFileInfo extends FileInfo {
+ isReviewed?: boolean;
+}
+interface NormalizedFileInfo extends ReviewedFileInfo {
+ __path: string;
+}
+
+interface PatchChange {
+ inserted: number;
+ deleted: number;
+ size_delta_inserted: number;
+ size_delta_deleted: number;
+ total_size: number;
+}
+
+function createDefaultPatchChange(): PatchChange {
+ // Use function instead of const to prevent unexpected changes in the default
+ // values.
+ return {
+ inserted: 0,
+ deleted: 0,
+ size_delta_inserted: 0,
+ size_delta_deleted: 0,
+ total_size: 0,
+ };
+}
+
+interface SizeBarLayout {
+ maxInserted: number;
+ maxDeleted: number;
+ maxAdditionWidth: number;
+ maxDeletionWidth: number;
+ deletionOffset: number;
+}
+
+function createDefaultSizeBarLayout(): SizeBarLayout {
+ // Use function instead of const to prevent unexpected changes in the default
+ // values.
+ return {
+ maxInserted: 0,
+ maxDeleted: 0,
+ maxAdditionWidth: 0,
+ maxDeletionWidth: 0,
+ deletionOffset: 0,
+ };
+}
+
+interface FileRow {
+ file: PatchSetFile;
+ element: HTMLElement;
+}
+
+export type FileNameToReviewedFileInfoMap = {[name: string]: ReviewedFileInfo};
+
+/**
+ * Type for FileInfo
+ *
+ * This should match with the type returned from `files` API plus
+ * additional info like `__path`.
+ *
+ * @typedef {Object} FileInfo
+ * @property {string} __path
+ * @property {?string} old_path
+ * @property {number} size
+ * @property {number} size_delta - fallback to 0 if not present in api
+ * @property {number} lines_deleted - fallback to 0 if not present in api
+ * @property {number} lines_inserted - fallback to 0 if not present in api
+ */
+
+@customElement('gr-file-list')
+export class GrFileList extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when a draft refresh should get triggered
+ *
+ * @event reload-drafts
+ */
+
+ @property({type: Object})
+ patchRange?: PatchRange;
+
+ @property({type: String})
+ patchNum?: string;
+
+ @property({type: Number})
+ changeNum?: NumericChangeId;
+
+ @property({type: Object})
+ changeComments?: ChangeComments;
+
+ @property({type: Object})
+ drafts?: {[path: string]: UIDraft[]};
+
+ @property({type: Array})
+ revisions?: {[revisionId: string]: RevisionInfo};
+
+ @property({type: Object})
+ projectConfig?: ConfigInfo;
+
+ @property({type: Number, notify: true})
+ selectedIndex = -1;
+
+ @property({type: Object})
+ keyEventTarget = document.body;
+
+ @property({type: Object})
+ change?: ParsedChangeInfo;
+
+ @property({type: String, notify: true, observer: '_updateDiffPreferences'})
+ diffViewMode?: DiffViewMode;
+
+ @property({type: Boolean, observer: '_editModeChanged'})
+ editMode?: boolean;
+
+ @property({type: String, notify: true})
+ filesExpanded = FilesExpandedState.NONE;
+
+ @property({type: Object})
+ _filesByPath?: FileNameToFileInfoMap;
+
+ @property({type: Array, observer: '_filesChanged'})
+ _files: NormalizedFileInfo[] = [];
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Array})
+ _reviewed?: string[] = [];
+
+ @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
+ diffPrefs?: DiffPreferencesInfo;
+
+ @property({type: Object})
+ _userPrefs?: PreferencesInfo;
+
+ @property({type: Boolean})
+ _showInlineDiffs?: boolean;
+
+ @property({type: Number, notify: true})
+ numFilesShown: number = DEFAULT_NUM_FILES_SHOWN;
+
+ @property({type: Object, computed: '_calculatePatchChange(_files)'})
+ _patchChange: PatchChange = createDefaultPatchChange();
+
+ @property({type: Number})
+ fileListIncrement: number = DEFAULT_NUM_FILES_SHOWN;
+
+ @property({type: Boolean, computed: '_shouldHideChangeTotals(_patchChange)'})
+ _hideChangeTotals = true;
+
+ @property({
+ type: Boolean,
+ computed: '_shouldHideBinaryChangeTotals(_patchChange)',
+ })
+ _hideBinaryChangeTotals = true;
+
+ @property({
+ type: Array,
+ computed: '_computeFilesShown(numFilesShown, _files)',
+ })
+ _shownFiles: NormalizedFileInfo[] = [];
+
+ @property({type: Number})
+ _reportinShownFilesIncrement = 0;
+
+ @property({type: Array})
+ _expandedFiles: PatchSetFile[] = [];
+
+ @property({type: Boolean})
+ _displayLine?: boolean;
+
+ @property({type: Boolean, observer: '_loadingChanged'})
+ _loading?: boolean;
+
+ @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
+ _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
+
+ @property({type: Boolean, computed: '_computeShowSizeBars(_userPrefs)'})
+ _showSizeBars = true;
+
+ private _cancelForEachDiff?: () => void;
+
+ @property({
+ type: Boolean,
+ computed:
+ '_computeShowDynamicColumns(_dynamicHeaderEndpoints, ' +
+ '_dynamicContentEndpoints, _dynamicSummaryEndpoints)',
+ })
+ _showDynamicColumns = false;
+
+ @property({
+ type: Boolean,
+ computed:
+ '_computeShowPrependedDynamicColumns(' +
+ '_dynamicPrependedHeaderEndpoints, _dynamicPrependedContentEndpoints)',
+ })
+ _showPrependedDynamicColumns = false;
+
+ @property({type: Array})
+ _dynamicHeaderEndpoints?: string[];
+
+ @property({type: Array})
+ _dynamicContentEndpoints?: string[];
+
+ @property({type: Array})
+ _dynamicSummaryEndpoints?: string[];
+
+ @property({type: Array})
+ _dynamicPrependedHeaderEndpoints?: string[];
+
+ @property({type: Array})
+ _dynamicPrependedContentEndpoints?: string[];
+
+ private readonly reporting = appContext.reportingService;
+
+ get keyBindings() {
+ return {
+ esc: '_handleEscKey',
+ };
+ }
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.LEFT_PANE]: '_handleLeftPane',
+ [Shortcut.RIGHT_PANE]: '_handleRightPane',
+ [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
+ [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
+ [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+ '_handleToggleHideAllCommentThreads',
+ [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
+ [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
+ [Shortcut.NEXT_LINE]: '_handleCursorNext',
+ [Shortcut.PREV_LINE]: '_handleCursorPrev',
+ [Shortcut.NEW_COMMENT]: '_handleNewComment',
+ [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
+ [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
+ [Shortcut.OPEN_FILE]: '_handleOpenFile',
+ [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
+ [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
+ [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
+ [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+
+ // Final two are actually handled by gr-comment-thread.
+ [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+ [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+ };
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-file-list-header'
+ );
+ this._dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-file-list-content'
+ );
+ this._dynamicPrependedHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-file-list-header-prepend'
+ );
+ this._dynamicPrependedContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-file-list-content-prepend'
+ );
+ this._dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
+ 'change-view-file-list-summary'
+ );
+
+ if (
+ this._dynamicHeaderEndpoints.length !==
+ this._dynamicContentEndpoints.length
+ ) {
+ console.warn(
+ 'Different number of dynamic file-list header and content.'
+ );
+ }
+ if (
+ this._dynamicPrependedHeaderEndpoints.length !==
+ this._dynamicPrependedContentEndpoints.length
+ ) {
+ console.warn(
+ 'Different number of dynamic file-list header and content.'
+ );
+ }
+ if (
+ this._dynamicHeaderEndpoints.length !==
+ this._dynamicSummaryEndpoints.length
+ ) {
+ console.warn(
+ 'Different number of dynamic file-list headers and summary.'
+ );
+ }
+ });
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this._cancelDiffs();
+ }
+
+ /**
+ * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+ * events must be scoped to a component level (e.g. `enter`) in order to not
+ * override native browser functionality.
+ *
+ * Context: Issue 7277
+ */
+ _scopedKeydownHandler(e: KeyboardEvent) {
+ if (e.keyCode === 13) {
+ // TODO(TS): e is not an instance of CustomKeyboardEvent.
+ // However, to fix it we should fix keyboard-shortcut-mixin first
+ // The keyboard-shortcut-mixin will be updated in a separate change
+ this._handleOpenFile((e as unknown) as CustomKeyboardEvent);
+ }
+ }
+
+ reload() {
+ if (!this.changeNum || !this.patchRange?.patchNum) {
+ return Promise.resolve();
+ }
+ const changeNum = this.changeNum;
+ const patchRange = this.patchRange;
+
+ this._loading = true;
+
+ this.collapseAllDiffs();
+ const promises = [];
+
+ promises.push(
+ this.$.restAPI
+ .getChangeOrEditFiles(changeNum, patchRange)
+ .then(filesByPath => {
+ this._filesByPath = filesByPath;
+ })
+ );
+ promises.push(
+ this._getLoggedIn()
+ .then(loggedIn => (this._loggedIn = loggedIn))
+ .then(loggedIn => {
+ if (!loggedIn) {
+ return;
+ }
+
+ return this._getReviewedFiles(changeNum, patchRange).then(
+ reviewed => {
+ this._reviewed = reviewed;
+ }
+ );
+ })
+ );
+
+ promises.push(
+ this._getDiffPreferences().then(prefs => {
+ this.diffPrefs = prefs;
+ })
+ );
+
+ promises.push(
+ this._getPreferences().then(prefs => {
+ this._userPrefs = prefs;
+ })
+ );
+
+ return Promise.all(promises).then(() => {
+ this._loading = false;
+ this._detectChromiteButler();
+ this.reporting.fileListDisplayed();
+ });
+ }
+
+ _detectChromiteButler() {
+ const hasButler = !!document.getElementById('butler-suggested-owners');
+ if (hasButler) {
+ this.reporting.reportExtension('butler');
+ }
+ }
+
+ get diffs(): GrDiffHost[] {
+ const diffs = this.root!.querySelectorAll('gr-diff-host');
+ // It is possible that a bogus diff element is hanging around invisibly
+ // from earlier with a different patch set choice and associated with a
+ // different entry in the files array. So filter on visible items only.
+ return Array.from(diffs).filter(
+ el => !!el && !!el.style && el.style.display !== 'none'
+ );
+ }
+
+ openDiffPrefs() {
+ this.$.diffPreferencesDialog.open();
+ }
+
+ _calculatePatchChange(files: NormalizedFileInfo[]): PatchChange {
+ const magicFilesExcluded = files.filter(
+ files => !isMagicPath(files.__path)
+ );
+
+ return magicFilesExcluded.reduce((acc, obj) => {
+ const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+ const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+ const total_size = obj.size && obj.binary ? obj.size : 0;
+ const size_delta_inserted =
+ obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
+ const size_delta_deleted =
+ obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
+
+ return {
+ inserted: acc.inserted + inserted,
+ deleted: acc.deleted + deleted,
+ size_delta_inserted: acc.size_delta_inserted + size_delta_inserted,
+ size_delta_deleted: acc.size_delta_deleted + size_delta_deleted,
+ total_size: acc.total_size + total_size,
+ };
+ }, createDefaultPatchChange());
+ }
+
+ _getDiffPreferences() {
+ return this.$.restAPI.getDiffPreferences();
+ }
+
+ _getPreferences() {
+ return this.$.restAPI.getPreferences();
+ }
+
+ private _toggleFileExpanded(file: PatchSetFile) {
+ // Is the path in the list of expanded diffs? IF so remove it, otherwise
+ // add it to the list.
+ const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
+ if (pathIndex === -1) {
+ this.push('_expandedFiles', file);
+ } else {
+ this.splice('_expandedFiles', pathIndex, 1);
+ }
+ }
+
+ _toggleFileExpandedByIndex(index: number) {
+ this._toggleFileExpanded(this._computePatchSetFile(this._files[index]));
+ }
+
+ _updateDiffPreferences() {
+ if (!this.diffs.length) {
+ return;
+ }
+ // Re-render all expanded diffs sequentially.
+ this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+ this._renderInOrder(
+ this._expandedFiles,
+ this.diffs,
+ this._expandedFiles.length
+ );
+ }
+
+ _forEachDiff(fn: (host: GrDiffHost) => void) {
+ const diffs = this.diffs;
+ for (let i = 0; i < diffs.length; i++) {
+ fn(diffs[i]);
+ }
+ }
+
+ expandAllDiffs() {
+ this._showInlineDiffs = true;
+
+ // Find the list of paths that are in the file list, but not in the
+ // expanded list.
+ const newFiles: PatchSetFile[] = [];
+ let path: string;
+ for (let i = 0; i < this._shownFiles.length; i++) {
+ path = this._shownFiles[i].__path;
+ if (!this._expandedFiles.some(f => f.path === path)) {
+ newFiles.push(this._computePatchSetFile(this._shownFiles[i]));
+ }
+ }
+
+ this.splice('_expandedFiles', 0, 0, ...newFiles);
+ }
+
+ collapseAllDiffs() {
+ this._showInlineDiffs = false;
+ this._expandedFiles = [];
+ this.filesExpanded = this._computeExpandedFiles(
+ this._expandedFiles.length,
+ this._files.length
+ );
+ this.$.diffCursor.handleDiffUpdate();
+ }
+
+ /**
+ * Computes a string with the number of comments and unresolved comments.
+ */
+ _computeCommentsString(
+ changeComments?: ChangeComments,
+ patchRange?: PatchRange,
+ path?: string
+ ) {
+ if (
+ changeComments === undefined ||
+ patchRange === undefined ||
+ path === undefined
+ ) {
+ return '';
+ }
+ const unresolvedCount =
+ changeComments.computeUnresolvedNum({
+ patchNum: patchRange.basePatchNum,
+ path,
+ }) +
+ changeComments.computeUnresolvedNum({
+ patchNum: patchRange.patchNum,
+ path,
+ });
+ const commentThreadCount =
+ changeComments.computeCommentThreadCount({
+ patchNum: patchRange.basePatchNum,
+ path,
+ }) +
+ changeComments.computeCommentThreadCount({
+ patchNum: patchRange.patchNum,
+ path,
+ });
+ const commentString = GrCountStringFormatter.computePluralString(
+ commentThreadCount,
+ 'comment'
+ );
+ const unresolvedString = GrCountStringFormatter.computeString(
+ unresolvedCount,
+ 'unresolved'
+ );
+
+ return (
+ commentString +
+ // Add a space if both comments and unresolved
+ (commentString && unresolvedString ? ' ' : '') +
+ // Add parentheses around unresolved if it exists.
+ (unresolvedString ? `(${unresolvedString})` : '')
+ );
+ }
+
+ /**
+ * Computes a string with the number of drafts.
+ */
+ _computeDraftsString(
+ changeComments?: ChangeComments,
+ patchRange?: PatchRange,
+ path?: string
+ ) {
+ if (
+ changeComments === undefined ||
+ patchRange === undefined ||
+ path === undefined
+ ) {
+ return '';
+ }
+ const draftCount =
+ changeComments.computeDraftCount({
+ patchNum: patchRange.basePatchNum,
+ path,
+ }) +
+ changeComments.computeDraftCount({
+ patchNum: patchRange.patchNum,
+ path,
+ });
+ return GrCountStringFormatter.computePluralString(draftCount, 'draft');
+ }
+
+ /**
+ * Computes a shortened string with the number of drafts.
+ */
+ _computeDraftsStringMobile(
+ changeComments?: ChangeComments,
+ patchRange?: PatchRange,
+ path?: string
+ ) {
+ if (
+ changeComments === undefined ||
+ patchRange === undefined ||
+ path === undefined
+ ) {
+ return '';
+ }
+ const draftCount =
+ changeComments.computeDraftCount({
+ patchNum: patchRange.basePatchNum,
+ path,
+ }) +
+ changeComments.computeDraftCount({
+ patchNum: patchRange.patchNum,
+ path,
+ });
+ return GrCountStringFormatter.computeShortString(draftCount, 'd');
+ }
+
+ /**
+ * Computes a shortened string with the number of comments.
+ */
+ _computeCommentsStringMobile(
+ changeComments?: ChangeComments,
+ patchRange?: PatchRange,
+ path?: string
+ ) {
+ if (
+ changeComments === undefined ||
+ patchRange === undefined ||
+ path === undefined
+ ) {
+ return '';
+ }
+ const commentThreadCount =
+ changeComments.computeCommentThreadCount({
+ patchNum: patchRange.basePatchNum,
+ path,
+ }) +
+ changeComments.computeCommentThreadCount({
+ patchNum: patchRange.patchNum,
+ path,
+ });
+ return GrCountStringFormatter.computeShortString(commentThreadCount, 'c');
+ }
+
+ private _reviewFile(path: string, reviewed?: boolean) {
+ if (this.editMode) {
+ return Promise.resolve();
+ }
+ const index = this._files.findIndex(file => file.__path === path);
+ reviewed = reviewed || !this._files[index].isReviewed;
+
+ this.set(['_files', index, 'isReviewed'], reviewed);
+ if (index < this._shownFiles.length) {
+ this.notifyPath(`_shownFiles.${index}.isReviewed`);
+ }
+
+ return this._saveReviewedState(path, reviewed);
+ }
+
+ _saveReviewedState(path: string, reviewed: boolean) {
+ if (!this.changeNum || !this.patchRange) {
+ throw new Error('changeNum and patchRange must be set');
+ }
+
+ return this.$.restAPI.saveFileReviewed(
+ this.changeNum,
+ this.patchRange.patchNum,
+ path,
+ reviewed
+ );
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _getReviewedFiles(changeNum: NumericChangeId, patchRange: PatchRange) {
+ if (this.editMode) {
+ return Promise.resolve([]);
+ }
+ return this.$.restAPI.getReviewedFiles(changeNum, patchRange.patchNum);
+ }
+
+ _normalizeChangeFilesResponse(
+ response: FileNameToReviewedFileInfoMap
+ ): NormalizedFileInfo[] {
+ const paths = Object.keys(response).sort(specialFilePathCompare);
+ const files: NormalizedFileInfo[] = [];
+ for (let i = 0; i < paths.length; i++) {
+ // TODO(TS): make copy instead of as NormalizedFileInfo
+ const info = response[paths[i]] as NormalizedFileInfo;
+ info.__path = paths[i];
+ info.lines_inserted = info.lines_inserted || 0;
+ info.lines_deleted = info.lines_deleted || 0;
+ info.size_delta = info.size_delta || 0;
+ files.push(info);
+ }
+ return files;
+ }
+
+ /**
+ * Returns true if the event e is a click on an element.
+ *
+ * The click is: mouse click or pressing Enter or Space key
+ * P.S> Screen readers sends click event as well
+ */
+ _isClickEvent(e: MouseEvent | KeyboardEvent) {
+ if (e.type === 'click') {
+ return true;
+ }
+ const ke = e as KeyboardEvent;
+ const isSpaceOrEnter = ke.key === 'Enter' || ke.key === ' ';
+ return ke.type === 'keydown' && isSpaceOrEnter;
+ }
+
+ _fileActionClick(
+ e: MouseEvent | KeyboardEvent,
+ fileAction: (file: PatchSetFile) => void
+ ) {
+ if (this._isClickEvent(e)) {
+ const fileRow = this._getFileRowFromEvent(e);
+ if (!fileRow) {
+ return;
+ }
+ // Prevent default actions (e.g. scrolling for space key)
+ e.preventDefault();
+ // Prevent _handleFileListClick handler call
+ e.stopPropagation();
+ this.$.fileCursor.setCursor(fileRow.element);
+ fileAction(fileRow.file);
+ }
+ }
+
+ _reviewedClick(e: MouseEvent | KeyboardEvent) {
+ this._fileActionClick(e, file => this._reviewFile(file.path));
+ }
+
+ _expandedClick(e: MouseEvent | KeyboardEvent) {
+ this._fileActionClick(e, file => this._toggleFileExpanded(file));
+ }
+
+ /**
+ * Handle all events from the file list dom-repeat so event handleers don't
+ * have to get registered for potentially very long lists.
+ */
+ _handleFileListClick(e: MouseEvent) {
+ if (!e.target) {
+ return;
+ }
+ const fileRow = this._getFileRowFromEvent(e);
+ if (!fileRow) {
+ return;
+ }
+ const file = fileRow.file;
+ const path = file.path;
+ // If a path cannot be interpreted from the click target (meaning it's not
+ // somewhere in the row, e.g. diff content) or if the user clicked the
+ // link, defer to the native behavior.
+ if (!path || descendedFromClass(e.target as Element, 'pathLink')) {
+ return;
+ }
+
+ // Disregard the event if the click target is in the edit controls.
+ if (descendedFromClass(e.target as Element, 'editFileControls')) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.fileCursor.setCursor(fileRow.element);
+ this._toggleFileExpanded(file);
+ }
+
+ _getFileRowFromEvent(e: Event): FileRow | null {
+ // Traverse upwards to find the row element if the target is not the row.
+ let row = e.target as HTMLElement;
+ while (!row.classList.contains(FILE_ROW_CLASS) && row.parentElement) {
+ row = row.parentElement;
+ }
+
+ // No action needed for item without a valid file
+ if (!row.dataset['file']) {
+ return null;
+ }
+
+ return {
+ file: JSON.parse(row.dataset['file']) as PatchSetFile,
+ element: row,
+ };
+ }
+
+ /**
+ * Generates file range from file info object.
+ */
+ _computePatchSetFile(file: NormalizedFileInfo): PatchSetFile {
+ const fileData: PatchSetFile = {
+ path: file.__path,
+ };
+ if (file.old_path) {
+ fileData.basePath = file.old_path;
+ }
+ return fileData;
+ }
+
+ _handleLeftPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.diffCursor.moveLeft();
+ }
+
+ _handleRightPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.diffCursor.moveRight();
+ }
+
+ _handleToggleInlineDiff(e: CustomKeyboardEvent) {
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ this.modifierPressed(e) ||
+ this.$.fileCursor.index === -1
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this._toggleFileExpandedByIndex(this.$.fileCursor.index);
+ }
+
+ _handleToggleAllInlineDiffs(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._toggleInlineDiffs();
+ }
+
+ _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.toggleClass('hideComments');
+ }
+
+ _handleCursorNext(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ if (this._showInlineDiffs) {
+ e.preventDefault();
+ this.$.diffCursor.moveDown();
+ this._displayLine = true;
+ } else {
+ // Down key
+ if (this.getKeyboardEvent(e).keyCode === 40) {
+ return;
+ }
+ e.preventDefault();
+ this.$.fileCursor.next();
+ this.selectedIndex = this.$.fileCursor.index;
+ }
+ }
+
+ _handleCursorPrev(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ if (this._showInlineDiffs) {
+ e.preventDefault();
+ this.$.diffCursor.moveUp();
+ this._displayLine = true;
+ } else {
+ // Up key
+ if (this.getKeyboardEvent(e).keyCode === 38) {
+ return;
+ }
+ e.preventDefault();
+ this.$.fileCursor.previous();
+ this.selectedIndex = this.$.fileCursor.index;
+ }
+ }
+
+ _handleNewComment(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ e.preventDefault();
+ this.$.diffCursor.createCommentInPlace();
+ }
+
+ _handleOpenLastFile(e: CustomKeyboardEvent) {
+ // Check for meta key to avoid overriding native chrome shortcut.
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ this.getKeyboardEvent(e).metaKey
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this._openSelectedFile(this._files.length - 1);
+ }
+
+ _handleOpenFirstFile(e: CustomKeyboardEvent) {
+ // Check for meta key to avoid overriding native chrome shortcut.
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ this.getKeyboardEvent(e).metaKey
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this._openSelectedFile(0);
+ }
+
+ _handleOpenFile(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ e.preventDefault();
+
+ if (this._showInlineDiffs) {
+ this._openCursorFile();
+ return;
+ }
+
+ this._openSelectedFile();
+ }
+
+ _handleNextChunk(e: CustomKeyboardEvent) {
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ (this.modifierPressed(e) &&
+ !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+ this._noDiffsExpanded()
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+ this.$.diffCursor.moveToNextCommentThread();
+ } else {
+ this.$.diffCursor.moveToNextChunk();
+ }
+ }
+
+ _handlePrevChunk(e: CustomKeyboardEvent) {
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ (this.modifierPressed(e) &&
+ !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
+ this._noDiffsExpanded()
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
+ this.$.diffCursor.moveToPreviousCommentThread();
+ } else {
+ this.$.diffCursor.moveToPreviousChunk();
+ }
+ }
+
+ _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ if (!this._files[this.$.fileCursor.index]) {
+ return;
+ }
+ this._reviewFile(this._files[this.$.fileCursor.index].__path);
+ }
+
+ _handleToggleLeftPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this._forEachDiff(diff => {
+ diff.toggleLeftDiff();
+ });
+ }
+
+ _toggleInlineDiffs() {
+ if (this._showInlineDiffs) {
+ this.collapseAllDiffs();
+ } else {
+ this.expandAllDiffs();
+ }
+ }
+
+ _openCursorFile() {
+ const diff = this.$.diffCursor.getTargetDiffElement();
+ if (
+ !this.change ||
+ !diff ||
+ !this.patchRange ||
+ !diff.path ||
+ !diff.patchRange
+ ) {
+ throw new Error('change, diff and patchRange must be all set and valid');
+ }
+ GerritNav.navigateToDiff(
+ this.change,
+ diff.path,
+ diff.patchRange.patchNum,
+ this.patchRange.basePatchNum
+ );
+ }
+
+ _openSelectedFile(index?: number) {
+ if (index !== undefined) {
+ this.$.fileCursor.setCursorAtIndex(index);
+ }
+ if (!this._files[this.$.fileCursor.index]) {
+ return;
+ }
+ if (!this.change || !this.patchRange) {
+ throw new Error('change and patchRange must be set');
+ }
+ GerritNav.navigateToDiff(
+ this.change,
+ this._files[this.$.fileCursor.index].__path,
+ this.patchRange.patchNum,
+ this.patchRange.basePatchNum
+ );
+ }
+
+ _addDraftAtTarget() {
+ const diff = this.$.diffCursor.getTargetDiffElement();
+ const target = this.$.diffCursor.getTargetLineElement();
+ if (diff && target) {
+ diff.addDraftAtLine(target);
+ }
+ }
+
+ _shouldHideChangeTotals(_patchChange: PatchChange): boolean {
+ return _patchChange.inserted === 0 && _patchChange.deleted === 0;
+ }
+
+ _shouldHideBinaryChangeTotals(_patchChange: PatchChange) {
+ return (
+ _patchChange.size_delta_inserted === 0 &&
+ _patchChange.size_delta_deleted === 0
+ );
+ }
+
+ _computeFileStatus(
+ status?: keyof typeof FileStatus
+ ): keyof typeof FileStatus {
+ return status || 'M';
+ }
+
+ _computeDiffURL(
+ change?: ParsedChangeInfo,
+ patchRange?: PatchRange,
+ path?: string,
+ editMode?: boolean
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ change === undefined ||
+ patchRange === undefined ||
+ path === undefined ||
+ editMode === undefined
+ ) {
+ return;
+ }
+ if (editMode && path !== SpecialFilePath.MERGE_LIST) {
+ return GerritNav.getEditUrlForDiff(change, path, patchRange.patchNum);
+ }
+ return GerritNav.getUrlForDiff(
+ change,
+ path,
+ patchRange.patchNum,
+ patchRange.basePatchNum
+ );
+ }
+
+ _formatBytes(bytes?: number) {
+ if (!bytes) return '+/-0 B';
+ const bits = 1024;
+ const decimals = 1;
+ const sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+ const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+ const prepend = bytes > 0 ? '+' : '';
+ const value = parseFloat(
+ (bytes / Math.pow(bits, exponent)).toFixed(decimals)
+ );
+ return `${prepend}${value} ${sizes[exponent]}`;
+ }
+
+ _formatPercentage(size?: number, delta?: number) {
+ if (size === undefined || delta === undefined) {
+ return '';
+ }
+ const oldSize = size - delta;
+
+ if (oldSize === 0) {
+ return '';
+ }
+
+ const percentage = Math.round(Math.abs((delta * 100) / oldSize));
+ return `(${delta > 0 ? '+' : '-'}${percentage}%)`;
+ }
+
+ _computeBinaryClass(delta?: number) {
+ if (!delta) {
+ return;
+ }
+ return delta > 0 ? 'added' : 'removed';
+ }
+
+ _computeClass(baseClass?: string, path?: string) {
+ const classes = [];
+ if (baseClass) {
+ classes.push(baseClass);
+ }
+ if (
+ path === SpecialFilePath.COMMIT_MESSAGE ||
+ path === SpecialFilePath.MERGE_LIST
+ ) {
+ classes.push('invisible');
+ }
+ return classes.join(' ');
+ }
+
+ _computeStatusClass(file?: NormalizedFileInfo) {
+ if (!file) return '';
+ const classStr = this._computeClass('status', file.__path);
+ return `${classStr} ${this._computeFileStatus(file.status)}`;
+ }
+
+ _computePathClass(
+ path: string | undefined,
+ expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+ ) {
+ return this._isFileExpanded(path, expandedFilesRecord) ? 'expanded' : '';
+ }
+
+ _computeShowHideIcon(
+ path: string | undefined,
+ expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+ ) {
+ return this._isFileExpanded(path, expandedFilesRecord)
+ ? 'gr-icons:expand-less'
+ : 'gr-icons:expand-more';
+ }
+
+ @observe(
+ '_filesByPath',
+ 'changeComments',
+ 'patchRange',
+ '_reviewed',
+ '_loading'
+ )
+ _computeFiles(
+ filesByPath?: FileNameToFileInfoMap,
+ changeComments?: ChangeComments,
+ patchRange?: PatchRange,
+ reviewed?: string[],
+ loading?: boolean
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ filesByPath === undefined ||
+ changeComments === undefined ||
+ patchRange === undefined ||
+ reviewed === undefined ||
+ loading === undefined
+ ) {
+ return;
+ }
+
+ // Await all promises resolving from reload. @See Issue 9057
+ if (loading || !changeComments) {
+ return;
+ }
+
+ const commentedPaths = changeComments.getPaths(patchRange);
+ const files: FileNameToReviewedFileInfoMap = {...filesByPath};
+ addUnmodifiedFiles(files, commentedPaths);
+ const reviewedSet = new Set(reviewed || []);
+ for (const filePath in files) {
+ if (!hasOwnProperty(files, filePath)) {
+ continue;
+ }
+ files[filePath].isReviewed = reviewedSet.has(filePath);
+ }
+
+ this._files = this._normalizeChangeFilesResponse(files);
+ }
+
+ _computeFilesShown(
+ numFilesShown: number,
+ files: NormalizedFileInfo[]
+ ): NormalizedFileInfo[] | undefined {
+ // Polymer 2: check for undefined
+ if (numFilesShown === undefined || files === undefined) return undefined;
+
+ const previousNumFilesShown = this._shownFiles
+ ? this._shownFiles.length
+ : 0;
+
+ const filesShown = files.slice(0, numFilesShown);
+ this.dispatchEvent(
+ new CustomEvent('files-shown-changed', {
+ detail: {length: filesShown.length},
+ composed: true,
+ bubbles: true,
+ })
+ );
+
+ // Start the timer for the rendering work hwere because this is where the
+ // _shownFiles property is being set, and _shownFiles is used in the
+ // dom-repeat binding.
+ this.reporting.time(RENDER_TIMING_LABEL);
+
+ // How many more files are being shown (if it's an increase).
+ this._reportinShownFilesIncrement = Math.max(
+ 0,
+ filesShown.length - previousNumFilesShown
+ );
+
+ return filesShown;
+ }
+
+ _updateDiffCursor() {
+ // Overwrite the cursor's list of diffs:
+ this.$.diffCursor.splice(
+ 'diffs',
+ 0,
+ this.$.diffCursor.diffs.length,
+ ...this.diffs
+ );
+ }
+
+ _filesChanged() {
+ if (this._files && this._files.length > 0) {
+ flush();
+ this.$.fileCursor.stops = Array.from(
+ this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)
+ );
+ this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
+ }
+ }
+
+ _incrementNumFilesShown() {
+ this.numFilesShown += this.fileListIncrement;
+ }
+
+ _computeFileListControlClass(
+ numFilesShown?: number,
+ files?: NormalizedFileInfo[]
+ ) {
+ if (numFilesShown === undefined || files === undefined) return 'invisible';
+ return numFilesShown >= files.length ? 'invisible' : '';
+ }
+
+ _computeIncrementText(numFilesShown?: number, files?: NormalizedFileInfo[]) {
+ if (numFilesShown === undefined || files === undefined) return '';
+ const text = Math.min(this.fileListIncrement, files.length - numFilesShown);
+ return `Show ${text} more`;
+ }
+
+ _computeShowAllText(files: NormalizedFileInfo[]) {
+ if (!files) {
+ return '';
+ }
+ return `Show all ${files.length} files`;
+ }
+
+ _computeWarnShowAll(files: NormalizedFileInfo[]) {
+ return files.length > WARN_SHOW_ALL_THRESHOLD;
+ }
+
+ _computeShowAllWarning(files: NormalizedFileInfo[]) {
+ if (!this._computeWarnShowAll(files)) {
+ return '';
+ }
+ return `Warning: showing all ${files.length} files may take several seconds.`;
+ }
+
+ _showAllFiles() {
+ this.numFilesShown = this._files.length;
+ }
+
+ /**
+ * Get a descriptive label for use in the status indicator's tooltip and
+ * ARIA label.
+ */
+ _computeFileStatusLabel(status?: keyof typeof FileStatus) {
+ const statusCode = this._computeFileStatus(status);
+ return hasOwnProperty(FileStatus, statusCode)
+ ? FileStatus[statusCode]
+ : 'Status Unknown';
+ }
+
+ /**
+ * Converts any boolean-like variable to the string 'true' or 'false'
+ *
+ * This method is useful when you bind aria-checked attribute to a boolean
+ * value. The aria-checked attribute is string attribute. Binding directly
+ * to boolean variable causes problem on gerrit-CI.
+ *
+ * @return 'true' if val is true-like, otherwise false
+ */
+ _booleanToString(val?: unknown) {
+ return val ? 'true' : 'false';
+ }
+
+ _isFileExpanded(
+ path: string | undefined,
+ expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+ ) {
+ return expandedFilesRecord.base.some(f => f.path === path);
+ }
+
+ _isFileExpandedStr(
+ path: string | undefined,
+ expandedFilesRecord: ElementPropertyDeepChange<GrFileList, '_expandedFiles'>
+ ) {
+ return this._booleanToString(
+ this._isFileExpanded(path, expandedFilesRecord)
+ );
+ }
+
+ private _computeExpandedFiles(
+ expandedCount: number,
+ totalCount: number
+ ): FilesExpandedState {
+ if (expandedCount === 0) {
+ return FilesExpandedState.NONE;
+ } else if (expandedCount === totalCount) {
+ return FilesExpandedState.ALL;
+ }
+ return FilesExpandedState.SOME;
+ }
+
+ /**
+ * Handle splices to the list of expanded file paths. If there are any new
+ * entries in the expanded list, then render each diff corresponding in
+ * order by waiting for the previous diff to finish before starting the next
+ * one.
+ *
+ * @param record The splice record in the expanded paths list.
+ */
+ @observe('_expandedFiles.splices')
+ _expandedFilesChanged(record?: PolymerSpliceChange<PatchSetFile[]>) {
+ // Clear content for any diffs that are not open so if they get re-opened
+ // the stale content does not flash before it is cleared and reloaded.
+ const collapsedDiffs = this.diffs.filter(
+ diff => this._expandedFiles.findIndex(f => f.path === diff.path) === -1
+ );
+ this._clearCollapsedDiffs(collapsedDiffs);
+
+ if (!record) {
+ return;
+ } // Happens after "Collapse all" clicked.
+
+ this.filesExpanded = this._computeExpandedFiles(
+ this._expandedFiles.length,
+ this._files.length
+ );
+
+ // Find the paths introduced by the new index splices:
+ const newFiles = record.indexSplices
+ .map(splice =>
+ splice.object.slice(splice.index, splice.index + splice.addedCount)
+ )
+ .reduce((acc, paths) => acc.concat(paths), []);
+
+ // Required so that the newly created diff view is included in this.diffs.
+ flush();
+
+ this.reporting.time(EXPAND_ALL_TIMING_LABEL);
+
+ if (newFiles.length) {
+ this._renderInOrder(newFiles, this.diffs, newFiles.length);
+ }
+
+ this._updateDiffCursor();
+ this.$.diffCursor.reInitAndUpdateStops();
+ }
+
+ private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
+ for (const diff of collapsedDiffs) {
+ diff.cancel();
+ diff.clearDiffContent();
+ }
+ }
+
+ /**
+ * Given an array of paths and a NodeList of diff elements, render the diff
+ * for each path in order, awaiting the previous render to complete before
+ * continuing.
+ *
+ * @param initialCount The total number of paths in the pass. This
+ * is used to generate log messages.
+ */
+ private _renderInOrder(
+ files: PatchSetFile[],
+ diffElements: GrDiffHost[],
+ initialCount: number
+ ) {
+ let iter = 0;
+
+ for (const file of files) {
+ const path = file.path;
+ const diffElem = this._findDiffByPath(path, diffElements);
+ if (diffElem) {
+ diffElem.prefetchDiff();
+ }
+ }
+
+ return new Promise(resolve => {
+ this.dispatchEvent(
+ new CustomEvent('reload-drafts', {
+ detail: {resolve},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }).then(() =>
+ asyncForeach(files, (file, cancel) => {
+ const path = file.path;
+ this._cancelForEachDiff = cancel;
+
+ iter++;
+ console.info('Expanding diff', iter, 'of', initialCount, ':', path);
+ const diffElem = this._findDiffByPath(path, diffElements);
+ if (!diffElem) {
+ console.warn(`Did not find <gr-diff-host> element for ${path}`);
+ return Promise.resolve();
+ }
+ if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
+ throw new Error(
+ 'changeComments, patchRange and diffPrefs must be set'
+ );
+ }
+ diffElem.comments = this.changeComments.getCommentsBySideForFile(
+ file,
+ this.patchRange,
+ this.projectConfig
+ );
+ const promises: Array<Promise<unknown>> = [diffElem.reload()];
+ if (this._loggedIn && !this.diffPrefs.manual_review) {
+ promises.push(this._reviewFile(path, true));
+ }
+ return Promise.all(promises);
+ }).then(() => {
+ this._cancelForEachDiff = undefined;
+ console.info('Finished expanding', initialCount, 'diff(s)');
+ this.reporting.timeEndWithAverage(
+ EXPAND_ALL_TIMING_LABEL,
+ EXPAND_ALL_AVG_TIMING_LABEL,
+ initialCount
+ );
+ /* Block diff cursor from auto scrolling after files are done rendering.
+ * This prevents the bug where the screen jumps to the first diff chunk
+ * after files are done being rendered after the user has already begun
+ * scrolling.
+ * This also however results in the fact that the cursor does not auto
+ * focus on the first diff chunk on a small screen. This is however, a use
+ * case we are willing to not support for now.
+
+ * Using handleDiffUpdate resulted in diffCursor.row being set which
+ * prevented the issue of scrolling to top when we expand the second
+ * file individually.
+ */
+ this.$.diffCursor.reInitAndUpdateStops();
+ })
+ );
+ }
+
+ /** Cancel the rendering work of every diff in the list */
+ _cancelDiffs() {
+ if (this._cancelForEachDiff) {
+ this._cancelForEachDiff();
+ }
+ this._forEachDiff(d => d.cancel());
+ }
+
+ /**
+ * In the given NodeList of diff elements, find the diff for the given path.
+ */
+ private _findDiffByPath(path: string, diffElements: GrDiffHost[]) {
+ for (let i = 0; i < diffElements.length; i++) {
+ if (diffElements[i].path === path) {
+ return diffElements[i];
+ }
+ }
+ return undefined;
+ }
+
+ /**
+ * Reset the comments of a modified thread
+ */
+ reloadCommentsForThreadWithRootId(rootId: UrlEncodedCommentId, path: string) {
+ // Don't bother continuing if we already know that the path that contains
+ // the updated comment thread is not expanded.
+ if (!this._expandedFiles.some(f => f.path === path)) {
+ return;
+ }
+ const diff = this.diffs.find(d => d.path === path);
+
+ if (!diff) {
+ throw new Error("Can't find diff by path");
+ }
+
+ const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
+ if (!threadEl) {
+ return;
+ }
+
+ if (!this.changeComments) {
+ throw new Error('changeComments must be set');
+ }
+
+ const newComments = this.changeComments.getCommentsForThread(rootId);
+
+ // If newComments is null, it means that a single draft was
+ // removed from a thread in the thread view, and the thread should
+ // no longer exist. Remove the existing thread element in the diff
+ // view.
+ if (!newComments) {
+ threadEl.fireRemoveSelf();
+ return;
+ }
+
+ // Comments are not returned with the commentSide attribute from
+ // the api, but it's necessary to be stored on the diff's
+ // comments due to use in the _handleCommentUpdate function.
+ // The comment thread already has a side associated with it, so
+ // set the comment's side to match.
+ threadEl.comments = newComments.map(c =>
+ Object.assign(c, {__commentSide: threadEl.commentSide})
+ );
+ flush();
+ }
+
+ _handleEscKey(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+ return;
+ }
+ e.preventDefault();
+ this._displayLine = false;
+ }
+
+ /**
+ * Update the loading class for the file list rows. The update is inside a
+ * debouncer so that the file list doesn't flash gray when the API requests
+ * are reasonably fast.
+ */
+ _loadingChanged(loading?: boolean) {
+ this.debounce(
+ 'loading-change',
+ () => {
+ // Only show set the loading if there have been files loaded to show. In
+ // this way, the gray loading style is not shown on initial loads.
+ this.classList.toggle('loading', loading && !!this._files.length);
+ },
+ LOADING_DEBOUNCE_INTERVAL
+ );
+ }
+
+ _editModeChanged(editMode?: boolean) {
+ this.classList.toggle('editMode', editMode);
+ }
+
+ _computeReviewedClass(isReviewed?: boolean) {
+ return isReviewed ? 'isReviewed' : '';
+ }
+
+ _computeReviewedText(isReviewed?: boolean) {
+ return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+ }
+
+ /**
+ * Given a file path, return whether that path should have visible size bars
+ * and be included in the size bars calculation.
+ */
+ _showBarsForPath(path?: string) {
+ return (
+ path !== SpecialFilePath.COMMIT_MESSAGE &&
+ path !== SpecialFilePath.MERGE_LIST
+ );
+ }
+
+ /**
+ * Compute size bar layout values from the file list.
+ */
+ _computeSizeBarLayout(
+ shownFilesRecord?: ElementPropertyDeepChange<GrFileList, '_shownFiles'>
+ ) {
+ const stats: SizeBarLayout = createDefaultSizeBarLayout();
+ if (!shownFilesRecord || !shownFilesRecord.base) {
+ return stats;
+ }
+ shownFilesRecord.base
+ .filter(f => this._showBarsForPath(f.__path))
+ .forEach(f => {
+ if (f.lines_inserted) {
+ stats.maxInserted = Math.max(stats.maxInserted, f.lines_inserted);
+ }
+ if (f.lines_deleted) {
+ stats.maxDeleted = Math.max(stats.maxDeleted, f.lines_deleted);
+ }
+ });
+ const ratio = stats.maxInserted / (stats.maxInserted + stats.maxDeleted);
+ if (!isNaN(ratio)) {
+ stats.maxAdditionWidth =
+ (SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH) * ratio;
+ stats.maxDeletionWidth =
+ SIZE_BAR_MAX_WIDTH - SIZE_BAR_GAP_WIDTH - stats.maxAdditionWidth;
+ stats.deletionOffset = stats.maxAdditionWidth + SIZE_BAR_GAP_WIDTH;
+ }
+ return stats;
+ }
+
+ /**
+ * Get the width of the addition bar for a file.
+ */
+ _computeBarAdditionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+ if (
+ !file ||
+ !stats ||
+ stats.maxInserted === 0 ||
+ !file.lines_inserted ||
+ !this._showBarsForPath(file.__path)
+ ) {
+ return 0;
+ }
+ const width =
+ (stats.maxAdditionWidth * file.lines_inserted) / stats.maxInserted;
+ return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+ }
+
+ /**
+ * Get the x-offset of the addition bar for a file.
+ */
+ _computeBarAdditionX(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+ if (!file || !stats) return;
+ return stats.maxAdditionWidth - this._computeBarAdditionWidth(file, stats);
+ }
+
+ /**
+ * Get the width of the deletion bar for a file.
+ */
+ _computeBarDeletionWidth(file?: NormalizedFileInfo, stats?: SizeBarLayout) {
+ if (
+ !file ||
+ !stats ||
+ stats.maxDeleted === 0 ||
+ !file.lines_deleted ||
+ !this._showBarsForPath(file.__path)
+ ) {
+ return 0;
+ }
+ const width =
+ (stats.maxDeletionWidth * file.lines_deleted) / stats.maxDeleted;
+ return width === 0 ? 0 : Math.max(SIZE_BAR_MIN_WIDTH, width);
+ }
+
+ /**
+ * Get the x-offset of the deletion bar for a file.
+ */
+ _computeBarDeletionX(stats: SizeBarLayout) {
+ return stats.deletionOffset;
+ }
+
+ _computeShowSizeBars(userPrefs?: PreferencesInfo) {
+ return !!userPrefs?.size_bar_in_change_table;
+ }
+
+ _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
+ let hideClass = '';
+ if (!showSizeBars) {
+ hideClass = 'hide';
+ } else if (!this._showBarsForPath(path)) {
+ hideClass = 'invisible';
+ }
+ return `sizeBars desktop ${hideClass}`;
+ }
+
+ /**
+ * Shows registered dynamic columns iff the 'header', 'content' and
+ * 'summary' endpoints are registered the exact same number of times.
+ * Ideally, there should be a better way to enforce the expectation of the
+ * dependencies between dynamic endpoints.
+ */
+ _computeShowDynamicColumns(
+ headerEndpoints?: string,
+ contentEndpoints?: string,
+ summaryEndpoints?: string
+ ) {
+ return (
+ headerEndpoints &&
+ contentEndpoints &&
+ summaryEndpoints &&
+ headerEndpoints.length &&
+ headerEndpoints.length === contentEndpoints.length &&
+ headerEndpoints.length === summaryEndpoints.length
+ );
+ }
+
+ /**
+ * Shows registered dynamic prepended columns iff the 'header', 'content'
+ * endpoints are registered the exact same number of times.
+ */
+ _computeShowPrependedDynamicColumns(
+ headerEndpoints?: string,
+ contentEndpoints?: string
+ ) {
+ return (
+ headerEndpoints &&
+ contentEndpoints &&
+ headerEndpoints.length &&
+ headerEndpoints.length === contentEndpoints.length
+ );
+ }
+
+ /**
+ * Returns true if none of the inline diffs have been expanded.
+ */
+ _noDiffsExpanded() {
+ return this.filesExpanded === FilesExpandedState.NONE;
+ }
+
+ /**
+ * Method to call via binding when each file list row is rendered. This
+ * allows approximate detection of when the dom-repeat has completed
+ * rendering.
+ *
+ * @param index The index of the row being rendered.
+ */
+ _reportRenderedRow(index: number) {
+ if (index === this._shownFiles.length - 1) {
+ this.async(() => {
+ this.reporting.timeEndWithAverage(
+ RENDER_TIMING_LABEL,
+ RENDER_AVG_TIMING_LABEL,
+ this._reportinShownFilesIncrement
+ );
+ }, 1);
+ }
+ return '';
+ }
+
+ _reviewedTitle(reviewed?: boolean) {
+ if (reviewed) {
+ return 'Mark as not reviewed (shortcut: r)';
+ }
+
+ return 'Mark as reviewed (shortcut: r)';
+ }
+
+ _handleReloadingDiffPreference() {
+ this._getDiffPreferences().then(prefs => {
+ this.diffPrefs = prefs;
+ });
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeDisplayPath(path: string) {
+ return computeDisplayPath(path);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeTruncatedPath(path: string) {
+ return computeTruncatedPath(path);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-file-list': GrFileList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
index e141b70..d93ce68 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_html.ts
@@ -314,6 +314,7 @@
--gr-comment-thread-display: none;
}
</style>
+ <h3 class="assistive-tech-only">File list</h3>
<div
id="container"
on-click="_handleFileListClick"
@@ -375,7 +376,7 @@
<div class="stickyArea">
<div
class$="file-row row [[_computePathClass(file.__path, _expandedFiles.*)]]"
- data-file$="[[_computeFileRange(file)]]"
+ data-file$="[[_computePatchSetFile(file)]]"
tabindex="-1"
role="row"
>
@@ -656,8 +657,9 @@
display-line="[[_displayLine]]"
hidden="[[!_isFileExpanded(file.__path, _expandedFiles.*)]]"
change-num="[[changeNum]]"
+ change="[[change]]"
patch-range="[[patchRange]]"
- file="[[_computeFileRange(file)]]"
+ file="[[_computePatchSetFile(file)]]"
path="[[file.__path]]"
prefs="[[diffPrefs]]"
project-name="[[change.project]]"
@@ -670,20 +672,22 @@
</div>
<div class="row totalChanges" hidden$="[[_hideChangeTotals]]">
<div class="total-stats">
- <span
- class="added"
- tabindex="0"
- aria-label$="Total [[_patchChange.inserted]] lines added"
- >
- +[[_patchChange.inserted]]
- </span>
- <span
- class="removed"
- tabindex="0"
- aria-label$="Total [[_patchChange.deleted]] lines removed"
- >
- -[[_patchChange.deleted]]
- </span>
+ <div>
+ <span
+ class="added"
+ tabindex="0"
+ aria-label$="Total [[_patchChange.inserted]] lines added"
+ >
+ +[[_patchChange.inserted]]
+ </span>
+ <span
+ class="removed"
+ tabindex="0"
+ aria-label$="Total [[_patchChange.deleted]] lines removed"
+ >
+ -[[_patchChange.deleted]]
+ </span>
+ </div>
</div>
<!-- endpoint: change-view-file-list-summary -->
<template is="dom-if" if="[[_showDynamicColumns]]">
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
index 932000c..d85ae4d 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.js
@@ -16,17 +16,19 @@
*/
import '../../../test/common-test-setup-karma.js';
+import {listenOnce} from '../../../test/test-utils.js';
import '../../diff/gr-comment-api/gr-comment-api.js';
import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
import './gr-file-list.js';
import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GrFileListConstants} from '../gr-file-list-constants.js';
+import {FilesExpandedState} from '../gr-file-list-constants.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {runA11yAudit} from '../../../test/a11y-test-utils.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api.js';
const commentApiMock = createCommentApiMockWithTemplateElement(
'gr-file-list-comment-api-mock', html`
@@ -113,7 +115,7 @@
element.numFilesShown = 200;
element.patchRange = {
basePatchNum: 'PARENT',
- patchNum: '2',
+ patchNum: 2,
};
saveStub = sinon.stub(element, '_saveReviewedState').callsFake(
() => Promise.resolve());
@@ -127,9 +129,9 @@
return _filesByPath;
}, {});
- flushAsynchronousOperations();
+ flush();
assert.equal(
- dom(element.root).querySelectorAll('.file-row').length,
+ element.root.querySelectorAll('.file-row').length,
element.numFilesShown);
const controlRow = element.shadowRoot
.querySelector('.controlRow');
@@ -140,7 +142,7 @@
'Show all 500 files');
MockInteractions.tap(element.$.showAllButton);
- flushAsynchronousOperations();
+ flush();
assert.equal(element.numFilesShown, 500);
assert.equal(element._shownFiles.length, 500);
@@ -154,9 +156,9 @@
_filesByPath['/file' + idx] = {lines_inserted: 9};
return _filesByPath;
}, {});
- flushAsynchronousOperations();
+ flush();
assert.equal(
- dom(element.root).querySelectorAll('.file-row').length, 10);
+ element.root.querySelectorAll('.file-row').length, 10);
assert.equal(renderedStub.callCount, 10);
});
@@ -311,7 +313,7 @@
for (const bytes in table) {
if (table.hasOwnProperty(bytes)) {
- assert.equal(element._formatBytes(bytes), table[bytes]);
+ assert.equal(element._formatBytes(Number(bytes)), table[bytes]);
}
}
});
@@ -351,56 +353,86 @@
});
test('comment filtering', () => {
- element.changeComments._comments = {
+ const comments = {
'/COMMIT_MSG': [
- {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
- {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
- {patch_set: 2, message: 'hello', updated: '2017-02-10 16:40:49'},
+ {
+ patch_set: 1,
+ message: 'Done',
+ updated: '2017-02-08 16:40:49',
+ id: '1',
+ },
+ {
+ patch_set: 1,
+ message: 'oh hay',
+ updated: '2017-02-09 16:40:49',
+ id: '2',
+ },
+ {
+ patch_set: 2,
+ message: 'hello',
+ updated: '2017-02-10 16:40:49',
+ id: '3',
+ },
],
'myfile.txt': [
- {patch_set: 1, message: 'good news!', updated: '2017-02-08 16:40:49'},
- {patch_set: 2, message: 'wat!?', updated: '2017-02-09 16:40:49'},
- {patch_set: 2, message: 'hi', updated: '2017-02-10 16:40:49'},
+ {
+ patch_set: 1,
+ message: 'good news!',
+ updated: '2017-02-08 16:40:49',
+ id: '4',
+ },
+ {
+ patch_set: 2,
+ message: 'wat!?',
+ updated: '2017-02-09 16:40:49',
+ id: '5',
+ },
+ {
+ patch_set: 2,
+ message: 'hi',
+ updated: '2017-02-10 16:40:49',
+ id: '6',
+ },
],
'unresolved.file': [
{
patch_set: 2,
message: 'wat!?',
updated: '2017-02-09 16:40:49',
- id: '1',
+ id: '7',
unresolved: true,
},
{
patch_set: 2,
message: 'hi',
updated: '2017-02-10 16:40:49',
- id: '2',
- in_reply_to: '1',
+ id: '8',
+ in_reply_to: '7',
unresolved: false,
},
{
patch_set: 2,
message: 'good news!',
updated: '2017-02-08 16:40:49',
- id: '3',
+ id: '9',
unresolved: true,
},
],
};
- element.changeComments._drafts = {
+ const drafts = {
'/COMMIT_MSG': [
{
patch_set: 1,
message: 'hi',
updated: '2017-02-15 16:40:49',
- id: '5',
+ id: '10',
unresolved: true,
},
{
patch_set: 1,
message: 'fyi',
updated: '2017-02-15 16:40:49',
- id: '6',
+ id: '11',
unresolved: false,
},
],
@@ -409,25 +441,26 @@
patch_set: 1,
message: 'hi',
updated: '2017-02-11 16:40:49',
- id: '4',
+ id: '12',
unresolved: false,
},
],
};
+ element.changeComments = new ChangeComments(comments, {}, drafts, 123);
const parentTo1 = {
basePatchNum: 'PARENT',
- patchNum: '1',
+ patchNum: 1,
};
const parentTo2 = {
basePatchNum: 'PARENT',
- patchNum: '2',
+ patchNum: 2,
};
const _1To2 = {
- basePatchNum: '1',
- patchNum: '2',
+ basePatchNum: 1,
+ patchNum: 2,
};
assert.equal(
@@ -567,10 +600,10 @@
'file_added_in_rev2.txt', 'comment'), '');
assert.equal(
element._computeCommentsString(element.changeComments, parentTo2,
- 'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+ 'unresolved.file', 'comment'), '2 comments (1 unresolved)');
assert.equal(
element._computeCommentsString(element.changeComments, _1To2,
- 'unresolved.file', 'comment'), '3 comments (1 unresolved)');
+ 'unresolved.file', 'comment'), '2 comments (1 unresolved)');
});
test('_reviewedTitle', () => {
@@ -591,7 +624,7 @@
element.changeNum = '42';
element.patchRange = {
basePatchNum: 'PARENT',
- patchNum: '2',
+ patchNum: 2,
};
element.change = {_number: 42};
element.$.fileCursor.setCursorAtIndex(0);
@@ -609,9 +642,9 @@
});
test('keyboard shortcuts', () => {
- flushAsynchronousOperations();
+ flush();
- const items = dom(element.root).querySelectorAll('.file-row');
+ const items = [...element.root.querySelectorAll('.file-row')];
element.$.fileCursor.stops = items;
element.$.fileCursor.setCursorAtIndex(0);
assert.equal(items.length, 3);
@@ -648,7 +681,7 @@
MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
assert(navStub.lastCall.calledWith(element.change,
- 'file_added_in_rev2.txt', '2'),
+ 'file_added_in_rev2.txt', 2),
'Should navigate to /c/42/2/file_added_in_rev2.txt');
MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
@@ -666,35 +699,35 @@
test('i key shows/hides selected inline diff', () => {
const paths = Object.keys(element._filesByPath);
sinon.stub(element, '_expandedFilesChanged');
- flushAsynchronousOperations();
- const files = dom(element.root).querySelectorAll('.file-row');
+ flush();
+ const files = [...element.root.querySelectorAll('.file-row')];
element.$.fileCursor.stops = files;
element.$.fileCursor.setCursorAtIndex(0);
assert.equal(element.diffs.length, 0);
assert.equal(element._expandedFiles.length, 0);
MockInteractions.keyUpOn(element, 73, null, 'i');
- flushAsynchronousOperations();
+ flush();
assert.equal(element.diffs.length, 1);
assert.equal(element.diffs[0].path, paths[0]);
assert.equal(element._expandedFiles.length, 1);
assert.equal(element._expandedFiles[0].path, paths[0]);
MockInteractions.keyUpOn(element, 73, null, 'i');
- flushAsynchronousOperations();
+ flush();
assert.equal(element.diffs.length, 0);
assert.equal(element._expandedFiles.length, 0);
element.$.fileCursor.setCursorAtIndex(1);
MockInteractions.keyUpOn(element, 73, null, 'i');
- flushAsynchronousOperations();
+ flush();
assert.equal(element.diffs.length, 1);
assert.equal(element.diffs[0].path, paths[1]);
assert.equal(element._expandedFiles.length, 1);
assert.equal(element._expandedFiles[0].path, paths[1]);
MockInteractions.keyUpOn(element, 73, 'shift', 'i');
- flushAsynchronousOperations();
+ flush();
assert.equal(element.diffs.length, paths.length);
assert.equal(element._expandedFiles.length, paths.length);
for (const index in element.diffs) {
@@ -706,7 +739,7 @@
}
MockInteractions.keyUpOn(element, 73, 'shift', 'i');
- flushAsynchronousOperations();
+ flush();
assert.equal(element.diffs.length, 0);
assert.equal(element._expandedFiles.length, 0);
});
@@ -714,14 +747,14 @@
test('r key toggles reviewed flag', () => {
const reducer = (accum, file) => (file.isReviewed ? ++accum : accum);
const getNumReviewed = () => element._files.reduce(reducer, 0);
- flushAsynchronousOperations();
+ flush();
// Default state should be unreviewed.
assert.equal(getNumReviewed(), 0);
// Press the review key to toggle it (set the flag).
MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
- flushAsynchronousOperations();
+ flush();
assert.equal(getNumReviewed(), 1);
// Press the review key to toggle it (clear the flag).
@@ -824,15 +857,15 @@
element.changeNum = '42';
element.patchRange = {
basePatchNum: 'PARENT',
- patchNum: '2',
+ patchNum: 2,
};
element.$.fileCursor.setCursorAtIndex(0);
const reviewSpy = sinon.spy(element, '_reviewFile');
const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
- flushAsynchronousOperations();
+ flush();
const fileRows =
- dom(element.root).querySelectorAll('.row:not(.header-row)');
+ element.root.querySelectorAll('.row:not(.header-row)');
const checkSelector = 'span.reviewedSwitch[role="switch"]';
const commitMsg = fileRows[0].querySelector(checkSelector);
const fileAdded = fileRows[1].querySelector(checkSelector);
@@ -879,7 +912,7 @@
element.changeNum = '42';
element.patchRange = {
basePatchNum: 'PARENT',
- patchNum: '2',
+ patchNum: 2,
};
const clickSpy = sinon.spy(element, '_handleFileListClick');
@@ -898,7 +931,7 @@
// Click inside the diff. This should result in no additional calls to
// _toggleFileExpanded or _reviewFile.
- dom(element.root).querySelector('gr-diff-host')
+ element.root.querySelector('gr-diff-host')
.click();
assert.isTrue(clickSpy.calledTwice);
assert.isTrue(toggleExpandSpy.calledOnce);
@@ -914,10 +947,10 @@
element.changeNum = '42';
element.patchRange = {
basePatchNum: 'PARENT',
- patchNum: '2',
+ patchNum: 2,
};
element.editMode = true;
- flushAsynchronousOperations();
+ flush();
const clickSpy = sinon.spy(element, '_handleFileListClick');
const toggleExpandSpy = sinon.spy(element, '_toggleFileExpanded');
@@ -935,13 +968,13 @@
element.changeNum = '42';
element.patchRange = {
basePatchNum: 'PARENT',
- patchNum: '2',
+ patchNum: 2,
};
element.$.fileCursor.setCursorAtIndex(0);
sinon.stub(element, '_expandedFilesChanged');
- flushAsynchronousOperations();
+ flush();
const fileRows =
- dom(element.root).querySelectorAll('.row:not(.header-row)');
+ element.root.querySelectorAll('.row:not(.header-row)');
// Because the label surrounds the input, the tap event is triggered
// there first.
const showHideCheck = fileRows[0].querySelector(
@@ -962,18 +995,18 @@
element.changeNum = '42';
element.patchRange = {
basePatchNum: 'PARENT',
- patchNum: '2',
+ patchNum: 2,
};
sinon.spy(element, '_updateDiffPreferences');
element.$.fileCursor.setCursorAtIndex(0);
- flushAsynchronousOperations();
+ flush();
// Tap on a file to generate the diff.
const row = dom(element.root)
.querySelectorAll('.row:not(.header-row) span.show-hide')[0];
MockInteractions.tap(row);
- flushAsynchronousOperations();
+ flush();
const diffDisplay = element.diffs[0];
element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
element.set('diffViewMode', 'UNIFIED_DIFF');
@@ -996,10 +1029,10 @@
element.changeNum = '42';
element.patchRange = {
basePatchNum: 'PARENT',
- patchNum: '2',
+ patchNum: 2,
};
sinon.stub(element, '_expandedFilesChanged');
- flushAsynchronousOperations();
+ flush();
const commitMsgFile = dom(element.root)
.querySelectorAll('.row:not(.header-row) a.pathLink')[0];
@@ -1008,7 +1041,7 @@
const togglePathSpy = sinon.spy(element, '_toggleFileExpanded');
MockInteractions.tap(commitMsgFile);
- flushAsynchronousOperations();
+ flush();
assert(togglePathSpy.notCalled, 'file is opened as diff view');
assert.isNotOk(element.shadowRoot
.querySelector('.expanded'));
@@ -1027,7 +1060,7 @@
.querySelector('iron-icon').icon, 'gr-icons:expand-more');
assert.equal(element._expandedFiles.length, 0);
element._toggleFileExpanded({path});
- flushAsynchronousOperations();
+ flush();
assert.equal(collapseStub.lastCall.args[0].length, 0);
assert.equal(element.shadowRoot
.querySelector('iron-icon').icon, 'gr-icons:expand-less');
@@ -1035,7 +1068,7 @@
assert.equal(renderSpy.callCount, 1);
assert.isTrue(element._expandedFiles.some(f => f.path === path));
element._toggleFileExpanded({path});
- flushAsynchronousOperations();
+ flush();
assert.equal(element.shadowRoot
.querySelector('iron-icon').icon, 'gr-icons:expand-more');
@@ -1054,13 +1087,13 @@
const path = 'path/to/my/file.txt';
element._filesByPath = {[path]: {}};
element.expandAllDiffs();
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element._showInlineDiffs);
assert.isTrue(reInitStub.calledOnce);
assert.equal(collapseStub.lastCall.args[0].length, 0);
element.collapseAllDiffs();
- flushAsynchronousOperations();
+ flush();
assert.equal(element._expandedFiles.length, 0);
assert.isFalse(element._showInlineDiffs);
assert.isTrue(cursorUpdateStub.calledOnce);
@@ -1105,25 +1138,25 @@
'foo.bar': {},
'baz.bar': {},
};
- flushAsynchronousOperations();
+ flush();
assert.equal(element.filesExpanded,
- GrFileListConstants.FilesExpandedState.NONE);
+ FilesExpandedState.NONE);
element.push('_expandedFiles', {path: 'baz.bar'});
- flushAsynchronousOperations();
+ flush();
assert.equal(element.filesExpanded,
- GrFileListConstants.FilesExpandedState.SOME);
+ FilesExpandedState.SOME);
element.push('_expandedFiles', {path: 'foo.bar'});
- flushAsynchronousOperations();
+ flush();
assert.equal(element.filesExpanded,
- GrFileListConstants.FilesExpandedState.ALL);
+ FilesExpandedState.ALL);
element.collapseAllDiffs();
- flushAsynchronousOperations();
+ flush();
assert.equal(element.filesExpanded,
- GrFileListConstants.FilesExpandedState.NONE);
+ FilesExpandedState.NONE);
element.expandAllDiffs();
- flushAsynchronousOperations();
+ flush();
assert.equal(element.filesExpanded,
- GrFileListConstants.FilesExpandedState.ALL);
+ FilesExpandedState.ALL);
});
test('_renderInOrder', done => {
@@ -1327,15 +1360,23 @@
suite('size bars', () => {
test('_computeSizeBarLayout', () => {
- assert.isUndefined(element._computeSizeBarLayout(null));
- assert.isUndefined(element._computeSizeBarLayout({}));
- assert.deepEqual(element._computeSizeBarLayout({base: []}), {
+ const defaultSizeBarLayout = {
maxInserted: 0,
maxDeleted: 0,
maxAdditionWidth: 0,
maxDeletionWidth: 0,
deletionOffset: 0,
- });
+ };
+
+ assert.deepEqual(
+ element._computeSizeBarLayout(null),
+ defaultSizeBarLayout);
+ assert.deepEqual(
+ element._computeSizeBarLayout({}),
+ defaultSizeBarLayout);
+ assert.deepEqual(
+ element._computeSizeBarLayout({base: []}),
+ defaultSizeBarLayout);
const files = [
{__path: '/COMMIT_MSG', lines_inserted: 10000},
@@ -1482,7 +1523,7 @@
},
];
- const setupDiff = function(diff) {
+ async function setupDiff(diff) {
diff.comments = {
left: diff.path === '/COMMIT_MSG' ? commitMsgComments : [],
right: [],
@@ -1511,21 +1552,29 @@
ignore_whitespace: 'IGNORE_NONE',
};
diff.diff = getMockDiffResponse();
- diff.$.diff.flushDebouncer('renderDiffTable');
- };
+ commentApiWrapper.loadComments().then(() => {
+ sinon.stub(element.changeComments, 'getCommentsBySideForPath')
+ .withArgs('/COMMIT_MSG', {
+ basePatchNum: 'PARENT',
+ patchNum: 2,
+ })
+ .returns(diff.comments);
+ });
+ await listenOnce(diff, 'render');
+ }
- const renderAndGetNewDiffs = function(index) {
+ async function renderAndGetNewDiffs(index) {
const diffs =
- dom(element.root).querySelectorAll('gr-diff-host');
+ element.root.querySelectorAll('gr-diff-host');
for (let i = index; i < diffs.length; i++) {
- setupDiff(diffs[i]);
+ await setupDiff(diffs[i]);
}
element._updateDiffCursor();
element.$.diffCursor.handleDiffUpdate();
return diffs;
- };
+ }
setup(done => {
stub('gr-rest-api-interface', {
@@ -1549,6 +1598,7 @@
element = commentApiWrapper.$.fileList;
loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
element.diffPrefs = {};
+ element.change = {_number: 42, project: 'testRepo'};
sinon.stub(element, '_reviewFile');
// Stub methods on the changeComments object after changeComments has
@@ -1582,16 +1632,16 @@
element.changeNum = '42';
element.patchRange = {
basePatchNum: 'PARENT',
- patchNum: '2',
+ patchNum: 2,
};
sinon.stub(window, 'fetch').callsFake(() => Promise.resolve());
- flushAsynchronousOperations();
+ flush();
});
- test('cursor with individually opened files', () => {
+ test('cursor with individually opened files', async () => {
MockInteractions.keyUpOn(element, 73, null, 'i');
- flushAsynchronousOperations();
- let diffs = renderAndGetNewDiffs(0);
+ flush();
+ let diffs = await renderAndGetNewDiffs(0);
const diffStops = diffs[0].getCursorStops();
// 1 diff should be rendered.
@@ -1603,22 +1653,22 @@
// Tapping content on a line selects the line number.
MockInteractions.tap(dom(
diffStops[10]).querySelectorAll('.contentText')[0]);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(diffStops[10].classList.contains('target-row'));
// Keyboard shortcuts are still moving the file cursor, not the diff
// cursor.
MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(diffStops[10].classList.contains('target-row'));
assert.isFalse(diffStops[11].classList.contains('target-row'));
// The file cursor is now at 1.
assert.equal(element.$.fileCursor.index, 1);
MockInteractions.keyUpOn(element, 73, null, 'i');
- flushAsynchronousOperations();
+ flush();
- diffs = renderAndGetNewDiffs(1);
+ diffs = await renderAndGetNewDiffs(1);
// Two diffs should be rendered.
assert.equal(diffs.length, 2);
const diffStopsFirst = diffs[0].getCursorStops();
@@ -1629,11 +1679,11 @@
assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
});
- test('cursor with toggle all files', () => {
+ test('cursor with toggle all files', async () => {
MockInteractions.keyUpOn(element, 73, 'shift', 'i');
- flushAsynchronousOperations();
+ flush();
- const diffs = renderAndGetNewDiffs(0);
+ const diffs = await renderAndGetNewDiffs(0);
const diffStops = diffs[0].getCursorStops();
// 1 diff should be rendered.
@@ -1645,13 +1695,13 @@
// Tapping content on a line selects the line number.
MockInteractions.tap(dom(
diffStops[10]).querySelectorAll('.contentText')[0]);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(diffStops[10].classList.contains('target-row'));
// Keyboard shortcuts are still moving the file cursor, not the diff
// cursor.
MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
- flushAsynchronousOperations();
+ flush();
assert.isFalse(diffStops[10].classList.contains('target-row'));
assert.isTrue(diffStops[11].classList.contains('target-row'));
@@ -1673,12 +1723,12 @@
nextChunkStub = sinon.stub(element.$.diffCursor,
'moveToNextChunk');
fileRows =
- dom(element.root).querySelectorAll('.row:not(.header-row)');
+ element.root.querySelectorAll('.row:not(.header-row)');
});
test('n key with some files expanded and no shift key', () => {
MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
- flushAsynchronousOperations();
+ flush();
// Handle N key should return before calling diff cursor functions.
MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
@@ -1692,7 +1742,7 @@
test('n key with some files expanded and shift key', () => {
MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
- flushAsynchronousOperations();
+ flush();
assert.equal(nextChunkStub.callCount, 0);
MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
@@ -1706,7 +1756,7 @@
test('n key without all files expanded and shift key', () => {
MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
- flushAsynchronousOperations();
+ flush();
MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
assert.isTrue(nKeySpy.called);
@@ -1719,7 +1769,7 @@
test('n key without all files expanded and no shift key', () => {
MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
- flushAsynchronousOperations();
+ flush();
MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
assert.isTrue(nKeySpy.called);
@@ -1740,7 +1790,7 @@
assert.isFalse(navStub.called);
element.set('_filesByPath', _filesByPath);
- flushAsynchronousOperations();
+ flush();
// Navigates when a file is selected.
element._openSelectedFile();
assert.isTrue(navStub.called);
@@ -1777,7 +1827,7 @@
assert.isTrue(saveReviewStub.calledOnce);
element.editMode = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
assert.isTrue(saveReviewStub.calledOnce);
@@ -1799,7 +1849,7 @@
.querySelector('gr-edit-file-controls'));
element.editMode = true;
- flushAsynchronousOperations();
+ flush();
// Commit message should not have edit controls.
const editControls =
@@ -1810,11 +1860,11 @@
assert.isTrue(editControls[0].classList.contains('invisible'));
});
- test('reloadCommentsForThreadWithRootId', () => {
+ test('reloadCommentsForThreadWithRootId', async () => {
// Expand the commit message diff
MockInteractions.keyUpOn(element, 73, 'shift', 'i');
- const diffs = renderAndGetNewDiffs(0);
- flushAsynchronousOperations();
+ const diffs = await renderAndGetNewDiffs(0);
+ flush();
// Two comment threads should be generated by renderAndGetNewDiffs
const threadEls = diffs[0].getThreadEls();
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
deleted file mode 100644
index c42c734..0000000
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
+++ /dev/null
@@ -1,112 +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-input/iron-input.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-included-in-dialog_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrIncludedInDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-included-in-dialog'; }
- /**
- * Fired when the user presses the close button.
- *
- * @event close
- */
-
- static get properties() {
- return {
- /** @type {?} */
- changeNum: {
- type: Object,
- observer: '_resetData',
- },
- /** @type {?} */
- _includedIn: Object,
- _loaded: {
- type: Boolean,
- value: false,
- },
- _filterText: {
- type: String,
- value: '',
- },
- };
- }
-
- loadData() {
- if (!this.changeNum) { return; }
- this._filterText = '';
- return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(
- configs => {
- if (!configs) { return; }
- this._includedIn = configs;
- this._loaded = true;
- });
- }
-
- _resetData() {
- this._includedIn = null;
- this._loaded = false;
- }
-
- _computeGroups(includedIn, filterText) {
- if (!includedIn || filterText === undefined) {
- return [];
- }
-
- const filter = item => !filterText.length ||
- item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
-
- const groups = [
- {title: 'Branches', items: includedIn.branches.filter(filter)},
- {title: 'Tags', items: includedIn.tags.filter(filter)},
- ];
- if (includedIn.external) {
- for (const externalKey of Object.keys(includedIn.external)) {
- groups.push({
- title: externalKey,
- items: includedIn.external[externalKey].filter(filter),
- });
- }
- }
- return groups.filter(g => g.items.length);
- }
-
- _handleCloseTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('close', {
- composed: true, bubbles: false,
- }));
- }
-
- _computeLoadingClass(loaded) {
- return loaded ? 'loading loaded' : 'loading';
- }
-}
-
-customElements.define(GrIncludedInDialog.is, GrIncludedInDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
new file mode 100644
index 0000000..1957f5c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -0,0 +1,129 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-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-included-in-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {IncludedInInfo, NumericChangeId} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export interface GrIncludedInDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+interface DisplayGroup {
+ title: string;
+ items: string[];
+}
+
+@customElement('gr-included-in-dialog')
+export class GrIncludedInDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the user presses the close button.
+ *
+ * @event close
+ */
+
+ @property({type: Object, observer: '_resetData'})
+ changeNum?: NumericChangeId;
+
+ @property({type: Object})
+ _includedIn?: IncludedInInfo;
+
+ @property({type: Boolean})
+ _loaded = false;
+
+ @property({type: String})
+ _filterText = '';
+
+ loadData() {
+ if (!this.changeNum) {
+ return Promise.reject(new Error('missing required property changeNum'));
+ }
+ this._filterText = '';
+ return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(configs => {
+ if (!configs) {
+ return;
+ }
+ this._includedIn = configs;
+ this._loaded = true;
+ });
+ }
+
+ _resetData() {
+ this._includedIn = undefined;
+ this._loaded = false;
+ }
+
+ _computeGroups(includedIn: IncludedInInfo | undefined, filterText: string) {
+ if (!includedIn || filterText === undefined) {
+ return [];
+ }
+
+ const filter = (item: string) =>
+ !filterText.length ||
+ item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+
+ const groups: DisplayGroup[] = [
+ {title: 'Branches', items: includedIn.branches.filter(filter)},
+ {title: 'Tags', items: includedIn.tags.filter(filter)},
+ ];
+ if (includedIn.external) {
+ for (const externalKey of Object.keys(includedIn.external)) {
+ groups.push({
+ title: externalKey,
+ items: includedIn.external[externalKey].filter(filter),
+ });
+ }
+ }
+ return groups.filter(g => g.items.length);
+ }
+
+ _handleCloseTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('close', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _computeLoadingClass(loaded: boolean) {
+ return loaded ? 'loading loaded' : 'loading';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-included-in-dialog': GrIncludedInDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
deleted file mode 100644
index fe88e4e..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
+++ /dev/null
@@ -1,194 +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-selector/iron-selector.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../../styles/gr-voting-styles.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-label-score-row_html.js';
-
-/** @extends PolymerElement */
-class GrLabelScoreRow extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-label-score-row'; }
- /**
- * Fired when any label is changed.
- *
- * @event labels-changed
- */
-
- static get properties() {
- return {
- /**
- * @type {{ name: string }}
- */
- label: Object,
- labels: Object,
- name: {
- type: String,
- reflectToAttribute: true,
- },
- permittedLabels: Object,
- labelValues: Object,
- _selectedValueText: {
- type: String,
- value: 'No value selected',
- },
- _items: {
- type: Array,
- computed: '_computePermittedLabelValues(permittedLabels, label.name)',
- },
- };
- }
-
- get selectedItem() {
- if (!this._ironSelector) { return undefined; }
- return this._ironSelector.selectedItem;
- }
-
- get selectedValue() {
- if (!this._ironSelector) { return undefined; }
- return this._ironSelector.selected;
- }
-
- setSelectedValue(value) {
- // The selector may not be present if it’s not at the latest patch set.
- if (!this._ironSelector) { return; }
- this._ironSelector.select(value);
- }
-
- get _ironSelector() {
- return this.$ && this.$.labelSelector;
- }
-
- _computeBlankItems(permittedLabels, label, side) {
- if (!permittedLabels || !permittedLabels[label] ||
- !permittedLabels[label].length || !this.labelValues ||
- !Object.keys(this.labelValues).length) {
- return [];
- }
- const startPosition = this.labelValues[parseInt(
- permittedLabels[label][0], 10)];
- if (side === 'start') {
- return new Array(startPosition);
- }
- const endPosition = this.labelValues[parseInt(
- permittedLabels[label][permittedLabels[label].length - 1], 10)];
- return new Array(Object.keys(this.labelValues).length - endPosition - 1);
- }
-
- _getLabelValue(labels, permittedLabels, label) {
- if (label.value) {
- return label.value;
- } else if (labels[label.name].hasOwnProperty('default_value') &&
- permittedLabels.hasOwnProperty(label.name)) {
- // default_value is an int, convert it to string label, e.g. "+1".
- return permittedLabels[label.name].find(
- value => parseInt(value, 10) === labels[label.name].default_value);
- }
- }
-
- /**
- * Maps the label value to exactly one of: min, max, positive, negative,
- * neutral. Used for the 'vote' attribute, because we don't want to
- * interfere with <iron-selector> using the 'class' attribute for setting
- * 'iron-selected'.
- */
- _computeVoteAttribute(value, index, totalItems) {
- if (value < 0 && index === 0) {
- return 'min';
- } else if (value < 0) {
- return 'negative';
- } else if (value > 0 && index === totalItems - 1) {
- return 'max';
- } else if (value > 0) {
- return 'positive';
- } else {
- return 'neutral';
- }
- }
-
- _computeLabelValue(labels, permittedLabels, label) {
- if ([labels, permittedLabels, label].includes(undefined)) {
- return null;
- }
- if (!labels[label.name]) { return null; }
- const labelValue = this._getLabelValue(labels, permittedLabels, label);
- const len = permittedLabels[label.name] != null ?
- permittedLabels[label.name].length : 0;
- for (let i = 0; i < len; i++) {
- const val = permittedLabels[label.name][i];
- if (val === labelValue) {
- return val;
- }
- }
- return null;
- }
-
- _setSelectedValueText(e) {
- // Needed because when the selected item changes, it first changes to
- // nothing and then to the new item.
- if (!e.target.selectedItem) { return; }
- for (const item of this.$.labelSelector.items) {
- if (e.target.selectedItem === item) {
- item.setAttribute('aria-checked', 'true');
- } else {
- item.removeAttribute('aria-checked');
- }
- }
- this._selectedValueText = e.target.selectedItem.getAttribute('title');
- // Needed to update the style of the selected button.
- this.updateStyles();
- const name = e.target.selectedItem.dataset.name;
- const value = e.target.selectedItem.dataset.value;
- this.dispatchEvent(new CustomEvent(
- 'labels-changed',
- {detail: {name, value}, bubbles: true, composed: true}));
- }
-
- _computeAnyPermittedLabelValues(permittedLabels, label) {
- return permittedLabels && permittedLabels.hasOwnProperty(label) &&
- permittedLabels[label].length;
- }
-
- _computeHiddenClass(permittedLabels, label) {
- return !this._computeAnyPermittedLabelValues(permittedLabels, label) ?
- 'hidden' : '';
- }
-
- _computePermittedLabelValues(permittedLabels, label) {
- // Polymer 2: check for undefined
- if ([permittedLabels, label].includes(undefined)) {
- return undefined;
- }
-
- return permittedLabels[label];
- }
-
- _computeLabelValueTitle(labels, label, value) {
- return labels[label] &&
- labels[label].values &&
- labels[label].values[value];
- }
-}
-
-customElements.define(GrLabelScoreRow.is, GrLabelScoreRow);
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
new file mode 100644
index 0000000..60a6058
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -0,0 +1,289 @@
+/**
+ * @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-selector/iron-selector';
+import '../../shared/gr-button/gr-button';
+import '../../../styles/gr-voting-styles';
+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-label-score-row_html';
+import {customElement, property} from '@polymer/decorators';
+import {IronSelectorElement} from '@polymer/iron-selector/iron-selector';
+import {
+ LabelNameToValueMap,
+ LabelNameToInfoMap,
+ QuickLabelInfo,
+ DetailedLabelInfo,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+export interface Label {
+ name: string;
+ value: string | null;
+}
+
+// TODO(TS): add description to explain what this is after moving
+// gr-label-scores to ts
+export interface LabelValuesMap {
+ [key: number]: number;
+}
+
+export interface GrLabelScoreRow {
+ $: {
+ labelSelector: IronSelectorElement;
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-label-score-row': GrLabelScoreRow;
+ }
+}
+
+@customElement('gr-label-score-row')
+export class GrLabelScoreRow extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when any label is changed.
+ *
+ * @event labels-changed
+ */
+
+ @property({type: Object})
+ label: Label | undefined | null;
+
+ @property({type: Object})
+ labels?: LabelNameToInfoMap;
+
+ @property({type: String, reflectToAttribute: true})
+ name?: string;
+
+ @property({type: Object})
+ permittedLabels: LabelNameToValueMap | undefined | null;
+
+ @property({type: Object})
+ labelValues?: LabelValuesMap;
+
+ @property({type: String})
+ _selectedValueText = 'No value selected';
+
+ @property({
+ computed: '_computePermittedLabelValues(permittedLabels, label.name)',
+ type: Array,
+ })
+ _items!: string[];
+
+ get selectedItem() {
+ if (!this._ironSelector) {
+ return undefined;
+ }
+ return this._ironSelector.selectedItem;
+ }
+
+ get selectedValue() {
+ if (!this._ironSelector) {
+ return undefined;
+ }
+ return this._ironSelector.selected;
+ }
+
+ setSelectedValue(value: string) {
+ // The selector may not be present if it’s not at the latest patch set.
+ if (!this._ironSelector) {
+ return;
+ }
+ this._ironSelector.select(value);
+ }
+
+ get _ironSelector() {
+ return this.$ && this.$.labelSelector;
+ }
+
+ _computeBlankItems(
+ permittedLabels: LabelNameToValueMap,
+ label: string,
+ side: string
+ ) {
+ if (
+ !permittedLabels ||
+ !permittedLabels[label] ||
+ !permittedLabels[label].length ||
+ !this.labelValues ||
+ !Object.keys(this.labelValues).length
+ ) {
+ return [];
+ }
+ const startPosition = this.labelValues[Number(permittedLabels[label][0])];
+ if (side === 'start') {
+ return new Array(startPosition);
+ }
+ const endPosition = this.labelValues[
+ Number(permittedLabels[label][permittedLabels[label].length - 1])
+ ];
+ return new Array(Object.keys(this.labelValues).length - endPosition - 1);
+ }
+
+ _getLabelValue(
+ labels: LabelNameToInfoMap,
+ permittedLabels: LabelNameToValueMap,
+ label: Label
+ ) {
+ if (label.value) {
+ return label.value;
+ } else if (
+ hasOwnProperty(labels[label.name], 'default_value') &&
+ hasOwnProperty(permittedLabels, label.name)
+ ) {
+ // default_value is an int, convert it to string label, e.g. "+1".
+ return permittedLabels[label.name].find(
+ value =>
+ Number(value) === (labels[label.name] as QuickLabelInfo).default_value
+ );
+ }
+ return;
+ }
+
+ /**
+ * Maps the label value to exactly one of: min, max, positive, negative,
+ * neutral. Used for the 'vote' attribute, because we don't want to
+ * interfere with <iron-selector> using the 'class' attribute for setting
+ * 'iron-selected'.
+ */
+ _computeVoteAttribute(value: number, index: number, totalItems: number) {
+ if (value < 0 && index === 0) {
+ return 'min';
+ } else if (value < 0) {
+ return 'negative';
+ } else if (value > 0 && index === totalItems - 1) {
+ return 'max';
+ } else if (value > 0) {
+ return 'positive';
+ } else {
+ return 'neutral';
+ }
+ }
+
+ _computeLabelValue(
+ labels?: LabelNameToInfoMap,
+ permittedLabels?: LabelNameToValueMap,
+ label?: Label
+ ) {
+ // Polymer 2+ undefined check
+ if (
+ labels === undefined ||
+ permittedLabels === undefined ||
+ label === undefined
+ ) {
+ return null;
+ }
+
+ if (!labels[label.name]) {
+ return null;
+ }
+ const labelValue = this._getLabelValue(labels, permittedLabels, label);
+ const len = permittedLabels[label.name]
+ ? permittedLabels[label.name].length
+ : 0;
+ for (let i = 0; i < len; i++) {
+ const val = permittedLabels[label.name][i];
+ if (val === labelValue) {
+ return val;
+ }
+ }
+ return null;
+ }
+
+ _setSelectedValueText(e: Event) {
+ // Needed because when the selected item changes, it first changes to
+ // nothing and then to the new item.
+ const selectedItem = (e.target as IronSelectorElement)
+ .selectedItem as HTMLElement;
+ if (!selectedItem) {
+ return;
+ }
+ if (!this.$.labelSelector.items) {
+ return;
+ }
+ for (const item of this.$.labelSelector.items) {
+ if (selectedItem === item) {
+ item.setAttribute('aria-checked', 'true');
+ } else {
+ item.removeAttribute('aria-checked');
+ }
+ }
+ this._selectedValueText = selectedItem.getAttribute('title') || '';
+ // Needed to update the style of the selected button.
+ this.updateStyles();
+ const name = selectedItem.dataset['name'];
+ const value = selectedItem.dataset['value'];
+ this.dispatchEvent(
+ new CustomEvent('labels-changed', {
+ detail: {name, value},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ _computeAnyPermittedLabelValues(
+ permittedLabels: LabelNameToValueMap,
+ labelName: string
+ ) {
+ return (
+ permittedLabels &&
+ hasOwnProperty(permittedLabels, labelName) &&
+ permittedLabels[labelName].length
+ );
+ }
+
+ _computeHiddenClass(permittedLabels: LabelNameToValueMap, labelName: string) {
+ return !this._computeAnyPermittedLabelValues(permittedLabels, labelName)
+ ? 'hidden'
+ : '';
+ }
+
+ _computePermittedLabelValues(
+ permittedLabels?: LabelNameToValueMap,
+ labelName?: string
+ ) {
+ // Polymer 2: check for undefined
+ if (permittedLabels === undefined || labelName === undefined) {
+ return [];
+ }
+
+ return permittedLabels[labelName] || [];
+ }
+
+ _computeLabelValueTitle(
+ labels: LabelNameToInfoMap,
+ label: string,
+ value: string
+ ) {
+ // TODO(TS): maybe add a type guard for DetailedLabelInfo and QuickLabelInfo
+ return (
+ labels[label] &&
+ (labels[label] as DetailedLabelInfo).values &&
+ (labels[label] as DetailedLabelInfo).values![value]
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
index 0739f73..3c9b7c7 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-label-score-row.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-label-score-row');
@@ -105,7 +104,7 @@
MockInteractions.tap(element.shadowRoot
.querySelector(
'gr-button[data-value="-1"]'));
- flushAsynchronousOperations();
+ flush();
assert.strictEqual(element.selectedValue, '-1');
assert.strictEqual(element.selectedItem
.textContent.trim(), '-1');
@@ -235,7 +234,7 @@
};
const selector = element.$.labelSelector;
element.set('label', {name: 'Verified', value: ' 0'});
- flushAsynchronousOperations();
+ flush();
assert.strictEqual(selector.selected, ' 0');
assert.strictEqual(
element.$.selectedValueLabel.textContent.trim(), 'No score');
@@ -250,17 +249,17 @@
'+1',
],
};
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.$.labelSelector);
assert.isFalse(element.$.labelSelector.hidden);
element.permittedLabels = {};
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.$.labelSelector);
assert.isTrue(element.$.labelSelector.hidden);
element.permittedLabels = {Verified: []};
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.$.labelSelector);
assert.isTrue(element.$.labelSelector.hidden);
});
@@ -283,7 +282,7 @@
assert.strictEqual(element.$.labelSelector
.items.length, 2);
assert.strictEqual(
- dom(element.root).querySelectorAll('.placeholder').length,
+ element.root.querySelectorAll('.placeholder').length,
3);
element.permittedLabels = {
@@ -303,7 +302,7 @@
assert.strictEqual(element.$.labelSelector
.items.length, 5);
assert.strictEqual(
- dom(element.root).querySelectorAll('.placeholder').length,
+ element.root.querySelectorAll('.placeholder').length,
0);
done();
});
@@ -334,7 +333,7 @@
name: 'Verified',
value: null,
};
- flushAsynchronousOperations();
+ flush();
assert.strictEqual(element.selectedValue, '-1');
checkAriaCheckedValid();
});
@@ -363,7 +362,7 @@
name: 'Code-Review',
value: null,
};
- flushAsynchronousOperations();
+ flush();
assert.isNull(element.selectedValue);
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
deleted file mode 100644
index aa01b01..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
+++ /dev/null
@@ -1,154 +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 '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-label-score-row/gr-label-score-row.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-label-scores_html.js';
-
-/** @extends PolymerElement */
-class GrLabelScores extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-label-scores'; }
-
- static get properties() {
- return {
- _labels: {
- type: Array,
- computed: '_computeLabels(change.labels.*, account)',
- },
- permittedLabels: {
- type: Object,
- observer: '_computeColumns',
- },
- /** @type {?} */
- change: Object,
- /** @type {?} */
- account: Object,
-
- _labelValues: Object,
- };
- }
-
- getLabelValues() {
- const labels = {};
- for (const label in this.permittedLabels) {
- if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
-
- const selectorEl = this.shadowRoot
- .querySelector(`gr-label-score-row[name="${label}"]`);
- if (!selectorEl) { continue; }
-
- // The user may have not voted on this label.
- if (!selectorEl.selectedItem) { continue; }
-
- const selectedVal = parseInt(selectorEl.selectedValue, 10);
-
- // Only send the selection if the user changed it.
- let prevVal = this._getVoteForAccount(this.change.labels, label,
- this.account);
- if (prevVal !== null) {
- prevVal = parseInt(prevVal, 10);
- }
- if (selectedVal !== prevVal) {
- labels[label] = selectedVal;
- }
- }
- return labels;
- }
-
- _getStringLabelValue(labels, labelName, numberValue) {
- for (const k in labels[labelName].values) {
- if (parseInt(k, 10) === numberValue) {
- return k;
- }
- }
- return numberValue;
- }
-
- _getVoteForAccount(labels, labelName, account) {
- const votes = labels[labelName];
- if (votes.all && votes.all.length > 0) {
- for (let i = 0; i < votes.all.length; i++) {
- if (votes.all[i]._account_id == account._account_id) {
- return this._getStringLabelValue(
- labels, labelName, votes.all[i].value);
- }
- }
- }
- return null;
- }
-
- _computeLabels(labelRecord, account) {
- // Polymer 2: check for undefined
- if ([labelRecord, account].includes(undefined)) {
- return undefined;
- }
-
- const labelsObj = labelRecord.base;
- if (!labelsObj) { return []; }
- return Object.keys(labelsObj).sort()
- .map(key => {
- return {
- name: key,
- value: this._getVoteForAccount(labelsObj, key, this.account),
- };
- });
- }
-
- _computeColumns(permittedLabels) {
- const labels = Object.keys(permittedLabels);
- const values = {};
- for (const label of labels) {
- for (const value of permittedLabels[label]) {
- values[parseInt(value, 10)] = true;
- }
- }
-
- const orderedValues = Object.keys(values).sort((a, b) => a - b);
-
- for (let i = 0; i < orderedValues.length; i++) {
- values[orderedValues[i]] = i;
- }
- this._labelValues = values;
- }
-
- _changeIsMerged(changeStatus) {
- return changeStatus === 'MERGED';
- }
-
- /**
- * @param {string|undefined} label
- * @param {Object|undefined} permittedLabels
- * @return {string}
- */
- _computeLabelAccessClass(label, permittedLabels) {
- if (label == null || permittedLabels == null) {
- return '';
- }
-
- return permittedLabels.hasOwnProperty(label) &&
- permittedLabels[label].length ? 'access' : 'no-access';
- }
-}
-
-customElements.define(GrLabelScores.is, GrLabelScores);
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
new file mode 100644
index 0000000..d528192
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.ts
@@ -0,0 +1,230 @@
+/**
+ * @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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-label-score-row/gr-label-score-row';
+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-label-scores_html';
+import {customElement, property} from '@polymer/decorators';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {
+ LabelNameToValueMap,
+ ChangeInfo,
+ AccountInfo,
+ DetailedLabelInfo,
+ LabelNameToInfoMap,
+ LabelNameToValuesMap,
+} from '../../../types/common';
+import {
+ GrLabelScoreRow,
+ LabelValuesMap,
+} from '../gr-label-score-row/gr-label-score-row';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+
+type Labels = {[label: string]: number};
+@customElement('gr-label-scores')
+export class GrLabelScores extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Array, computed: '_computeLabels(change.labels.*, account)'})
+ _labels?: Labels;
+
+ @property({type: Object, observer: '_computeColumns'})
+ permittedLabels?: LabelNameToValueMap;
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Object})
+ account?: AccountInfo;
+
+ @property({type: Object})
+ _labelValues?: LabelValuesMap;
+
+ getLabelValues(includeDefaults = true): LabelNameToValuesMap {
+ const labels: LabelNameToValuesMap = {};
+ if (this.shadowRoot === null || !this.change) {
+ return labels;
+ }
+ for (const label in this.permittedLabels) {
+ if (!hasOwnProperty(this.permittedLabels, label)) {
+ continue;
+ }
+
+ const selectorEl = this.shadowRoot.querySelector(
+ `gr-label-score-row[name="${label}"]`
+ ) as null | GrLabelScoreRow;
+ if (!selectorEl) {
+ continue;
+ }
+
+ // The user may have not voted on this label.
+ if (!selectorEl.selectedItem) {
+ continue;
+ }
+
+ const selectedVal =
+ typeof selectorEl.selectedValue === 'string'
+ ? Number(selectorEl.selectedValue)
+ : selectorEl.selectedValue;
+
+ if (selectedVal === undefined) {
+ continue;
+ }
+
+ // Only send the selection if the user changed it.
+ const prevVal = this._getVoteForAccount(
+ this.change.labels,
+ label,
+ this.account
+ );
+
+ let prevValNum: number | null | undefined;
+ if (typeof prevVal === 'string') {
+ prevValNum = Number(prevVal);
+ } else {
+ prevValNum = prevVal;
+ }
+
+ const defValNum = this._getDefaultValue(this.change.labels, label);
+
+ if (selectedVal !== prevValNum) {
+ if (includeDefaults || !!prevValNum || selectedVal !== defValNum) {
+ labels[label] = selectedVal;
+ }
+ }
+ }
+ return labels;
+ }
+
+ _getStringLabelValue(
+ labels: LabelNameToInfoMap,
+ labelName: string,
+ numberValue?: number
+ ) {
+ for (const k in (labels[labelName] as DetailedLabelInfo).values) {
+ if (Number(k) === numberValue) {
+ return k;
+ }
+ }
+ return numberValue;
+ }
+
+ _getDefaultValue(labels?: LabelNameToInfoMap, labelName?: string) {
+ if (!labelName || !labels?.[labelName]) return undefined;
+ const labelInfo = labels[labelName] as DetailedLabelInfo;
+ return labelInfo.default_value;
+ }
+
+ _getVoteForAccount(
+ labels: LabelNameToInfoMap | undefined,
+ labelName: string,
+ account?: AccountInfo
+ ) {
+ if (!labels) return null;
+ const votes = labels[labelName] as DetailedLabelInfo;
+ if (votes.all && votes.all.length > 0) {
+ for (let i = 0; i < votes.all.length; i++) {
+ // TODO(TS): Replace == with === and check code can assign string to _account_id instead of number
+ // eslint-disable-next-line eqeqeq
+ if (account && votes.all[i]._account_id == account._account_id) {
+ return this._getStringLabelValue(
+ labels,
+ labelName,
+ votes.all[i].value
+ );
+ }
+ }
+ }
+ return null;
+ }
+
+ _computeLabels(
+ labelRecord: PolymerDeepPropertyChange<
+ LabelNameToInfoMap,
+ LabelNameToInfoMap
+ >,
+ account?: AccountInfo
+ ) {
+ // Polymer 2: check for undefined
+ if ([labelRecord, account].includes(undefined)) {
+ return undefined;
+ }
+
+ const labelsObj = labelRecord.base;
+ if (!labelsObj) {
+ return [];
+ }
+ return Object.keys(labelsObj)
+ .sort()
+ .map(key => {
+ return {
+ name: key,
+ value: this._getVoteForAccount(labelsObj, key, this.account),
+ };
+ });
+ }
+
+ _computeColumns(permittedLabels?: LabelNameToValueMap) {
+ if (!permittedLabels) return;
+ const labels = Object.keys(permittedLabels);
+ const values: Set<number> = new Set();
+ for (const label of labels) {
+ for (const value of permittedLabels[label]) {
+ values.add(Number(value));
+ }
+ }
+
+ const orderedValues = Array.from(values.values()).sort((a, b) => a - b);
+
+ const labelValues: LabelValuesMap = {};
+ for (let i = 0; i < orderedValues.length; i++) {
+ labelValues[orderedValues[i]] = i;
+ }
+ this._labelValues = labelValues;
+ }
+
+ _changeIsMerged(changeStatus: string) {
+ return changeStatus === 'MERGED';
+ }
+
+ _computeLabelAccessClass(
+ label?: string,
+ permittedLabels?: LabelNameToValueMap
+ ) {
+ if (!permittedLabels || !label) {
+ return '';
+ }
+
+ return hasOwnProperty(permittedLabels, label) &&
+ permittedLabels[label].length
+ ? 'access'
+ : 'no-access';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-label-scores': GrLabelScores;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
index 974e55d..7b1fb7f 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
@@ -34,7 +34,7 @@
display: table-row;
}
gr-label-score-row.no-access {
- display: var(--label-no-access-display, table-row);
+ display: none;
}
</style>
<div class="scoresTable">
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
index ffc17cd..ae639e1 100644
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
@@ -99,6 +99,22 @@
});
});
+ test('getLabelValues includeDefaults', async () => {
+ element.change = {
+ _number: '123',
+ labels: {
+ 'Code-Review': {
+ values: {'0': 'meh', '+1': 'good', '-1': 'bad'},
+ default_value: 0,
+ },
+ },
+ };
+ await flush();
+
+ assert.deepEqual(element.getLabelValues(true), {'Code-Review': 0});
+ assert.deepEqual(element.getLabelValues(false), {});
+ });
+
test('_getVoteForAccount', () => {
const labelName = 'Code-Review';
assert.strictEqual(element._getVoteForAccount(
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
deleted file mode 100644
index 0da687f..0000000
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ /dev/null
@@ -1,430 +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 '@polymer/iron-icon/iron-icon.js';
-import '../../shared/gr-account-label/gr-account-label.js';
-import '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-formatted-text/gr-formatted-text.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import '../../../styles/gr-voting-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-message_html.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-
-const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+:\s*(.*)/;
-const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?$/;
-
-/**
- * @extends PolymerElement
- */
-class GrMessage extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-message'; }
- /**
- * Fired when this message's reply link is tapped.
- *
- * @event reply
- */
-
- /**
- * Fired when the message's timestamp is tapped.
- *
- * @event message-anchor-tap
- */
-
- /**
- * Fired when a change message is deleted.
- *
- * @event change-message-deleted
- */
-
- static get properties() {
- return {
- /** @type {?} */
- change: Object,
- changeNum: Number,
- /** @type {?} */
- message: Object,
- author: {
- type: Object,
- computed: '_computeAuthor(message)',
- },
- /**
- * TODO(taoalpha): remove once the change log experiment is launched
- *
- * @type {Object} - a map on file and comments on it
- */
- comments: {
- type: Object,
- },
- config: Object,
- hideAutomated: {
- type: Boolean,
- value: false,
- },
- hidden: {
- type: Boolean,
- computed: '_computeIsHidden(hideAutomated, isAutomated)',
- reflectToAttribute: true,
- },
- isAutomated: {
- type: Boolean,
- computed: '_computeIsAutomated(message)',
- },
- showOnBehalfOf: {
- type: Boolean,
- computed: '_computeShowOnBehalfOf(message)',
- },
- showReplyButton: {
- type: Boolean,
- computed: '_computeShowReplyButton(message, _loggedIn)',
- },
- projectName: {
- type: String,
- observer: '_projectNameChanged',
- },
-
- /**
- * A mapping from label names to objects representing the minimum and
- * maximum possible values for that label.
- */
- labelExtremes: Object,
-
- /**
- * @type {{ commentlinks: Array }}
- */
- _projectConfig: Object,
- // Computed property needed to trigger Polymer value observing.
- _expanded: {
- type: Object,
- computed: '_computeExpanded(message.expanded)',
- },
- _messageContentExpanded: {
- type: String,
- computed:
- '_computeMessageContentExpanded(message.message, message.tag)',
- },
- _messageContentCollapsed: {
- type: String,
- computed:
- '_computeMessageContentCollapsed(message.message, message.tag,' +
- ' message.commentThreads)',
- },
- _commentCountText: {
- type: Number,
- computed: '_computeCommentCountText(message.commentThreads.length)',
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- },
- _isAdmin: {
- type: Boolean,
- value: false,
- },
- _isDeletingChangeMsg: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_updateExpandedClass(message.expanded)',
- ];
- }
-
- constructor() {
- super();
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('click',
- e => this._handleClick(e));
- }
-
- /** @override */
- ready() {
- super.ready();
- this.$.restAPI.getConfig().then(config => {
- this.config = config;
- });
- this.$.restAPI.getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- });
- this.$.restAPI.getIsAdmin().then(isAdmin => {
- this._isAdmin = isAdmin;
- });
- }
-
- _updateExpandedClass(expanded) {
- if (expanded) {
- this.classList.add('expanded');
- } else {
- this.classList.remove('expanded');
- }
- }
-
- _computeCommentCountText(threadsLength) {
- if (threadsLength === 0) {
- return undefined;
- } else if (threadsLength === 1) {
- return '1 comment';
- } else {
- return `${threadsLength} comments`;
- }
- }
-
- _onThreadListModified() {
- // TODO(taoalpha): this won't propagate the changes to the files
- // should consider replacing this with either top level events
- // or gerrit level events
-
- // emit the event so change-view can also get updated with latest changes
- this.dispatchEvent(new CustomEvent('comment-refresh', {
- composed: true, bubbles: true,
- }));
- }
-
- _computeMessageContentExpanded(content, tag) {
- return this._computeMessageContent(content, tag, true);
- }
-
- _patchsetCommentSummary(commentThreads) {
- const id = this.message.id;
- if (!id) return '';
- const patchsetThreads = commentThreads.filter(thread =>
- thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS);
- for (const thread of patchsetThreads) {
- // Find if there was a patchset level comment created through the reply
- // dialog and use it to determine the summary
- if (thread.comments[0].change_message_id === id) {
- return thread.comments[0].message;
- }
- }
- // Find if there is a reply to some patchset comment left
- for (const thread of patchsetThreads) {
- for (const comment of thread.comments) {
- if (comment.change_message_id === id) { return comment.message; }
- }
- }
- return '';
- }
-
- _computeMessageContentCollapsed(content, tag, commentThreads) {
- const summary =
- this._computeMessageContent(content, tag, false);
- if (summary || !commentThreads) return summary;
- return this._patchsetCommentSummary(commentThreads);
- }
-
- _computeMessageContent(content, tag, isExpanded) {
- content = content || '';
- tag = tag || '';
- const isNewPatchSet = tag.endsWith(':newPatchSet') ||
- tag.endsWith(':newWipPatchSet');
- const lines = content.split('\n');
- const filteredLines = lines.filter(line => {
- if (!isExpanded && line.startsWith('>')) {
- return false;
- }
- if (line.startsWith('(') && line.endsWith(' comment)')) {
- return false;
- }
- if (line.startsWith('(') && line.endsWith(' comments)')) {
- return false;
- }
- if (!isNewPatchSet && line.match(PATCH_SET_PREFIX_PATTERN)) {
- return false;
- }
- return true;
- });
- const mappedLines = filteredLines.map(line => {
- // The change message formatting is not very consistent, so
- // unfortunately we have to do a bit of tweaking here:
- // Labels should be stripped from lines like this:
- // Patch Set 29: Verified+1
- // Rebase messages (which have a ':newPatchSet' tag) should be kept on
- // lines like this:
- // Patch Set 27: Patch Set 26 was rebased
- if (isNewPatchSet) {
- line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
- }
- return line;
- });
- return mappedLines.join('\n').trim();
- }
-
- _computeAuthor(message) {
- return message.author || message.updated_by;
- }
-
- _computeShowOnBehalfOf(message) {
- const author = message.author || message.updated_by;
- return !!(author && message.real_author &&
- author._account_id != message.real_author._account_id);
- }
-
- _computeShowReplyButton(message, loggedIn) {
- return message && !!message.message && loggedIn &&
- !this._computeIsAutomated(message);
- }
-
- _computeExpanded(expanded) {
- return expanded;
- }
-
- _handleClick(e) {
- if (this.message.expanded) { return; }
- e.stopPropagation();
- this.set('message.expanded', true);
- }
-
- _handleAuthorClick(e) {
- if (!this.message.expanded) { return; }
- e.stopPropagation();
- this.set('message.expanded', false);
- }
-
- _computeIsAutomated(message) {
- return !!(message.reviewer ||
- this._computeIsReviewerUpdate(message) ||
- (message.tag && message.tag.startsWith('autogenerated')));
- }
-
- _computeIsHidden(hideAutomated, isAutomated) {
- return hideAutomated && isAutomated;
- }
-
- _computeIsReviewerUpdate(message) {
- return message.type === 'REVIEWER_UPDATE';
- }
-
- _getScores(message, labelExtremes) {
- if (!message || !message.message || !labelExtremes) {
- return [];
- }
- const line = message.message.split('\n', 1)[0];
- const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
- if (!line.match(patchSetPrefix)) {
- return [];
- }
- const scoresRaw = line.split(patchSetPrefix)[1];
- if (!scoresRaw) {
- return [];
- }
- return scoresRaw.split(' ')
- .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
- .filter(ms =>
- ms && ms.length === 4 && labelExtremes.hasOwnProperty(ms[2]))
- .map(ms => {
- const label = ms[2];
- const value = ms[1] === '-' ? 'removed' : ms[3];
- return {label, value};
- });
- }
-
- _computeScoreClass(score, labelExtremes) {
- // Polymer 2: check for undefined
- if ([score, labelExtremes].includes(undefined)) {
- return '';
- }
- if (score.value === 'removed') {
- return 'removed';
- }
- const classes = [];
- if (score.value > 0) {
- classes.push('positive');
- } else if (score.value < 0) {
- classes.push('negative');
- }
- const extremes = labelExtremes[score.label];
- if (extremes) {
- const intScore = parseInt(score.value, 10);
- if (intScore === extremes.max) {
- classes.push('max');
- } else if (intScore === extremes.min) {
- classes.push('min');
- }
- }
- return classes.join(' ');
- }
-
- _computeClass(expanded) {
- const classes = [];
- classes.push(expanded ? 'expanded' : 'collapsed');
- return classes.join(' ');
- }
-
- _handleAnchorClick(e) {
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('message-anchor-tap', {
- bubbles: true,
- composed: true,
- detail: {id: this.message.id},
- }));
- }
-
- _handleReplyTap(e) {
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('reply', {
- detail: {message: this.message},
- composed: true, bubbles: true,
- }));
- }
-
- _handleDeleteMessage(e) {
- e.preventDefault();
- if (!this.message || !this.message.id) return;
- this._isDeletingChangeMsg = true;
- this.$.restAPI.deleteChangeCommitMessage(this.changeNum, this.message.id)
- .then(() => {
- this._isDeletingChangeMsg = false;
- this.dispatchEvent(new CustomEvent('change-message-deleted', {
- detail: {message: this.message},
- composed: true, bubbles: true,
- }));
- });
- }
-
- _projectNameChanged(name) {
- this.$.restAPI.getProjectConfig(name).then(config => {
- this._projectConfig = config;
- });
- }
-
- _computeExpandToggleIcon(expanded) {
- return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
- }
-
- _toggleExpanded(e) {
- e.stopPropagation();
- this.set('message.expanded', !this.message.expanded);
- }
-}
-
-customElements.define(GrMessage.is, GrMessage);
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
new file mode 100644
index 0000000..8b0cf4b
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -0,0 +1,504 @@
+/**
+ * @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 '@polymer/iron-icon/iron-icon';
+import '../../shared/gr-account-label/gr-account-label';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-formatted-text/gr-formatted-text';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../../styles/gr-voting-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-message_html';
+import {SpecialFilePath} from '../../../constants/constants';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {
+ ChangeInfo,
+ ChangeMessageInfo,
+ ServerInfo,
+ ConfigInfo,
+ RepoName,
+ ReviewInputTag,
+ VotingRangeInfo,
+ NumericChangeId,
+ ChangeMessageId,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {CommentThread} from '../../../utils/comment-util';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+const PATCH_SET_PREFIX_PATTERN = /^(?:Uploaded\s*)?(?:P|p)atch (?:S|s)et \d+:\s*(.*)/;
+const LABEL_TITLE_SCORE_PATTERN = /^(-?)([A-Za-z0-9-]+?)([+-]\d+)?[.]?$/;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-message': GrMessage;
+ }
+}
+
+export interface MessageAnchorTapDetail {
+ id: ChangeMessageId;
+}
+
+export interface GrMessage {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+interface ChangeMessage extends ChangeMessageInfo {
+ // TODO(TS): maybe should be an enum instead
+ type: string;
+ expanded: boolean;
+ commentThreads: CommentThread[];
+}
+
+export type LabelExtreme = {[labelName: string]: VotingRangeInfo};
+
+interface Score {
+ label?: string;
+ value?: string;
+}
+
+@customElement('gr-message')
+export class GrMessage extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when this message's reply link is tapped.
+ *
+ * @event reply
+ */
+
+ /**
+ * Fired when the message's timestamp is tapped.
+ *
+ * @event message-anchor-tap
+ */
+
+ /**
+ * Fired when a change message is deleted.
+ *
+ * @event change-message-deleted
+ */
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Number})
+ changeNum?: NumericChangeId;
+
+ @property({type: Object})
+ message: ChangeMessage | undefined;
+
+ @computed('message')
+ get author() {
+ return this.message?.author || this.message?.updated_by;
+ }
+
+ @property({type: Object})
+ config?: ServerInfo;
+
+ @property({type: Boolean})
+ hideAutomated = false;
+
+ @property({
+ type: Boolean,
+ reflectToAttribute: true,
+ computed: '_computeIsHidden(hideAutomated, isAutomated)',
+ })
+ hidden = false;
+
+ @computed('message')
+ get isAutomated() {
+ return !!this.message && this._computeIsAutomated(this.message);
+ }
+
+ @computed('message')
+ get showOnBehalfOf() {
+ return !!this.message && this._computeShowOnBehalfOf(this.message);
+ }
+
+ @property({
+ type: Boolean,
+ computed: '_computeShowReplyButton(message, _loggedIn)',
+ })
+ showReplyButton = false;
+
+ @property({type: String})
+ projectName?: string;
+
+ /**
+ * A mapping from label names to objects representing the minimum and
+ * maximum possible values for that label.
+ */
+ @property({type: Object})
+ labelExtremes?: LabelExtreme;
+
+ @property({type: Object})
+ _projectConfig?: ConfigInfo;
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Boolean})
+ _isAdmin = false;
+
+ @property({type: Boolean})
+ _isDeletingChangeMsg = false;
+
+ @property({type: Boolean, computed: '_computeExpanded(message.expanded)'})
+ _expanded = false;
+
+ @property({
+ type: String,
+ computed: '_computeMessageContentExpanded(message.message, message.tag)',
+ })
+ _messageContentExpanded = '';
+
+ @property({
+ type: String,
+ computed:
+ '_computeMessageContentCollapsed(message.message, message.tag,' +
+ ' message.commentThreads)',
+ })
+ _messageContentCollapsed = '';
+
+ @property({
+ type: String,
+ computed: '_computeCommentCountText(message.commentThreads.length)',
+ })
+ _commentCountText = '';
+
+ created() {
+ super.created();
+ this.addEventListener('click', e => this._handleClick(e));
+ }
+
+ attached() {
+ super.attached();
+ this.$.restAPI.getConfig().then(config => {
+ this.config = config;
+ });
+ this.$.restAPI.getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ });
+ this.$.restAPI.getIsAdmin().then(isAdmin => {
+ this._isAdmin = !!isAdmin;
+ });
+ }
+
+ @observe('message.expanded')
+ _updateExpandedClass(expanded: boolean) {
+ if (expanded) {
+ this.classList.add('expanded');
+ } else {
+ this.classList.remove('expanded');
+ }
+ }
+
+ _computeCommentCountText(threadsLength?: number) {
+ if (threadsLength === 0) {
+ return undefined;
+ } else if (threadsLength === 1) {
+ return '1 comment';
+ } else {
+ return `${threadsLength} comments`;
+ }
+ }
+
+ _onThreadListModified() {
+ // TODO(taoalpha): this won't propagate the changes to the files
+ // should consider replacing this with either top level events
+ // or gerrit level events
+
+ // emit the event so change-view can also get updated with latest changes
+ this.dispatchEvent(
+ new CustomEvent('comment-refresh', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
+ return this._computeMessageContent(content, tag, true);
+ }
+
+ _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
+ const id = this.message?.id;
+ if (!id) return '';
+ const patchsetThreads = commentThreads.filter(
+ thread => thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
+ );
+ for (const thread of patchsetThreads) {
+ // Find if there was a patchset level comment created through the reply
+ // dialog and use it to determine the summary
+ if (thread.comments[0].change_message_id === id) {
+ return thread.comments[0].message;
+ }
+ }
+ // Find if there is a reply to some patchset comment left
+ for (const thread of patchsetThreads) {
+ for (const comment of thread.comments) {
+ if (comment.change_message_id === id) {
+ return comment.message;
+ }
+ }
+ }
+ return '';
+ }
+
+ _computeMessageContentCollapsed(
+ content?: string,
+ tag?: ReviewInputTag,
+ commentThreads?: CommentThread[]
+ ) {
+ const summary = this._computeMessageContent(content, tag, false);
+ if (summary || !commentThreads) return summary;
+ return this._patchsetCommentSummary(commentThreads);
+ }
+
+ _computeMessageContent(
+ content = '',
+ tag: ReviewInputTag = '' as ReviewInputTag,
+ isExpanded: boolean
+ ) {
+ const isNewPatchSet =
+ tag.endsWith(':newPatchSet') || tag.endsWith(':newWipPatchSet');
+ const lines = content.split('\n');
+ const filteredLines = lines.filter(line => {
+ if (!isExpanded && line.startsWith('>')) {
+ return false;
+ }
+ if (line.startsWith('(') && line.endsWith(' comment)')) {
+ return false;
+ }
+ if (line.startsWith('(') && line.endsWith(' comments)')) {
+ return false;
+ }
+ if (!isNewPatchSet && line.match(PATCH_SET_PREFIX_PATTERN)) {
+ return false;
+ }
+ return true;
+ });
+ const mappedLines = filteredLines.map(line => {
+ // The change message formatting is not very consistent, so
+ // unfortunately we have to do a bit of tweaking here:
+ // Labels should be stripped from lines like this:
+ // Patch Set 29: Verified+1
+ // Rebase messages (which have a ':newPatchSet' tag) should be kept on
+ // lines like this:
+ // Patch Set 27: Patch Set 26 was rebased
+ if (isNewPatchSet) {
+ line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
+ }
+ return line;
+ });
+ return mappedLines.join('\n').trim();
+ }
+
+ _computeAuthor(message: ChangeMessage) {
+ return message.author || message.updated_by;
+ }
+
+ _computeShowOnBehalfOf(message: ChangeMessage) {
+ const author = this._computeAuthor(message);
+ return !!(
+ author &&
+ message.real_author &&
+ author._account_id !== message.real_author._account_id
+ );
+ }
+
+ _computeShowReplyButton(message?: ChangeMessage, loggedIn?: boolean) {
+ return (
+ message &&
+ !!message.message &&
+ loggedIn &&
+ !this._computeIsAutomated(message)
+ );
+ }
+
+ _computeExpanded(expanded: boolean) {
+ return expanded;
+ }
+
+ _handleClick(e: Event) {
+ if (this.message?.expanded) {
+ return;
+ }
+ e.stopPropagation();
+ this.set('message.expanded', true);
+ }
+
+ _handleAuthorClick(e: Event) {
+ if (!this.message?.expanded) {
+ return;
+ }
+ e.stopPropagation();
+ this.set('message.expanded', false);
+ }
+
+ _computeIsAutomated(message: ChangeMessage) {
+ return !!(
+ message.reviewer ||
+ this._computeIsReviewerUpdate(message) ||
+ (message.tag && message.tag.startsWith('autogenerated'))
+ );
+ }
+
+ _computeIsHidden(hideAutomated: boolean, isAutomated: boolean) {
+ return hideAutomated && isAutomated;
+ }
+
+ _computeIsReviewerUpdate(message: ChangeMessage) {
+ return message.type === 'REVIEWER_UPDATE';
+ }
+
+ _getScores(message?: ChangeMessage, labelExtremes?: LabelExtreme): Score[] {
+ if (!message || !message.message || !labelExtremes) {
+ return [];
+ }
+ const line = message.message.split('\n', 1)[0];
+ const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
+ if (!line.match(patchSetPrefix)) {
+ return [];
+ }
+ const scoresRaw = line.split(patchSetPrefix)[1];
+ if (!scoresRaw) {
+ return [];
+ }
+ return scoresRaw
+ .split(' ')
+ .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+ .filter(
+ ms => ms && ms.length === 4 && hasOwnProperty(labelExtremes, ms[2])
+ )
+ .map(ms => {
+ const label = ms?.[2];
+ const value = ms?.[1] === '-' ? 'removed' : ms?.[3];
+ return {label, value};
+ });
+ }
+
+ _computeScoreClass(score?: Score, labelExtremes?: LabelExtreme) {
+ // Polymer 2: check for undefined
+ if (score === undefined || labelExtremes === undefined) {
+ return '';
+ }
+ if (!score.value) {
+ return '';
+ }
+ if (score.value === 'removed') {
+ return 'removed';
+ }
+ const classes = [];
+ if (Number(score.value) > 0) {
+ classes.push('positive');
+ } else if (Number(score.value) < 0) {
+ classes.push('negative');
+ }
+ if (score.label) {
+ const extremes = labelExtremes[score.label];
+ if (extremes) {
+ const intScore = Number(score.value);
+ if (intScore === extremes.max) {
+ classes.push('max');
+ } else if (intScore === extremes.min) {
+ classes.push('min');
+ }
+ }
+ }
+ return classes.join(' ');
+ }
+
+ _computeClass(expanded: boolean) {
+ const classes = [];
+ classes.push(expanded ? 'expanded' : 'collapsed');
+ return classes.join(' ');
+ }
+
+ _handleAnchorClick(e: Event) {
+ e.preventDefault();
+ // The element which triggers _handleAnchorClick is rendered only if
+ // message.id defined: the elemenet is wrapped in dom-if if="[[message.id]]"
+ const detail: MessageAnchorTapDetail = {
+ id: this.message!.id,
+ };
+ this.dispatchEvent(
+ new CustomEvent('message-anchor-tap', {
+ bubbles: true,
+ composed: true,
+ detail,
+ })
+ );
+ }
+
+ _handleReplyTap(e: Event) {
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('reply', {
+ detail: {message: this.message},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _handleDeleteMessage(e: Event) {
+ e.preventDefault();
+ if (!this.message || !this.message.id || !this.changeNum) return;
+ this._isDeletingChangeMsg = true;
+ this.$.restAPI
+ .deleteChangeCommitMessage(this.changeNum, this.message.id)
+ .then(() => {
+ this._isDeletingChangeMsg = false;
+ this.dispatchEvent(
+ new CustomEvent('change-message-deleted', {
+ detail: {message: this.message},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }
+
+ @observe('projectName')
+ _projectNameChanged(name: string) {
+ this.$.restAPI.getProjectConfig(name as RepoName).then(config => {
+ this._projectConfig = config;
+ });
+ }
+
+ _computeExpandToggleIcon(expanded: boolean) {
+ return expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
+ }
+
+ _toggleExpanded(e: Event) {
+ e.stopPropagation();
+ this.set('message.expanded', !this.message?.expanded);
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
index 22d495b..ae4adf7 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_html.ts
@@ -96,8 +96,7 @@
margin-right: var(--spacing-s);
}
.authorLabel {
- min-width: 160px;
- display: inline-block;
+ width: 140px;
}
.expanded .author {
cursor: pointer;
@@ -172,10 +171,12 @@
padding-left: 0;
}
.score,
- .commentsSummary,
- .authorLabel {
+ .commentsSummary {
min-width: 0px;
}
+ .authorLabel {
+ width: 100px;
+ }
.dateContainer .patchset:before {
content: 'PS ';
}
@@ -268,7 +269,7 @@
items="[[update.reviewers]]"
as="reviewer"
>
- <gr-account-link account="[[reviewer]]"> </gr-account-link>
+ <gr-account-chip account="[[reviewer]]"> </gr-account-chip>
</template>
</div>
</template>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
index d425398..a705d45 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-message.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-message');
@@ -55,7 +54,7 @@
assert.deepEqual(e.detail.message, element.message);
done();
});
- flushAsynchronousOperations();
+ flush();
assert.isFalse(
element.shadowRoot.querySelector('.replyActionContainer').hidden
);
@@ -76,11 +75,12 @@
expanded: true,
};
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').hidden);
});
test('delete change message', done => {
+ element.changeNum = 314159;
element.message = {
id: '47c43261_55aa2c41',
author: {
@@ -99,7 +99,7 @@
assert.isFalse(element.shadowRoot.querySelector('.deleteBtn').disabled);
done();
});
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot.querySelector('.deleteBtn'));
assert.isTrue(element.shadowRoot.querySelector('.deleteBtn').disabled);
});
@@ -202,9 +202,9 @@
message: `Patch Set 1: ${label}+1`,
};
assert.isNotOk(
- dom(element.root).querySelector('.negativeVote'));
+ element.root.querySelector('.negativeVote'));
assert.isNotOk(
- dom(element.root).querySelector('.positiveVote'));
+ element.root.querySelector('.positiveVote'));
});
});
@@ -216,7 +216,7 @@
id: '47c43261_55aa2c41',
expanded: false,
};
- flushAsynchronousOperations();
+ flush();
const stub = sinon.stub();
element.addEventListener('message-anchor-tap', stub);
const dateEl = element.shadowRoot
@@ -301,8 +301,34 @@
'Code-Review': {max: 2, min: -2},
'Trybot-Label3': {max: 3, min: 0},
};
- flushAsynchronousOperations();
- const scoreChips = dom(element.root).querySelectorAll('.score');
+ flush();
+ const scoreChips = element.root.querySelectorAll('.score');
+ assert.equal(scoreChips.length, 3);
+
+ assert.isTrue(scoreChips[0].classList.contains('positive'));
+ assert.isTrue(scoreChips[0].classList.contains('max'));
+
+ assert.isTrue(scoreChips[1].classList.contains('negative'));
+ assert.isTrue(scoreChips[1].classList.contains('min'));
+
+ assert.isTrue(scoreChips[2].classList.contains('positive'));
+ assert.isFalse(scoreChips[2].classList.contains('min'));
+ });
+
+ test('Uploaded patch set X', () => {
+ element.message = {
+ author: {},
+ expanded: false,
+ message: 'Uploaded patch set 1:' +
+ 'Verified+1 Code-Review-2 Trybot-Label3+1 Blub+1',
+ };
+ element.labelExtremes = {
+ 'Verified': {max: 1, min: -1},
+ 'Code-Review': {max: 2, min: -2},
+ 'Trybot-Label3': {max: 3, min: 0},
+ };
+ flush();
+ const scoreChips = element.root.querySelectorAll('.score');
assert.equal(scoreChips.length, 3);
assert.isTrue(scoreChips[0].classList.contains('positive'));
@@ -326,8 +352,8 @@
'Code-Review': {max: 2, min: -2},
'Commit-Queue': {max: 3, min: 0},
};
- flushAsynchronousOperations();
- const scoreChips = dom(element.root).querySelectorAll('.score');
+ flush();
+ const scoreChips = element.root.querySelectorAll('.score');
assert.equal(scoreChips.length, 3);
assert.isTrue(scoreChips[1].classList.contains('removed'));
@@ -341,7 +367,7 @@
message: 'Patch Set 1: Cherry Picked from branch stable-2.14.',
};
element.labelExtremes = {};
- const scoreChips = dom(element.root).querySelectorAll('.score');
+ const scoreChips = element.root.querySelectorAll('.score');
assert.equal(scoreChips.length, 0);
});
});
@@ -373,7 +399,7 @@
expanded: true,
};
- flushAsynchronousOperations();
+ flush();
assert.isTrue(
element.shadowRoot.querySelector('.replyActionContainer').hidden
);
@@ -471,7 +497,7 @@
expanded: true,
};
- flushAsynchronousOperations();
+ flush();
assert.isFalse(
element.shadowRoot.querySelector('.replyActionContainer').hidden
);
@@ -482,7 +508,7 @@
test('reply button shown when message is updated', () => {
element.message = undefined;
- flushAsynchronousOperations();
+ flush();
let replyEl = element.shadowRoot.querySelector('.replyActionContainer');
// We don't even expect the button to show up in the DOM when the message
// is undefined.
@@ -500,7 +526,7 @@
_revision_number: 1,
expanded: true,
};
- flushAsynchronousOperations();
+ flush();
replyEl = element.shadowRoot.querySelector('.replyActionContainer');
assert.isOk(replyEl);
assert.isFalse(replyEl.hidden);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
deleted file mode 100644
index 635336f..0000000
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ /dev/null
@@ -1,434 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../gr-message/gr-message.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-messages-list_html.js';
-import {
- KeyboardShortcutMixin,
- Shortcut, ShortcutSection,
-} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {parseDate} from '../../../utils/date-util.js';
-import {MessageTag} from '../../../constants/constants.js';
-import {appContext} from '../../../services/app-context.js';
-
-/**
- * The content of the enum is also used in the UI for the button text.
- *
- * @enum {string}
- */
-const ExpandAllState = {
- EXPAND_ALL: 'Expand All',
- COLLAPSE_ALL: 'Collapse All',
-};
-
-/**
- * Computes message author's comments for this change message. The backend
- * sets comment.change_message_id for matching, so this computation is fairly
- * straightforward.
- */
-function computeThreads(message, allMessages, changeComments) {
- if ([message, allMessages, changeComments].includes(undefined)) {
- return [];
- }
- if (message._index === undefined) {
- return [];
- }
-
- return changeComments.getAllThreadsForChange().filter(
- thread => thread.comments.map(comment => {
- // collapse all by default
- comment.collapsed = true;
- return comment;
- }).some(comment => {
- const condition = comment.change_message_id === message.id;
- // Since getAllThreadsForChange() always returns a new copy of
- // all comments we can modify them here without worrying about
- // polluting other threads.
- comment.collapsed = !condition;
- return condition;
- })
- );
-}
-
-/**
- * If messages have the same tag, then that influences grouping and whether
- * a message is initally hidden or not, see isImportant(). So we are applying
- * some "magic" rules here in order to hide exactly the right messages.
- *
- * 1. If a message does not have a tag, but is associated with robot comments,
- * then it gets a tag.
- *
- * 2. Use the same tag for some of Gerrit's standard events, if they should be
- * considered one group, e.g. normal and wip patchset uploads.
- *
- * 3. Everything beyond the ~ character is cut off from the tag. That gives
- * tools control over which messages will be hidden.
- */
-function computeTag(message) {
- if (!message.tag) {
- const threads = message.commentThreads || [];
- const comments = threads.map(
- t => t.comments.find(c => c.change_message_id === message.id));
- const isRobot = comments.some(c => c && !!c.robot_id);
- return isRobot ? 'autogenerated:has-robot-comments' : undefined;
- }
-
- if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
- return MessageTag.TAG_NEW_PATCHSET;
- }
- if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
- return MessageTag.TAG_SET_ASSIGNEE;
- }
- if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
- return MessageTag.TAG_SET_PRIVATE;
- }
- if (message.tag === MessageTag.TAG_SET_WIP) {
- return MessageTag.TAG_SET_READY;
- }
-
- return message.tag.replace(/~.*/, '');
-}
-
-/**
- * Try to set a revision number that makes sense, if none is set. Just copy
- * over the revision number of the next older message. This is mostly relevant
- * for reviewer updates. Other messages should typically have the revision
- * number already set.
- */
-function computeRevision(message, allMessages) {
- if (message._revision_number > 0) return message._revision_number;
- let revision = 0;
- for (const m of allMessages) {
- if (m.date > message.date) break;
- if (m._revision_number > revision) revision = m._revision_number;
- }
- return revision > 0 ? revision : undefined;
-}
-
-/**
- * Unimportant messages are initially hidden.
- *
- * Human messages are always important. They have an undefined tag.
- *
- * Autogenerated messages are unimportant, if there is a message with the same
- * tag and a higher revision number.
- */
-function computeIsImportant(message, allMessages) {
- if (!message.tag) return true;
-
- const hasSameTag = m => m.tag === message.tag;
- const revNumber = message._revision_number || 0;
- const hasHigherRevisionNumber = m => m._revision_number > revNumber;
- return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber);
-}
-
-export const TEST_ONLY = {
- computeThreads,
- computeTag,
- computeRevision,
- computeIsImportant,
-};
-
-/**
- * @extends PolymerElement
- */
-class GrMessagesList extends KeyboardShortcutMixin(
- GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-messages-list'; }
-
- static get properties() {
- return {
- /** @type {?} */
- change: Object,
- changeNum: Number,
- /**
- * These are just the change messages. They are combined with reviewer
- * updates below. So _combinedMessages is the more important property.
- */
- messages: {
- type: Array,
- value() { return []; },
- },
- /**
- * These are just the reviewer updates. They are combined with change
- * messages above. So _combinedMessages is the more important property.
- */
- reviewerUpdates: {
- type: Array,
- value() { return []; },
- },
- changeComments: Object,
- projectName: String,
- showReplyButtons: {
- type: Boolean,
- value: false,
- },
- labels: Object,
-
- /**
- * Keeps track of the state of the "Expand All" toggle button. Note that
- * you can individually expand/collapse some messages without affecting
- * the toggle button's state.
- *
- * @type {ExpandAllState}
- */
- _expandAllState: {
- type: String,
- value: ExpandAllState.EXPAND_ALL,
- },
- _expandAllTitle: {
- type: String,
- computed: '_computeExpandAllTitle(_expandAllState)',
- },
-
- _showAllActivity: {
- type: Boolean,
- value: false,
- observer: '_observeShowAllActivity',
- },
- /**
- * The merged array of change messages and reviewer updates.
- */
- _combinedMessages: {
- type: Array,
- computed: '_computeCombinedMessages(messages, reviewerUpdates, '
- + 'changeComments)',
- observer: '_combinedMessagesChanged',
- },
-
- _labelExtremes: {
- type: Object,
- computed: '_computeLabelExtremes(labels.*)',
- },
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- scrollToMessage(messageID) {
- const selector = `[data-message-id="${messageID}"]`;
- const el = this.shadowRoot.querySelector(selector);
-
- if (!el && this._showAllActivity) {
- console.warn(`Failed to scroll to message: ${messageID}`);
- return;
- }
- if (!el) {
- this._showAllActivity = true;
- setTimeout(() => this.scrollToMessage(messageID));
- return;
- }
-
- el.set('message.expanded', true);
- let top = el.offsetTop;
- for (let offsetParent = el.offsetParent;
- offsetParent;
- offsetParent = offsetParent.offsetParent) {
- top += offsetParent.offsetTop;
- }
- window.scrollTo(0, top);
- this._highlightEl(el);
- }
-
- _observeShowAllActivity(showAllActivity) {
- // We have to call render() such that the dom-repeat filter picks up the
- // change.
- this.$.messageRepeat.render();
- }
-
- /**
- * Filter for the dom-repeat of combinedMessages.
- */
- _isMessageVisible(message) {
- return this._showAllActivity || message.isImportant;
- }
-
- /**
- * Merges change messages and reviewer updates into one array. Also processes
- * all messages and updates, aligns or massages some of the properties.
- */
- _computeCombinedMessages(messages, reviewerUpdates, changeComments) {
- const params = [messages, reviewerUpdates, changeComments];
- if (params.some(o => o === undefined)) return [];
-
- let mi = 0;
- let ri = 0;
- let combinedMessages = [];
- let mDate;
- let rDate;
- for (let i = 0; i < messages.length; i++) {
- messages[i]._index = i;
- }
-
- while (mi < messages.length || ri < reviewerUpdates.length) {
- if (mi >= messages.length) {
- combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
- break;
- }
- if (ri >= reviewerUpdates.length) {
- combinedMessages = combinedMessages.concat(messages.slice(mi));
- break;
- }
- mDate = mDate || parseDate(messages[mi].date);
- rDate = rDate || parseDate(reviewerUpdates[ri].date);
- if (rDate < mDate) {
- combinedMessages.push(reviewerUpdates[ri++]);
- rDate = null;
- } else {
- combinedMessages.push(messages[mi++]);
- mDate = null;
- }
- }
- combinedMessages.forEach(m => {
- if (m.expanded === undefined) {
- m.expanded = false;
- }
- m.commentThreads = computeThreads(m, combinedMessages, changeComments);
- m._revision_number = computeRevision(m, combinedMessages);
- m.tag = computeTag(m);
- });
- // computeIsImportant() depends on tags and revision numbers already being
- // updated for all messages, so we have to compute this in its own forEach
- // loop.
- combinedMessages.forEach(m => {
- m.isImportant = computeIsImportant(m, combinedMessages);
- });
- return combinedMessages;
- }
-
- _updateExpandedStateOfAllMessages(exp) {
- if (this._combinedMessages) {
- for (let i = 0; i < this._combinedMessages.length; i++) {
- this._combinedMessages[i].expanded = exp;
- this.notifyPath(`_combinedMessages.${i}.expanded`);
- }
- }
- }
-
- _computeExpandAllTitle(_expandAllState) {
- if (_expandAllState === ExpandAllState.COLLAPSED_ALL) {
- return this.createTitle(
- Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS);
- }
- if (_expandAllState === ExpandAllState.EXPAND_ALL) {
- return this.createTitle(
- Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS);
- }
- return '';
- }
-
- _highlightEl(el) {
- const highlightedEls =
- dom(this.root).querySelectorAll('.highlighted');
- for (const highlightedEl of highlightedEls) {
- highlightedEl.classList.remove('highlighted');
- }
- function handleAnimationEnd() {
- el.removeEventListener('animationend', handleAnimationEnd);
- el.classList.remove('highlighted');
- }
- el.addEventListener('animationend', handleAnimationEnd);
- el.classList.add('highlighted');
- }
-
- /**
- * @param {boolean} expand
- */
- handleExpandCollapse(expand) {
- this._expandAllState = expand ? ExpandAllState.COLLAPSE_ALL
- : ExpandAllState.EXPAND_ALL;
- this._updateExpandedStateOfAllMessages(expand);
- }
-
- _handleExpandCollapseTap(e) {
- e.preventDefault();
- this.handleExpandCollapse(
- this._expandAllState === ExpandAllState.EXPAND_ALL);
- }
-
- _handleAnchorClick(e) {
- this.scrollToMessage(e.detail.id);
- }
-
- _isVisibleShowAllActivityToggle(messages = []) {
- return messages.some(m => !m.isImportant);
- }
-
- _computeHiddenEntriesCount(messages = []) {
- return messages.filter(m => !m.isImportant).length;
- }
-
- /**
- * This method is for reporting stats only.
- */
- _combinedMessagesChanged(combinedMessages) {
- if (combinedMessages) {
- if (combinedMessages.length === 0) return;
- const tags = combinedMessages.map(
- message => message.tag || message.type ||
- (message.comments ? 'comments' : 'none'));
- const tagsCounted = tags.reduce((acc, val) => {
- acc[val] = (acc[val] || 0) + 1;
- return acc;
- }, {all: combinedMessages.length});
- this.reporting.reportInteraction('messages-count', tagsCounted);
- }
- }
-
- /**
- * Compute a mapping from label name to objects representing the minimum and
- * maximum possible values for that label.
- */
- _computeLabelExtremes(labelRecord) {
- const extremes = {};
- const labels = labelRecord.base;
- if (!labels) { return extremes; }
- for (const key of Object.keys(labels)) {
- if (!labels[key] || !labels[key].values) { continue; }
- const values = Object.keys(labels[key].values)
- .map(v => parseInt(v, 10));
- values.sort((a, b) => a - b);
- if (!values.length) { continue; }
- extremes[key] = {min: values[0], max: values[values.length - 1]};
- }
- return extremes;
- }
-
- /**
- * Work around a issue on iOS when clicking turns into double tap
- */
- _onTapShowAllActivityToggle(e) {
- e.preventDefault();
- }
-}
-
-customElements.define(GrMessagesList.is,
- GrMessagesList);
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
new file mode 100644
index 0000000..8557c10
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.ts
@@ -0,0 +1,495 @@
+/**
+ * @license
+ * 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.
+ */
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-icons/gr-icons';
+import '../gr-message/gr-message';
+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-messages-list_html';
+import {
+ KeyboardShortcutMixin,
+ Shortcut,
+ ShortcutSection,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {parseDate} from '../../../utils/date-util';
+import {MessageTag} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {
+ ChangeId,
+ ChangeMessageId,
+ ChangeMessageInfo,
+ ChangeViewChangeInfo,
+ LabelNameToInfoMap,
+ NumericChangeId,
+ PatchSetNum,
+ RepoName,
+ ReviewerUpdateInfo,
+ VotingRangeInfo,
+} from '../../../types/common';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread, isRobot} from '../../../utils/comment-util';
+import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {FormattedReviewerUpdateInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
+import {getVotingRange} from '../../../utils/label-util';
+
+/**
+ * The content of the enum is also used in the UI for the button text.
+ */
+enum ExpandAllState {
+ EXPAND_ALL = 'Expand All',
+ COLLAPSE_ALL = 'Collapse All',
+}
+
+interface TagsCountReportInfo {
+ [tag: string]: number;
+ all: number;
+}
+
+type CombinedMessage = Omit<
+ FormattedReviewerUpdateInfo | ChangeMessageInfo,
+ 'tag'
+> & {
+ _revision_number?: PatchSetNum;
+ _index?: number;
+ expanded?: boolean;
+ isImportant?: boolean;
+ commentThreads?: CommentThread[];
+ tag?: string;
+};
+
+function isChangeMessageInfo(x: CombinedMessage): x is ChangeMessageInfo {
+ return (x as ChangeMessageInfo).id !== undefined;
+}
+
+function getMessageId(x: CombinedMessage): ChangeMessageId | undefined {
+ return isChangeMessageInfo(x) ? x.id : undefined;
+}
+
+/**
+ * Computes message author's comments for this change message. The backend
+ * sets comment.change_message_id for matching, so this computation is fairly
+ * straightforward.
+ */
+function computeThreads(
+ message: CombinedMessage,
+ changeComments: ChangeComments
+): CommentThread[] {
+ if (message._index === undefined) {
+ return [];
+ }
+ const messageId = getMessageId(message);
+ return changeComments.getAllThreadsForChange().filter(thread =>
+ thread.comments
+ .map(comment => {
+ // collapse all by default
+ comment.collapsed = true;
+ return comment;
+ })
+ .some(comment => {
+ const condition = comment.change_message_id === messageId;
+ // Since getAllThreadsForChange() always returns a new copy of
+ // all comments we can modify them here without worrying about
+ // polluting other threads.
+ comment.collapsed = !condition;
+ return condition;
+ })
+ );
+}
+
+/**
+ * If messages have the same tag, then that influences grouping and whether
+ * a message is initally hidden or not, see isImportant(). So we are applying
+ * some "magic" rules here in order to hide exactly the right messages.
+ *
+ * 1. If a message does not have a tag, but is associated with robot comments,
+ * then it gets a tag.
+ *
+ * 2. Use the same tag for some of Gerrit's standard events, if they should be
+ * considered one group, e.g. normal and wip patchset uploads.
+ *
+ * 3. Everything beyond the ~ character is cut off from the tag. That gives
+ * tools control over which messages will be hidden.
+ */
+function computeTag(message: CombinedMessage) {
+ if (!message.tag) {
+ const threads = message.commentThreads || [];
+ const messageId = getMessageId(message);
+ const comments = threads.map(t =>
+ t.comments.find(c => c.change_message_id === messageId)
+ );
+ const hasRobotComments = comments.some(isRobot);
+ return hasRobotComments ? 'autogenerated:has-robot-comments' : undefined;
+ }
+
+ if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) {
+ return MessageTag.TAG_NEW_PATCHSET;
+ }
+ if (message.tag === MessageTag.TAG_UNSET_ASSIGNEE) {
+ return MessageTag.TAG_SET_ASSIGNEE;
+ }
+ if (message.tag === MessageTag.TAG_UNSET_PRIVATE) {
+ return MessageTag.TAG_SET_PRIVATE;
+ }
+ if (message.tag === MessageTag.TAG_SET_WIP) {
+ return MessageTag.TAG_SET_READY;
+ }
+
+ return message.tag.replace(/~.*/, '');
+}
+
+/**
+ * Try to set a revision number that makes sense, if none is set. Just copy
+ * over the revision number of the next older message. This is mostly relevant
+ * for reviewer updates. Other messages should typically have the revision
+ * number already set.
+ */
+function computeRevision(
+ message: CombinedMessage,
+ allMessages: CombinedMessage[]
+): PatchSetNum | undefined {
+ if (message._revision_number && message._revision_number > 0)
+ return message._revision_number;
+ let revision: PatchSetNum = 0 as PatchSetNum;
+ for (const m of allMessages) {
+ if (m.date > message.date) break;
+ if (m._revision_number && m._revision_number > revision)
+ revision = m._revision_number;
+ }
+ return revision > 0 ? revision : undefined;
+}
+
+/**
+ * Unimportant messages are initially hidden.
+ *
+ * Human messages are always important. They have an undefined tag.
+ *
+ * Autogenerated messages are unimportant, if there is a message with the same
+ * tag and a higher revision number.
+ */
+function computeIsImportant(
+ message: CombinedMessage,
+ allMessages: CombinedMessage[]
+) {
+ if (!message.tag) return true;
+
+ const hasSameTag = (m: CombinedMessage) => m.tag === message.tag;
+ const revNumber = message._revision_number || 0;
+ const hasHigherRevisionNumber = (m: CombinedMessage) =>
+ (m._revision_number || 0) > revNumber;
+ return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber);
+}
+
+export const TEST_ONLY = {
+ computeThreads,
+ computeTag,
+ computeRevision,
+ computeIsImportant,
+};
+
+export interface GrMessagesList {
+ $: {
+ messageRepeat: DomRepeat;
+ };
+}
+
+@customElement('gr-messages-list')
+export class GrMessagesList extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ change?: ChangeViewChangeInfo;
+
+ @property({type: String})
+ changeNum?: ChangeId | NumericChangeId;
+
+ @property({type: Array})
+ messages: ChangeMessageInfo[] = [];
+
+ @property({type: Array})
+ reviewerUpdates: ReviewerUpdateInfo[] = [];
+
+ @property({type: Object})
+ changeComments?: ChangeComments;
+
+ @property({type: String})
+ projectName?: RepoName;
+
+ @property({type: Boolean})
+ showReplyButtons = false;
+
+ @property({type: Object})
+ labels?: LabelNameToInfoMap;
+
+ @property({type: String})
+ _expandAllState = ExpandAllState.EXPAND_ALL;
+
+ @property({type: String, computed: '_computeExpandAllTitle(_expandAllState)'})
+ _expandAllTitle = '';
+
+ @property({type: Boolean, observer: '_observeShowAllActivity'})
+ _showAllActivity = false;
+
+ @property({
+ type: Array,
+ computed:
+ '_computeCombinedMessages(messages, reviewerUpdates, ' +
+ 'changeComments)',
+ observer: '_combinedMessagesChanged',
+ })
+ _combinedMessages: CombinedMessage[] = [];
+
+ @property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
+ _labelExtremes: {[lableName: string]: VotingRangeInfo} = {};
+
+ private readonly reporting = appContext.reportingService;
+
+ scrollToMessage(messageID: string) {
+ const selector = `[data-message-id="${messageID}"]`;
+ const el = this.shadowRoot!.querySelector(selector) as
+ | GrMessage
+ | undefined;
+
+ if (!el && this._showAllActivity) {
+ console.warn(`Failed to scroll to message: ${messageID}`);
+ return;
+ }
+ if (!el) {
+ this._showAllActivity = true;
+ setTimeout(() => this.scrollToMessage(messageID));
+ return;
+ }
+
+ el.set('message.expanded', true);
+ let top = el.offsetTop;
+ for (
+ let offsetParent = el.offsetParent as HTMLElement | null;
+ offsetParent;
+ offsetParent = offsetParent.offsetParent as HTMLElement | null
+ ) {
+ top += offsetParent.offsetTop;
+ }
+ window.scrollTo(0, top);
+ this._highlightEl(el);
+ }
+
+ _observeShowAllActivity() {
+ // We have to call render() such that the dom-repeat filter picks up the
+ // change.
+ this.$.messageRepeat.render();
+ }
+
+ /**
+ * Filter for the dom-repeat of combinedMessages.
+ */
+ _isMessageVisible(message: CombinedMessage) {
+ return this._showAllActivity || message.isImportant;
+ }
+
+ /**
+ * Merges change messages and reviewer updates into one array. Also processes
+ * all messages and updates, aligns or massages some of the properties.
+ */
+ _computeCombinedMessages(
+ messages?: ChangeMessageInfo[],
+ reviewerUpdates?: FormattedReviewerUpdateInfo[],
+ changeComments?: ChangeComments
+ ) {
+ if (
+ messages === undefined ||
+ reviewerUpdates === undefined ||
+ changeComments === undefined
+ )
+ return [];
+
+ let mi = 0;
+ let ri = 0;
+ let combinedMessages: CombinedMessage[] = [];
+ let mDate;
+ let rDate;
+ for (let i = 0; i < messages.length; i++) {
+ // TODO(TS): clone message instead and avoid API object mutation
+ (messages[i] as CombinedMessage)._index = i;
+ }
+
+ while (mi < messages.length || ri < reviewerUpdates.length) {
+ if (mi >= messages.length) {
+ combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri));
+ break;
+ }
+ if (ri >= reviewerUpdates.length) {
+ combinedMessages = combinedMessages.concat(messages.slice(mi));
+ break;
+ }
+ mDate = mDate || parseDate(messages[mi].date);
+ rDate = rDate || parseDate(reviewerUpdates[ri].date);
+ if (rDate < mDate) {
+ combinedMessages.push(reviewerUpdates[ri++]);
+ rDate = null;
+ } else {
+ combinedMessages.push(messages[mi++]);
+ mDate = null;
+ }
+ }
+ combinedMessages.forEach(m => {
+ if (m.expanded === undefined) {
+ m.expanded = false;
+ }
+ m.commentThreads = computeThreads(m, changeComments);
+ m._revision_number = computeRevision(m, combinedMessages);
+ m.tag = computeTag(m);
+ });
+ // computeIsImportant() depends on tags and revision numbers already being
+ // updated for all messages, so we have to compute this in its own forEach
+ // loop.
+ combinedMessages.forEach(m => {
+ m.isImportant = computeIsImportant(m, combinedMessages);
+ });
+ return combinedMessages;
+ }
+
+ _updateExpandedStateOfAllMessages(exp: boolean) {
+ if (this._combinedMessages) {
+ for (let i = 0; i < this._combinedMessages.length; i++) {
+ this._combinedMessages[i].expanded = exp;
+ this.notifyPath(`_combinedMessages.${i}.expanded`);
+ }
+ }
+ }
+
+ _computeExpandAllTitle(_expandAllState?: string) {
+ if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
+ return this.createTitle(
+ Shortcut.COLLAPSE_ALL_MESSAGES,
+ ShortcutSection.ACTIONS
+ );
+ }
+ if (_expandAllState === ExpandAllState.EXPAND_ALL) {
+ return this.createTitle(
+ Shortcut.EXPAND_ALL_MESSAGES,
+ ShortcutSection.ACTIONS
+ );
+ }
+ return '';
+ }
+
+ _highlightEl(el: HTMLElement) {
+ const highlightedEls = this.root!.querySelectorAll('.highlighted');
+ for (const highlightedEl of highlightedEls) {
+ highlightedEl.classList.remove('highlighted');
+ }
+ function handleAnimationEnd() {
+ el.removeEventListener('animationend', handleAnimationEnd);
+ el.classList.remove('highlighted');
+ }
+ el.addEventListener('animationend', handleAnimationEnd);
+ el.classList.add('highlighted');
+ }
+
+ handleExpandCollapse(expand: boolean) {
+ this._expandAllState = expand
+ ? ExpandAllState.COLLAPSE_ALL
+ : ExpandAllState.EXPAND_ALL;
+ this._updateExpandedStateOfAllMessages(expand);
+ }
+
+ _handleExpandCollapseTap(e: Event) {
+ e.preventDefault();
+ this.handleExpandCollapse(
+ this._expandAllState === ExpandAllState.EXPAND_ALL
+ );
+ }
+
+ _handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) {
+ this.scrollToMessage(e.detail.id);
+ }
+
+ _isVisibleShowAllActivityToggle(messages: CombinedMessage[] = []) {
+ return messages.some(m => !m.isImportant);
+ }
+
+ _computeHiddenEntriesCount(messages: CombinedMessage[] = []) {
+ return messages.filter(m => !m.isImportant).length;
+ }
+
+ /**
+ * This method is for reporting stats only.
+ */
+ _combinedMessagesChanged(combinedMessages?: CombinedMessage[]) {
+ if (combinedMessages) {
+ if (combinedMessages.length === 0) return;
+ const tags = combinedMessages.map(
+ message =>
+ message.tag || (message as FormattedReviewerUpdateInfo).type || 'none'
+ );
+ const tagsCounted = tags.reduce(
+ (acc, val) => {
+ acc[val] = (acc[val] || 0) + 1;
+ return acc;
+ },
+ {all: combinedMessages.length} as TagsCountReportInfo
+ );
+ this.reporting.reportInteraction('messages-count', tagsCounted);
+ }
+ }
+
+ /**
+ * Compute a mapping from label name to objects representing the minimum and
+ * maximum possible values for that label.
+ */
+ _computeLabelExtremes(
+ labelRecord: PolymerDeepPropertyChange<
+ LabelNameToInfoMap,
+ LabelNameToInfoMap
+ >
+ ) {
+ const extremes: {[lableName: string]: VotingRangeInfo} = {};
+ const labels = labelRecord.base;
+ if (!labels) {
+ return extremes;
+ }
+ for (const key of Object.keys(labels)) {
+ const range = getVotingRange(labels[key]);
+ if (range) {
+ extremes[key] = range;
+ }
+ }
+ return extremes;
+ }
+
+ /**
+ * Work around a issue on iOS when clicking turns into double tap
+ */
+ _onTapShowAllActivityToggle(e: Event) {
+ e.preventDefault();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-messages-list': GrMessagesList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
index 9c4ef04..d3a72072 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_html.ts
@@ -68,7 +68,7 @@
role="switch"
on-tap="_onTapShowAllActivityToggle"
></paper-toggle-button>
- <div id="showAllEntriesLabel">
+ <div id="showAllEntriesLabel" aria-hidden="true">
<span>Show all entries</span>
<span class="hiddenEntries" hidden$="[[_showAllActivity]]">
([[_computeHiddenEntriesCount(_combinedMessages)]] hidden)
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
index c3245fe..c27f9fd 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.js
@@ -19,7 +19,6 @@
import '../../diff/gr-comment-api/gr-comment-api.js';
import './gr-messages-list.js';
import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {TEST_ONLY} from './gr-messages-list.js';
import {MessageTag} from '../../../constants/constants.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
@@ -67,7 +66,7 @@
let commentApiWrapper;
const getMessages = function() {
- return dom(element.root).querySelectorAll('gr-message');
+ return element.root.querySelectorAll('gr-message');
};
const MESSAGE_ID_0 = '1234ccc949c6d482b061be6a28e10782abf0e7af';
@@ -232,7 +231,7 @@
const scrollToStub = sinon.stub(window, 'scrollTo');
const highlightStub = sinon.stub(element, '_highlightEl');
element.messages = generateRandomMessages(25);
- flushAsynchronousOperations();
+ flush();
assert.isFalse(scrollToStub.called);
assert.isFalse(highlightStub.called);
@@ -267,7 +266,7 @@
}
);
element.messages = messages;
- flushAsynchronousOperations();
+ flush();
const messageElements = getMessages();
assert.equal(messageElements.length, messages.length);
assert.deepEqual(messageElements[1].message, messages[1]);
@@ -286,7 +285,7 @@
},
];
element.messages = messages;
- flushAsynchronousOperations();
+ flush();
const messageElements = getMessages();
// threads
assert.equal(
@@ -414,7 +413,7 @@
const m2 = randomMessage(
{tag: MessageTag.TAG_NEW_WIP_PATCHSET, _revision_number: 2});
element.messages = [m1, m2];
- flushAsynchronousOperations();
+ flush();
assert.isFalse(m1.isImportant);
assert.isTrue(m2.isImportant);
});
@@ -428,7 +427,7 @@
id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
}];
element.messages = messages;
- flushAsynchronousOperations();
+ flush();
const messageEls = getMessages();
assert.equal(messageEls.length, 1);
assert.equal(messageEls[0].message.message, messages[0].message);
@@ -469,32 +468,32 @@
});
test('hide autogenerated button is not hidden', () => {
- const toggle = dom(element.root).querySelector('.showAllActivityToggle');
+ const toggle = element.root.querySelector('.showAllActivityToggle');
assert.isOk(toggle);
});
test('one unimportant message is hidden initially', () => {
- const displayedMsgs = dom(element.root).querySelectorAll('gr-message');
+ const displayedMsgs = element.root.querySelectorAll('gr-message');
assert.equal(displayedMsgs.length, 2);
});
test('unimportant messages hidden after toggle', () => {
element._showAllActivity = true;
- const toggle = dom(element.root).querySelector('.showAllActivityToggle');
+ const toggle = element.root.querySelector('.showAllActivityToggle');
assert.isOk(toggle);
MockInteractions.tap(toggle);
- flushAsynchronousOperations();
- const displayedMsgs = dom(element.root).querySelectorAll('gr-message');
+ flush();
+ const displayedMsgs = element.root.querySelectorAll('gr-message');
assert.equal(displayedMsgs.length, 2);
});
test('unimportant messages shown after toggle', () => {
element._showAllActivity = false;
- const toggle = dom(element.root).querySelector('.showAllActivityToggle');
+ const toggle = element.root.querySelector('.showAllActivityToggle');
assert.isOk(toggle);
MockInteractions.tap(toggle);
- flushAsynchronousOperations();
- const displayedMsgs = dom(element.root).querySelectorAll('gr-message');
+ flush();
+ const displayedMsgs = element.root.querySelectorAll('gr-message');
assert.equal(displayedMsgs.length, 3);
});
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
deleted file mode 100644
index d0613fc..0000000
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ /dev/null
@@ -1,407 +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-rest-api-interface/gr-rest-api-interface.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 '../../plugins/gr-endpoint-slot/gr-endpoint-slot.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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-related-changes-list_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {ChangeStatus} from '../../../constants/constants.js';
-import {patchNumEquals} from '../../../utils/patch-set-util.js';
-import {changeIsOpen} from '../../../utils/change-util.js';
-
-/**
- * @extends PolymerElement
- */
-class GrRelatedChangesList extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-related-changes-list'; }
- /**
- * Fired when a new section is loaded so that the change view can determine
- * a show more button is needed, sometimes before all the sections finish
- * loading.
- *
- * @event new-section-loaded
- */
-
- static get properties() {
- return {
- change: Object,
- hasParent: {
- type: Boolean,
- notify: true,
- value: false,
- },
- patchNum: String,
- parentChange: Object,
- hidden: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- loading: {
- type: Boolean,
- notify: true,
- },
- mergeable: Boolean,
- _connectedRevisions: {
- type: Array,
- computed: '_computeConnectedRevisions(change, patchNum, ' +
- '_relatedResponse.changes)',
- },
- /** @type {?} */
- _relatedResponse: {
- type: Object,
- value() { return {changes: []}; },
- },
- /** @type {?} */
- _submittedTogether: {
- type: Object,
- value() { return {changes: []}; },
- },
- _conflicts: {
- type: Array,
- value() { return []; },
- },
- _cherryPicks: {
- type: Array,
- value() { return []; },
- },
- _sameTopic: {
- type: Array,
- value() { return []; },
- },
- };
- }
-
- static get observers() {
- return [
- '_resultsChanged(_relatedResponse, _submittedTogether, ' +
- '_conflicts, _cherryPicks, _sameTopic)',
- ];
- }
-
- clear() {
- this.loading = true;
- this.hidden = true;
-
- this._relatedResponse = {changes: []};
- this._submittedTogether = {changes: []};
- this._conflicts = [];
- this._cherryPicks = [];
- this._sameTopic = [];
- }
-
- reload() {
- if (!this.change || !this.patchNum) {
- return Promise.resolve();
- }
- this.loading = true;
- const promises = [
- this._getRelatedChanges().then(response => {
- this._relatedResponse = response;
- this._fireReloadEvent();
- this.hasParent = this._calculateHasParent(this.change.change_id,
- response.changes);
- }),
- this._getSubmittedTogether().then(response => {
- this._submittedTogether = response;
- this._fireReloadEvent();
- }),
- this._getCherryPicks().then(response => {
- this._cherryPicks = response;
- this._fireReloadEvent();
- }),
- ];
-
- // Get conflicts if change is open and is mergeable.
- if (changeIsOpen(this.change) && this.mergeable) {
- promises.push(this._getConflicts().then(response => {
- // Because the server doesn't always return a response and the
- // template expects an array, always return an array.
- this._conflicts = response ? response : [];
- this._fireReloadEvent();
- }));
- }
-
- promises.push(this._getServerConfig().then(config => {
- if (this.change.topic && !config.change.submit_whole_topic) {
- return this._getChangesWithSameTopic().then(response => {
- this._sameTopic = response;
- });
- } else {
- this._sameTopic = [];
- }
- return this._sameTopic;
- }));
-
- return Promise.all(promises).then(() => {
- this.loading = false;
- });
- }
-
- _fireReloadEvent() {
- // The listener on the change computes height of the related changes
- // section, so they have to be rendered first, and inside a dom-repeat,
- // that requires a flush.
- flush();
- this.dispatchEvent(new CustomEvent('new-section-loaded'));
- }
-
- /**
- * Determines whether or not the given change has a parent change. If there
- * is a relation chain, and the change id is not the last item of the
- * relation chain, there is a parent.
- *
- * @param {number} currentChangeId
- * @param {!Array} relatedChanges
- * @return {boolean}
- */
- _calculateHasParent(currentChangeId, relatedChanges) {
- return relatedChanges.length > 0 &&
- relatedChanges[relatedChanges.length - 1].change_id !==
- currentChangeId;
- }
-
- _getRelatedChanges() {
- return this.$.restAPI.getRelatedChanges(this.change._number,
- this.patchNum);
- }
-
- _getSubmittedTogether() {
- return this.$.restAPI.getChangesSubmittedTogether(this.change._number);
- }
-
- _getServerConfig() {
- return this.$.restAPI.getConfig();
- }
-
- _getConflicts() {
- return this.$.restAPI.getChangeConflicts(this.change._number);
- }
-
- _getCherryPicks() {
- return this.$.restAPI.getChangeCherryPicks(this.change.project,
- this.change.change_id, this.change._number);
- }
-
- _getChangesWithSameTopic() {
- return this.$.restAPI.getChangesWithSameTopic(this.change.topic,
- this.change._number);
- }
-
- /**
- * @param {number} changeNum
- * @param {string} project
- * @param {number=} opt_patchNum
- * @return {string}
- */
- _computeChangeURL(changeNum, project, opt_patchNum) {
- return GerritNav.getUrlForChangeById(changeNum, project, opt_patchNum);
- }
-
- _computeChangeContainerClass(currentChange, relatedChange) {
- const classes = ['changeContainer'];
- if ([relatedChange, currentChange].includes(undefined)) {
- return classes;
- }
- if (this._changesEqual(relatedChange, currentChange)) {
- classes.push('thisChange');
- }
- return classes.join(' ');
- }
-
- /**
- * Do the given objects describe the same change? Compares the changes by
- * their numbers.
- *
- * @see /Documentation/rest-api-changes.html#change-info
- * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
- * @param {!Object} a Either ChangeInfo or RelatedChangeAndCommitInfo
- * @param {!Object} b Either ChangeInfo or RelatedChangeAndCommitInfo
- * @return {boolean}
- */
- _changesEqual(a, b) {
- const aNum = this._getChangeNumber(a);
- const bNum = this._getChangeNumber(b);
- return aNum === bNum;
- }
-
- /**
- * Get the change number from either a ChangeInfo (such as those included in
- * SubmittedTogetherInfo responses) or get the change number from a
- * RelatedChangeAndCommitInfo (such as those included in a
- * RelatedChangesInfo response).
- *
- * @see /Documentation/rest-api-changes.html#change-info
- * @see /Documentation/rest-api-changes.html#related-change-and-commit-info
- *
- * @param {!Object} change Either a ChangeInfo or a
- * RelatedChangeAndCommitInfo object.
- * @return {number}
- */
- _getChangeNumber(change) {
- // Default to 0 if change property is not defined.
- if (!change) return 0;
-
- if (change.hasOwnProperty('_change_number')) {
- return change._change_number;
- }
- return change._number;
- }
-
- _computeLinkClass(change) {
- const statuses = [];
- if (change.status == ChangeStatus.ABANDONED) {
- statuses.push('strikethrough');
- }
- if (change.submittable) {
- statuses.push('submittable');
- }
- return statuses.join(' ');
- }
-
- _computeChangeStatusClass(change) {
- const classes = ['status'];
- if (change._revision_number != change._current_revision_number) {
- classes.push('notCurrent');
- } else if (this._isIndirectAncestor(change)) {
- classes.push('indirectAncestor');
- } else if (change.submittable) {
- classes.push('submittable');
- } else if (change.status == ChangeStatus.NEW) {
- classes.push('hidden');
- }
- return classes.join(' ');
- }
-
- _computeChangeStatus(change) {
- switch (change.status) {
- case ChangeStatus.MERGED:
- return 'Merged';
- case ChangeStatus.ABANDONED:
- return 'Abandoned';
- }
- if (change._revision_number != change._current_revision_number) {
- return 'Not current';
- } else if (this._isIndirectAncestor(change)) {
- return 'Indirect ancestor';
- } else if (change.submittable) {
- return 'Submittable';
- }
- return '';
- }
-
- _resultsChanged(related, submittedTogether, conflicts,
- cherryPicks, sameTopic) {
- // Polymer 2: check for undefined
- if ([
- related,
- submittedTogether,
- conflicts,
- cherryPicks,
- sameTopic,
- ].includes(undefined)) {
- return;
- }
-
- const results = [
- related && related.changes,
- // If there are either visible or non-visible changes, we need a
- // non-empty list to fire the event and set visibility.
- submittedTogether && ((submittedTogether.changes || [])
- + (submittedTogether.non_visible_changes ? [{}] : [])),
- conflicts,
- cherryPicks,
- sameTopic,
- ];
- for (let i = 0; i < results.length; i++) {
- if (results[i] && results[i].length > 0) {
- this.hidden = false;
- this.dispatchEvent(new CustomEvent('update', {
- composed: true, bubbles: false,
- }));
- return;
- }
- }
- this.hidden = true;
- }
-
- _isIndirectAncestor(change) {
- return !this._connectedRevisions.includes(change.commit.commit);
- }
-
- _computeConnectedRevisions(change, patchNum, relatedChanges) {
- // Polymer 2: check for undefined
- if ([change, patchNum, relatedChanges].includes(undefined)) {
- return undefined;
- }
-
- const connected = [];
- let changeRevision;
- if (!change) { return []; }
- for (const rev in change.revisions) {
- if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
- changeRevision = rev;
- }
- }
- const commits = relatedChanges.map(c => c.commit);
- let pos = commits.length - 1;
-
- while (pos >= 0) {
- const commit = commits[pos].commit;
- connected.push(commit);
- if (commit == changeRevision) {
- break;
- }
- pos--;
- }
- while (pos >= 0) {
- for (let i = 0; i < commits[pos].parents.length; i++) {
- if (connected.includes(commits[pos].parents[i].commit)) {
- connected.push(commits[pos].commit);
- break;
- }
- }
- --pos;
- }
- return connected;
- }
-
- _computeSubmittedTogetherClass(submittedTogether) {
- if (!submittedTogether || (
- submittedTogether.changes.length === 0 &&
- !submittedTogether.non_visible_changes)) {
- return 'hidden';
- }
- return '';
- }
-
- _computeNonVisibleChangesNote(n) {
- const noun = n === 1 ? 'change' : 'changes';
- return `(+ ${n} non-visible ${noun})`;
- }
-}
-
-customElements.define(GrRelatedChangesList.is, GrRelatedChangesList);
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
new file mode 100644
index 0000000..f8a4af2
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.ts
@@ -0,0 +1,465 @@
+/**
+ * @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-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../plugins/gr-endpoint-slot/gr-endpoint-slot';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-related-changes-list_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {ChangeStatus} from '../../../constants/constants';
+import {patchNumEquals} from '../../../utils/patch-set-util';
+import {changeIsOpen} from '../../../utils/change-util';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ ChangeId,
+ ChangeInfo,
+ CommitId,
+ NumericChangeId,
+ PatchSetNum,
+ RelatedChangeAndCommitInfo,
+ RelatedChangesInfo,
+ RepoName,
+ SubmittedTogetherInfo,
+} from '../../../types/common';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+export interface GrRelatedChangesList {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+function getEmptySubmitTogetherInfo(): SubmittedTogetherInfo {
+ return {changes: [], non_visible_changes: 0};
+}
+
+function isChangeInfo(
+ x: ChangeInfo | RelatedChangeAndCommitInfo
+): x is ChangeInfo {
+ return (x as ChangeInfo)._number !== undefined;
+}
+
+@customElement('gr-related-changes-list')
+export class GrRelatedChangesList extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when a new section is loaded so that the change view can determine
+ * a show more button is needed, sometimes before all the sections finish
+ * loading.
+ *
+ * @event new-section-loaded
+ */
+
+ @property({type: Object})
+ change?: ParsedChangeInfo;
+
+ @property({type: Boolean, notify: true})
+ hasParent = false;
+
+ @property({type: String})
+ patchNum?: PatchSetNum;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ hidden = false;
+
+ @property({type: Boolean, notify: true})
+ loading?: boolean;
+
+ @property({type: Boolean})
+ mergeable?: boolean;
+
+ @property({
+ type: Array,
+ computed:
+ '_computeConnectedRevisions(change, patchNum, ' +
+ '_relatedResponse.changes)',
+ })
+ _connectedRevisions?: CommitId[];
+
+ @property({type: Object})
+ _relatedResponse: RelatedChangesInfo = {changes: []};
+
+ @property({type: Object})
+ _submittedTogether?: SubmittedTogetherInfo = getEmptySubmitTogetherInfo();
+
+ @property({type: Array})
+ _conflicts: ChangeInfo[] = [];
+
+ @property({type: Array})
+ _cherryPicks: ChangeInfo[] = [];
+
+ @property({type: Array})
+ _sameTopic?: ChangeInfo[] = [];
+
+ clear() {
+ this.loading = true;
+ this.hidden = true;
+
+ this._relatedResponse = {changes: []};
+ this._submittedTogether = getEmptySubmitTogetherInfo();
+ this._conflicts = [];
+ this._cherryPicks = [];
+ this._sameTopic = [];
+ }
+
+ reload() {
+ if (!this.change || !this.patchNum) {
+ return Promise.resolve();
+ }
+ const change = this.change;
+ this.loading = true;
+ const promises: Array<Promise<void>> = [
+ this.$.restAPI
+ .getRelatedChanges(change._number, this.patchNum)
+ .then(response => {
+ if (!response) {
+ throw new Error('getRelatedChanges returned undefined response');
+ }
+ this._relatedResponse = response;
+ this._fireReloadEvent();
+ this.hasParent = this._calculateHasParent(
+ change.change_id,
+ response.changes
+ );
+ }),
+ this.$.restAPI
+ .getChangesSubmittedTogether(change._number)
+ .then(response => {
+ this._submittedTogether = response;
+ this._fireReloadEvent();
+ }),
+ this.$.restAPI
+ .getChangeCherryPicks(change.project, change.change_id, change._number)
+ .then(response => {
+ this._cherryPicks = response || [];
+ this._fireReloadEvent();
+ }),
+ ];
+
+ // Get conflicts if change is open and is mergeable.
+ if (changeIsOpen(change) && this.mergeable) {
+ promises.push(
+ this.$.restAPI.getChangeConflicts(change._number).then(response => {
+ // Because the server doesn't always return a response and the
+ // template expects an array, always return an array.
+ this._conflicts = response ? response : [];
+ this._fireReloadEvent();
+ })
+ );
+ }
+
+ promises.push(
+ this._getServerConfig().then(config => {
+ if (change.topic) {
+ if (!config) {
+ throw new Error('_getServerConfig returned undefined ');
+ }
+ if (!config.change.submit_whole_topic) {
+ return this.$.restAPI
+ .getChangesWithSameTopic(change.topic, change._number)
+ .then(response => {
+ this._sameTopic = response;
+ });
+ }
+ }
+ this._sameTopic = [];
+ return Promise.resolve();
+ })
+ );
+
+ return Promise.all(promises).then(() => {
+ this.loading = false;
+ });
+ }
+
+ _fireReloadEvent() {
+ // The listener on the change computes height of the related changes
+ // section, so they have to be rendered first, and inside a dom-repeat,
+ // that requires a flush.
+ flush();
+ this.dispatchEvent(new CustomEvent('new-section-loaded'));
+ }
+
+ /**
+ * Determines whether or not the given change has a parent change. If there
+ * is a relation chain, and the change id is not the last item of the
+ * relation chain, there is a parent.
+ */
+ _calculateHasParent(
+ currentChangeId: ChangeId,
+ relatedChanges: RelatedChangeAndCommitInfo[]
+ ) {
+ return (
+ relatedChanges.length > 0 &&
+ relatedChanges[relatedChanges.length - 1].change_id !== currentChangeId
+ );
+ }
+
+ _getServerConfig() {
+ return this.$.restAPI.getConfig();
+ }
+
+ _computeChangeURL(
+ changeNum: NumericChangeId,
+ project: RepoName,
+ patchNum?: PatchSetNum
+ ) {
+ return GerritNav.getUrlForChangeById(changeNum, project, patchNum);
+ }
+
+ /**
+ * Do the given objects describe the same change? Compares the changes by
+ * their numbers.
+ */
+ _changesEqual(
+ a: ChangeInfo | RelatedChangeAndCommitInfo,
+ b: ChangeInfo | RelatedChangeAndCommitInfo
+ ) {
+ const aNum = this._getChangeNumber(a);
+ const bNum = this._getChangeNumber(b);
+ return aNum === bNum;
+ }
+
+ /**
+ * Get the change number from either a ChangeInfo (such as those included in
+ * SubmittedTogetherInfo responses) or get the change number from a
+ * RelatedChangeAndCommitInfo (such as those included in a
+ * RelatedChangesInfo response).
+ */
+ _getChangeNumber(change?: ChangeInfo | RelatedChangeAndCommitInfo) {
+ // Default to 0 if change property is not defined.
+ if (!change) return 0;
+
+ if (isChangeInfo(change)) {
+ return change._number;
+ }
+ return change._change_number;
+ }
+
+ _computeLinkClass(change: ParsedChangeInfo) {
+ const statuses = [];
+ if (change.status === ChangeStatus.ABANDONED) {
+ statuses.push('strikethrough');
+ }
+ if (change.submittable) {
+ statuses.push('submittable');
+ }
+ return statuses.join(' ');
+ }
+
+ _computeChangeStatusClass(change: RelatedChangeAndCommitInfo) {
+ const classes = ['status'];
+ if (change._revision_number !== change._current_revision_number) {
+ classes.push('notCurrent');
+ } else if (this._isIndirectAncestor(change)) {
+ classes.push('indirectAncestor');
+ } else if (change.submittable) {
+ classes.push('submittable');
+ } else if (change.status === ChangeStatus.NEW) {
+ classes.push('hidden');
+ }
+ return classes.join(' ');
+ }
+
+ _computeChangeStatus(change: RelatedChangeAndCommitInfo) {
+ switch (change.status) {
+ case ChangeStatus.MERGED:
+ return 'Merged';
+ case ChangeStatus.ABANDONED:
+ return 'Abandoned';
+ }
+ if (change._revision_number !== change._current_revision_number) {
+ return 'Not current';
+ } else if (this._isIndirectAncestor(change)) {
+ return 'Indirect ancestor';
+ } else if (change.submittable) {
+ return 'Submittable';
+ }
+ return '';
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ // We listen to `new-section-loaded` events to allow plugins to trigger
+ // visibility computations, if their content or visibility changed.
+ this.addEventListener('new-section-loaded', () =>
+ this._handleNewSectionLoaded()
+ );
+ }
+
+ _handleNewSectionLoaded() {
+ // A plugin sent a `new-section-loaded` event, so its visibility likely
+ // changed. Hence, we update our visibility if needed.
+ this._resultsChanged(
+ this._relatedResponse,
+ this._submittedTogether,
+ this._conflicts,
+ this._cherryPicks,
+ this._sameTopic
+ );
+ }
+
+ @observe(
+ '_relatedResponse',
+ '_submittedTogether',
+ '_conflicts',
+ '_cherryPicks',
+ '_sameTopic'
+ )
+ _resultsChanged(
+ related: RelatedChangesInfo,
+ submittedTogether: SubmittedTogetherInfo | undefined,
+ conflicts: ChangeInfo[],
+ cherryPicks: ChangeInfo[],
+ sameTopic?: ChangeInfo[]
+ ) {
+ if (!submittedTogether || !sameTopic) {
+ return;
+ }
+ const submittedTogetherChangesCount =
+ (submittedTogether.changes || []).length +
+ (submittedTogether.non_visible_changes || 0);
+ const results = [
+ related && related.changes,
+ // If there are either visible or non-visible changes, we need a
+ // non-empty list to fire the event and set visibility.
+ submittedTogetherChangesCount ? [{}] : [],
+ conflicts,
+ cherryPicks,
+ sameTopic,
+ ];
+ for (let i = 0; i < results.length; i++) {
+ if (results[i] && results[i].length > 0) {
+ this.hidden = false;
+ this.dispatchEvent(
+ new CustomEvent('update', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ return;
+ }
+ }
+
+ this._computeHidden();
+ }
+
+ _computeHidden() {
+ // None of the built-in change lists had elements. So all of them are
+ // hidden. But since plugins might have injected visible content, we need
+ // to check for that and stay visible if we find any such visible content.
+ // (We consider plugins visible except if it's main element has the hidden
+ // attribute set to true.)
+ const plugins = getPluginEndpoints().getDetails('related-changes-section');
+ this.hidden = !plugins.some(
+ plugin =>
+ !plugin.domHook ||
+ plugin.domHook.getAllAttached().some(instance => !instance.hidden)
+ );
+ }
+
+ _isIndirectAncestor(change: RelatedChangeAndCommitInfo) {
+ return (
+ this._connectedRevisions &&
+ !this._connectedRevisions.includes(change.commit.commit)
+ );
+ }
+
+ _computeConnectedRevisions(
+ change?: ParsedChangeInfo,
+ patchNum?: PatchSetNum,
+ relatedChanges?: RelatedChangeAndCommitInfo[]
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ change === undefined ||
+ patchNum === undefined ||
+ relatedChanges === undefined
+ ) {
+ return undefined;
+ }
+
+ const connected: CommitId[] = [];
+ let changeRevision;
+ if (!change) {
+ return [];
+ }
+ for (const rev in change.revisions) {
+ if (patchNumEquals(change.revisions[rev]._number, patchNum)) {
+ changeRevision = rev;
+ }
+ }
+ const commits = relatedChanges.map(c => c.commit);
+ let pos = commits.length - 1;
+
+ while (pos >= 0) {
+ const commit: CommitId = commits[pos].commit;
+ connected.push(commit);
+ // TODO(TS): Ensure that both (commit and changeRevision) are string and use === instead
+ // eslint-disable-next-line eqeqeq
+ if (commit == changeRevision) {
+ break;
+ }
+ pos--;
+ }
+ while (pos >= 0) {
+ for (let i = 0; i < commits[pos].parents.length; i++) {
+ if (connected.includes(commits[pos].parents[i].commit)) {
+ connected.push(commits[pos].commit);
+ break;
+ }
+ }
+ --pos;
+ }
+ return connected;
+ }
+
+ _computeSubmittedTogetherClass(submittedTogether?: SubmittedTogetherInfo) {
+ if (
+ !submittedTogether ||
+ (submittedTogether.changes.length === 0 &&
+ !submittedTogether.non_visible_changes)
+ ) {
+ return 'hidden';
+ }
+ return '';
+ }
+
+ _computeNonVisibleChangesNote(n: number) {
+ const noun = n === 1 ? 'change' : 'changes';
+ return `(+ ${n} non-visible ${noun})`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-related-changes-list': GrRelatedChangesList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
index 6fe8cea..d4cd0f6 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_html.ts
@@ -37,9 +37,8 @@
.changeContainer {
display: flex;
}
- .changeContainer.thisChange:before {
- content: '➔';
- width: 1.2em;
+ .arrowToCurrentChange {
+ position: absolute;
}
h4,
section div {
@@ -72,10 +71,8 @@
.indirectAncestor {
color: #33691e;
}
- .submittable {
- color: #1b5e20;
- }
.submittableCheck {
+ padding-left: var(--spacing-s);
color: var(--positive-green-text-color);
display: none;
}
@@ -107,9 +104,15 @@
items="[[_relatedResponse.changes]]"
as="related"
>
- <div
- class$="rightIndent [[_computeChangeContainerClass(change, related)]]"
- >
+ <template is="dom-if" if="[[_changesEqual(related, change)]]">
+ <span
+ role="img"
+ class="arrowToCurrentChange"
+ aria-label="Arrow marking current change"
+ >➔</span
+ >
+ </template>
+ <div class="rightIndent changeContainer">
<a
href$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]"
class$="[[_computeLinkClass(related)]]"
@@ -133,7 +136,15 @@
items="[[_submittedTogether.changes]]"
as="related"
>
- <div class$="[[_computeChangeContainerClass(change, related)]]">
+ <template is="dom-if" if="[[_changesEqual(related, change)]]">
+ <span
+ role="img"
+ class="arrowToCurrentChange"
+ aria-label="Arrow marking current change"
+ >➔</span
+ >
+ </template>
+ <div class="changeContainer">
<a
href$="[[_computeChangeURL(related._number, related.project)]]"
class$="[[_computeLinkClass(related)]]"
@@ -145,6 +156,8 @@
tabindex="-1"
title="Submittable"
class$="submittableCheck [[_computeLinkClass(related)]]"
+ role="img"
+ aria-label="Submittable"
>✓</span
>
</div>
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
index 4c164ba..3983c5a 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.js
@@ -18,8 +18,9 @@
import '../../../test/common-test-setup-karma.js';
import './gr-related-changes-list.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {resetPlugins} from '../../../test/test-utils.js';
const pluginApi = _testOnly_initGerritPluginApi();
@@ -208,19 +209,6 @@
]);
});
- test('_computeChangeContainerClass', () => {
- const change1 = {change_id: 123, _number: 0};
- const change2 = {change_id: 456, _change_number: 1};
- const change3 = {change_id: 123, _number: 2};
-
- assert.notEqual(element._computeChangeContainerClass(
- change1, change1).indexOf('thisChange'), -1);
- assert.equal(element._computeChangeContainerClass(
- change1, change2).indexOf('thisChange'), -1);
- assert.equal(element._computeChangeContainerClass(
- change1, change3).indexOf('thisChange'), -1);
- });
-
test('_changesEqual', () => {
const change1 = {change_id: 123, _number: 0};
const change2 = {change_id: 456, _number: 1};
@@ -249,13 +237,13 @@
};
element.mergeable = true;
element.addEventListener('new-section-loaded', loadedStub);
- sinon.stub(element, '_getRelatedChanges')
+ sinon.stub(element.$.restAPI, 'getRelatedChanges')
.returns(Promise.resolve({changes: []}));
- sinon.stub(element, '_getSubmittedTogether')
+ sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
.returns(Promise.resolve());
- sinon.stub(element, '_getCherryPicks')
+ sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
.returns(Promise.resolve());
- sinon.stub(element, '_getConflicts')
+ sinon.stub(element.$.restAPI, 'getChangeConflicts')
.returns(Promise.resolve());
return element.reload().then(() => {
@@ -263,19 +251,19 @@
});
});
- suite('_getConflicts resolves undefined', () => {
+ suite('getChangeConflicts resolves undefined', () => {
let element;
setup(() => {
element = basicFixture.instantiate();
- sinon.stub(element, '_getRelatedChanges')
+ sinon.stub(element.$.restAPI, 'getRelatedChanges')
.returns(Promise.resolve({changes: []}));
- sinon.stub(element, '_getSubmittedTogether')
+ sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
.returns(Promise.resolve());
- sinon.stub(element, '_getCherryPicks')
+ sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
.returns(Promise.resolve());
- sinon.stub(element, '_getConflicts')
+ sinon.stub(element.$.restAPI, 'getChangeConflicts')
.returns(Promise.resolve());
});
@@ -298,13 +286,13 @@
setup(() => {
element = basicFixture.instantiate();
- sinon.stub(element, '_getRelatedChanges')
+ sinon.stub(element.$.restAPI, 'getRelatedChanges')
.returns(Promise.resolve({changes: []}));
- sinon.stub(element, '_getSubmittedTogether')
+ sinon.stub(element.$.restAPI, 'getChangesSubmittedTogether')
.returns(Promise.resolve());
- sinon.stub(element, '_getCherryPicks')
+ sinon.stub(element.$.restAPI, 'getChangeCherryPicks')
.returns(Promise.resolve());
- conflictsStub = sinon.stub(element, '_getConflicts')
+ conflictsStub = sinon.stub(element.$.restAPI, 'getChangeConflicts')
.returns(Promise.resolve());
});
@@ -517,13 +505,13 @@
});
test('no submitted together changes', () => {
- flushAsynchronousOperations();
+ flush();
assert.include(element.$.submittedTogether.className, 'hidden');
});
test('no non-visible submitted together changes', () => {
element._submittedTogether = {changes: [change]};
- flushAsynchronousOperations();
+ flush();
assert.notInclude(element.$.submittedTogether.className, 'hidden');
assert.isNull(element.shadowRoot
.querySelector('.note'));
@@ -532,7 +520,7 @@
test('no visible submitted together changes', () => {
// Technically this should never happen, but worth asserting the logic.
element._submittedTogether = {changes: [], non_visible_changes: 1};
- flushAsynchronousOperations();
+ flush();
assert.notInclude(element.$.submittedTogether.className, 'hidden');
assert.isNotNull(element.shadowRoot
.querySelector('.note'));
@@ -544,7 +532,7 @@
test('visible and non-visible submitted together changes', () => {
element._submittedTogether = {changes: [change], non_visible_changes: 2};
- flushAsynchronousOperations();
+ flush();
assert.notInclude(element.$.submittedTogether.className, 'hidden');
assert.isNotNull(element.shadowRoot
.querySelector('.note'));
@@ -554,26 +542,99 @@
'(+ 2 non-visible changes)');
});
});
+});
- suite('plugin endpoints', () => {
- test('endpoint params', done => {
- element.change = {labels: {}};
- let hookEl;
- let plugin;
- pluginApi.install(
- p => {
- plugin = p;
- plugin.hook('related-changes-section').getLastAttached()
- .then(el => hookEl = el);
- },
- '0.1',
- 'http://some/plugins/url.html');
- pluginLoader.loadPlugins([]);
- flush(() => {
- assert.strictEqual(hookEl.plugin, plugin);
- assert.strictEqual(hookEl.change, element.change);
- done();
- });
+suite('gr-related-changes-list plugin tests', () => {
+ let element;
+
+ setup(() => {
+ resetPlugins();
+ element = basicFixture.instantiate();
+ });
+
+ teardown(() => {
+ resetPlugins();
+ });
+
+ test('endpoint params', done => {
+ element.change = {labels: {}};
+ let hookEl;
+ let plugin;
+ pluginApi.install(
+ p => {
+ plugin = p;
+ plugin.hook('related-changes-section').getLastAttached()
+ .then(el => hookEl = el);
+ },
+ '0.1',
+ 'http://some/plugins/url1.html');
+ getPluginLoader().loadPlugins([]);
+ flush(() => {
+ assert.strictEqual(hookEl.plugin, plugin);
+ assert.strictEqual(hookEl.change, element.change);
+ done();
+ });
+ });
+
+ test('hiding and unhiding', done => {
+ element.change = {labels: {}};
+ let hookEl;
+ let plugin;
+
+ // No changes, and no plugin. The element is still hidden.
+ element._resultsChanged({}, {}, [], [], []);
+ assert.isTrue(element.hidden);
+ pluginApi.install(
+ p => {
+ plugin = p;
+ plugin.hook('related-changes-section').getLastAttached()
+ .then(el => hookEl = el);
+ },
+ '0.1',
+ 'http://some/plugins/url2.html');
+ getPluginLoader().loadPlugins([]);
+ flush(() => {
+ // No changes, and plugin without hidden attribute. So it's visible.
+ element._resultsChanged({}, {}, [], [], []);
+ assert.isFalse(element.hidden);
+
+ // No changes, but plugin with true hidden attribute. So it's invisible.
+ hookEl.hidden = true;
+
+ element._resultsChanged({}, {}, [], [], []);
+ assert.isTrue(element.hidden);
+
+ // No changes, and plugin with false hidden attribute. So it's visible.
+ hookEl.hidden = false;
+ element._resultsChanged({}, {}, [], [], []);
+ assert.isFalse(element.hidden);
+
+ // Hiding triggered by plugin itself
+ hookEl.hidden = true;
+ hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
+ composed: true, bubbles: true,
+ }));
+ assert.isTrue(element.hidden);
+
+ // Unhiding triggered by plugin itself
+ hookEl.hidden = false;
+ hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
+ composed: true, bubbles: true,
+ }));
+ assert.isFalse(element.hidden);
+
+ // Hiding plugin keeps list visible, if there are changes
+ hookEl.hidden = false;
+ element._sameTopic = ['test'];
+ element._resultsChanged({}, {}, [], [], ['test']);
+ assert.isFalse(element.hidden);
+ hookEl.hidden = true;
+ hookEl.dispatchEvent(new CustomEvent('new-section-loaded', {
+ composed: true, bubbles: true,
+ }));
+ assert.isFalse(element.hidden);
+
+ done();
});
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
index 56612ff..8a4b1f6 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.js
@@ -19,7 +19,7 @@
import {resetPlugins} from '../../../test/test-utils.js';
import './gr-reply-dialog.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
const basicFixture = fixtureFromElement('gr-reply-dialog');
const pluginApi = _testOnly_initGerritPluginApi();
@@ -81,7 +81,7 @@
element = basicFixture.instantiate();
setupElement(element);
// Allow the elements created by dom-repeat to be stamped.
- flushAsynchronousOperations();
+ flush();
});
teardown(() => {
@@ -98,7 +98,7 @@
MockInteractions.tap(element.shadowRoot
.querySelector('gr-button.send'));
assert.isFalse(sendStub.called);
- flushAsynchronousOperations();
+ flush();
element.$.ccs.$.entry.setText('test@test.test');
MockInteractions.tap(element.shadowRoot
@@ -122,20 +122,21 @@
}, null, 'http://test.com/plugins/lgtm.js');
element = basicFixture.instantiate();
setupElement(element);
- pluginLoader.loadPlugins([]);
- pluginLoader.awaitPluginsLoaded().then(() => {
- flush(() => {
- const textarea = element.$.textarea.getNativeTextarea();
- textarea.value = 'LGTM';
- textarea.dispatchEvent(new CustomEvent(
- 'input', {bubbles: true, composed: true}));
- const labelScoreRows = dom(element.$.labelScores.root)
- .querySelector('gr-label-score-row[name="Code-Review"]');
- const selectedBtn = dom(labelScoreRows.root)
- .querySelector('gr-button[data-value="+1"].iron-selected');
- assert.isOk(selectedBtn);
- done();
- });
- });
+ getPluginLoader().loadPlugins([]);
+ getPluginLoader().awaitPluginsLoaded()
+ .then(() => {
+ flush(() => {
+ const textarea = element.$.textarea.getNativeTextarea();
+ textarea.value = 'LGTM';
+ textarea.dispatchEvent(new CustomEvent(
+ 'input', {bubbles: true, composed: true}));
+ const labelScoreRows = dom(element.$.labelScores.root)
+ .querySelector('gr-label-score-row[name="Code-Review"]');
+ const selectedBtn = dom(labelScoreRows.root)
+ .querySelector('gr-button[data-value="+1"].iron-selected');
+ assert.isOk(selectedBtn);
+ done();
+ });
+ });
});
});
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
deleted file mode 100644
index f4beeea..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ /dev/null
@@ -1,1120 +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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-textarea/gr-textarea.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-formatted-text/gr-formatted-text.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-storage/gr-storage.js';
-import '../../shared/gr-account-list/gr-account-list.js';
-import '../gr-label-scores/gr-label-scores.js';
-import '../gr-thread-list/gr-thread-list.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-reply-dialog_html.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {appContext} from '../../../services/app-context.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {ExperimentIds} from '../../../services/flags.js';
-import {fetchChangeUpdates} from '../../../utils/patch-set-util.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
-
-const FocusTarget = {
- ANY: 'any',
- BODY: 'body',
- CCS: 'cc',
- REVIEWERS: 'reviewers',
-};
-
-const ReviewerTypes = {
- REVIEWER: 'REVIEWER',
- CC: 'CC',
-};
-
-const LatestPatchState = {
- LATEST: 'latest',
- CHECKING: 'checking',
- NOT_LATEST: 'not-latest',
-};
-
-const ButtonLabels = {
- START_REVIEW: 'Start review',
- SEND: 'Send',
-};
-
-const ButtonTooltips = {
- SAVE: 'Save but do not send notification or change review state',
- START_REVIEW: 'Mark as ready for review and send reply',
- SEND: 'Send reply',
-};
-
-const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
-
-const SEND_REPLY_TIMING_LABEL = 'SendReply';
-
-/**
- * @extends PolymerElement
- */
-class GrReplyDialog extends KeyboardShortcutMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-reply-dialog'; }
- /**
- * Fired when a reply is successfully sent.
- *
- * @event send
- */
-
- /**
- * Fired when the user presses the cancel button.
- *
- * @event cancel
- */
-
- /**
- * Fired when the main textarea's value changes, which may have triggered
- * a change in size for the dialog.
- *
- * @event autogrow
- */
-
- /**
- * Fires to show an alert when a send is attempted on the non-latest patch.
- *
- * @event show-alert
- */
-
- /**
- * Fires when the reply dialog believes that the server side diff drafts
- * have been updated and need to be refreshed.
- *
- * @event comment-refresh
- */
-
- /**
- * Fires when the state of the send button (enabled/disabled) changes.
- *
- * @event send-disabled-changed
- */
-
- constructor() {
- super();
- this.FocusTarget = FocusTarget;
- this.reporting = appContext.reportingService;
- this.flagsService = appContext.flagsService;
- }
-
- static get properties() {
- return {
- /**
- * @type {{ _number: number, removable_reviewers: Array }}
- */
- change: Object,
- patchNum: String,
- canBeStarted: {
- type: Boolean,
- value: false,
- },
- disabled: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- draft: {
- type: String,
- value: '',
- observer: '_draftChanged',
- },
- quote: {
- type: String,
- value: '',
- },
- /** @type {!Function} */
- filterReviewerSuggestion: {
- type: Function,
- value() {
- return this._filterReviewerSuggestionGenerator(false);
- },
- },
- /** @type {!Function} */
- filterCCSuggestion: {
- type: Function,
- value() {
- return this._filterReviewerSuggestionGenerator(true);
- },
- },
- permittedLabels: Object,
- /**
- * @type {{ commentlinks: Array }}
- */
- projectConfig: Object,
- serverConfig: Object,
- knownLatestState: String,
- underReview: {
- type: Boolean,
- value: true,
- },
-
- _account: Object,
- _ccs: Array,
- /** @type {?Object} */
- _ccPendingConfirmation: {
- type: Object,
- observer: '_reviewerPendingConfirmationUpdated',
- },
- _messagePlaceholder: {
- type: String,
- computed: '_computeMessagePlaceholder(canBeStarted)',
- },
- _owner: Object,
- /** @type {?} */
- _pendingConfirmationDetails: Object,
- _includeComments: {
- type: Boolean,
- value: true,
- },
- _reviewers: Array,
- /** @type {?Object} */
- _reviewerPendingConfirmation: {
- type: Object,
- observer: '_reviewerPendingConfirmationUpdated',
- },
- _previewFormatting: {
- type: Boolean,
- value: false,
- observer: '_handleHeightChanged',
- },
- _reviewersPendingRemove: {
- type: Object,
- value: {
- CC: [],
- REVIEWER: [],
- },
- },
- _sendButtonLabel: {
- type: String,
- computed: '_computeSendButtonLabel(canBeStarted)',
- },
- _savingComments: Boolean,
- _reviewersMutated: {
- type: Boolean,
- value: false,
- },
- _labelsChanged: {
- type: Boolean,
- value: false,
- },
- _saveTooltip: {
- type: String,
- value: ButtonTooltips.SAVE,
- readOnly: true,
- },
- _pluginMessage: {
- type: String,
- value: '',
- },
- _commentEditing: {
- type: Boolean,
- value: false,
- },
- /**
- * Is the UI in the state where the user individually modifies attention
- * set entries?
- */
- _attentionModified: {
- type: Boolean,
- value: false,
- },
- /**
- * Set of account IDs that currently constitutes the attention set, read
- * from change.attention_set. Will be updated by the
- * _computeNewAttention() observer.
- */
- _currentAttentionSet: {
- type: Object,
- value: () => new Set(),
- },
- /**
- * Set of account IDs that should constitute the attention set after
- * publishing the votes/comments. Will be initialized with a default (that
- * matches the default rules that the backend would also apply) by the
- * _computeNewAttention(_account, _reviewers, change) observer.
- */
- _newAttentionSet: {
- type: Object,
- value: () => new Set(),
- },
- _sendDisabled: {
- type: Boolean,
- computed: '_computeSendButtonDisabled(canBeStarted, ' +
- 'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
- '_includeComments, disabled, _commentEditing, _attentionModified)',
- observer: '_sendDisabledChanged',
- },
- draftCommentThreads: {
- type: Array,
- observer: '_handleHeightChanged',
- },
- // Track if the message typed in the reply dialog will be created as a
- // resolved/unresolved patchset level comment
- _isResolvedPatchsetLevelComment: {
- type: Boolean,
- value: true,
- },
-
- /**
- * A copy of added reviewers, a new copy is created when any change
- * made to the reviewers.
- */
- _allReviewers: {
- type: Array,
- computed: '_computeAllReviewers(_reviewers.*)',
- },
- };
- }
-
- get keyBindings() {
- return {
- 'esc': '_handleEscKey',
- 'ctrl+enter meta+enter': '_handleEnterKey',
- };
- }
-
- static get observers() {
- return [
- '_changeUpdated(change.reviewers.*, change.owner)',
- '_ccsChanged(_ccs.splices)',
- '_reviewersChanged(_reviewers.splices)',
- '_computeNewAttention(_account, _reviewers, change)',
- ];
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getAccount().then(account => {
- this._account = account || {};
- });
-
- this.addEventListener('comment-editing-changed', e => {
- this._commentEditing = e.detail;
- });
-
- // Plugins on reply-reviewers endpoint can take advantage of these
- // events to add / remove reviewers
-
- this.addEventListener('add-reviewer', e => {
- // Only support account type, see more from:
- // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
- this.$.reviewers.addAccountItem({account: e.detail.reviewer});
- });
-
- this.addEventListener('remove-reviewer', e => {
- this.$.reviewers.removeAccount(e.detail.reviewer);
- });
- }
-
- /** @override */
- ready() {
- super.ready();
- this._isPatchsetCommentsExperimentEnabled = this.flagsService
- .isEnabled(ExperimentIds.PATCHSET_COMMENTS);
- this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
- }
-
- open(opt_focusTarget) {
- this.knownLatestState = LatestPatchState.CHECKING;
- fetchChangeUpdates(this.change, this.$.restAPI)
- .then(result => {
- this.knownLatestState = result.isLatest ?
- LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
- });
-
- this._focusOn(opt_focusTarget);
- if (this.quote && this.quote.length) {
- // If a reply quote has been provided, use it and clear the property.
- this.draft = this.quote;
- this.quote = '';
- } else {
- // Otherwise, check for an unsaved draft in localstorage.
- this.draft = this._loadStoredDraft();
- }
- if (this.$.restAPI.hasPendingDiffDrafts()) {
- this._savingComments = true;
- this.$.restAPI.awaitPendingDiffDrafts().then(() => {
- this.dispatchEvent(new CustomEvent('comment-refresh', {
- composed: true, bubbles: true,
- }));
- this._savingComments = false;
- });
- }
- }
-
- focus() {
- this._focusOn(FocusTarget.ANY);
- }
-
- getFocusStops() {
- const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
- return {
- start: this.$.reviewers.focusStart,
- end,
- };
- }
-
- setLabelValue(label, value) {
- const selectorEl =
- this.$.labelScores.shadowRoot
- .querySelector(`gr-label-score-row[name="${label}"]`);
- if (!selectorEl) { return; }
- selectorEl.setSelectedValue(value);
- }
-
- getLabelValue(label) {
- const selectorEl =
- this.$.labelScores.shadowRoot
- .querySelector(`gr-label-score-row[name="${label}"]`);
- if (!selectorEl) { return null; }
-
- return selectorEl.selectedValue;
- }
-
- _handleEscKey(e) {
- this.cancel();
- }
-
- _handleEnterKey(e) {
- this._submit();
- }
-
- _ccsChanged(splices) {
- this._reviewerTypeChanged(splices, ReviewerTypes.CC);
- }
-
- _reviewersChanged(splices) {
- this._reviewerTypeChanged(splices, ReviewerTypes.REVIEWER);
- }
-
- _reviewerTypeChanged(splices, reviewerType) {
- if (splices && splices.indexSplices) {
- this._reviewersMutated = true;
- this._processReviewerChange(splices.indexSplices,
- reviewerType);
- let key;
- let index;
- let account;
- // Remove any accounts that already exist as a CC for reviewer
- // or vice versa.
- const isReviewer = ReviewerTypes.REVIEWER === reviewerType;
- for (const splice of splices.indexSplices) {
- for (let i = 0; i < splice.addedCount; i++) {
- account = splice.object[splice.index + i];
- key = this._accountOrGroupKey(account);
- const array = isReviewer ? this._ccs : this._reviewers;
- index = array.findIndex(
- account => this._accountOrGroupKey(account) === key);
- if (index >= 0) {
- this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
- const moveFrom = isReviewer ? 'CC' : 'reviewer';
- const moveTo = isReviewer ? 'reviewer' : 'CC';
- const message = (account.name || account.email || key) +
- ` moved from ${moveFrom} to ${moveTo}.`;
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message},
- composed: true, bubbles: true,
- }));
- }
- }
- }
- }
- }
-
- _processReviewerChange(indexSplices, type) {
- for (const splice of indexSplices) {
- for (const account of splice.removed) {
- if (!this._reviewersPendingRemove[type]) {
- console.err('Invalid type ' + type + ' for reviewer.');
- return;
- }
- this._reviewersPendingRemove[type].push(account);
- }
- }
- }
-
- /**
- * Resets the state of the _reviewersPendingRemove object, and removes
- * accounts if necessary.
- *
- * @param {boolean} isCancel true if the action is a cancel.
- * @param {Object=} opt_accountIdsTransferred map of account IDs that must
- * not be removed, because they have been readded in another state.
- */
- _purgeReviewersPendingRemove(isCancel, opt_accountIdsTransferred) {
- let reviewerArr;
- const keep = opt_accountIdsTransferred || {};
- for (const type in this._reviewersPendingRemove) {
- if (this._reviewersPendingRemove.hasOwnProperty(type)) {
- if (!isCancel) {
- reviewerArr = this._reviewersPendingRemove[type];
- for (let i = 0; i < reviewerArr.length; i++) {
- if (!keep[reviewerArr[i]._account_id]) {
- this._removeAccount(reviewerArr[i], type);
- }
- }
- }
- this._reviewersPendingRemove[type] = [];
- }
- }
- }
-
- /**
- * Removes an account from the change, both on the backend and the client.
- * Does nothing if the account is a pending addition.
- *
- * @param {!Object} account
- * @param {string} type
- */
- _removeAccount(account, type) {
- if (account._pendingAdd) { return; }
-
- return this.$.restAPI.removeChangeReviewer(this.change._number,
- account._account_id).then(response => {
- if (!response.ok) { return response; }
-
- const reviewers = this.change.reviewers[type] || [];
- for (let i = 0; i < reviewers.length; i++) {
- if (reviewers[i]._account_id == account._account_id) {
- this.splice(`change.reviewers.${type}`, i, 1);
- break;
- }
- }
- });
- }
-
- _mapReviewer(reviewer) {
- let reviewerId;
- let confirmed;
- if (reviewer.account) {
- reviewerId = reviewer.account._account_id || reviewer.account.email;
- } else if (reviewer.group) {
- reviewerId = reviewer.group.id;
- confirmed = reviewer.group.confirmed;
- }
- return {reviewer: reviewerId, confirmed};
- }
-
- send(includeComments, startReview) {
- this.reporting.time(SEND_REPLY_TIMING_LABEL);
- const labels = this.$.labelScores.getLabelValues();
-
- const reviewInput = {
- drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP',
- labels,
- };
-
- if (startReview) {
- reviewInput.ready = true;
- }
-
- if (this._attentionModified) {
- reviewInput.ignore_default_attention_set_rules = true;
- reviewInput.add_to_attention_set = [];
- for (const user of this._newAttentionSet) {
- if (!this._currentAttentionSet.has(user)) {
- reviewInput.add_to_attention_set.push({
- user,
- reason: 'manually added in reply dialog',
- });
- }
- }
- reviewInput.remove_from_attention_set = [];
- for (const user of this._currentAttentionSet) {
- if (!this._newAttentionSet.has(user)) {
- reviewInput.remove_from_attention_set.push({
- user,
- reason: 'manually removed in reply dialog',
- });
- }
- }
- }
- this.reportAttentionSetChanges(this._attentionModified,
- reviewInput.add_to_attention_set,
- reviewInput.remove_from_attention_set);
-
- if (this.draft != null) {
- if (this._isPatchsetCommentsExperimentEnabled) {
- const comment = {
- message: this.draft,
- unresolved: !this._isResolvedPatchsetLevelComment,
- };
- reviewInput.comments = {
- [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
- };
- } else {
- reviewInput.message = this.draft;
- }
- }
-
- const accountAdditions = {};
- reviewInput.reviewers = this.$.reviewers.additions().map(reviewer => {
- if (reviewer.account) {
- accountAdditions[reviewer.account._account_id] = true;
- }
- return this._mapReviewer(reviewer);
- });
- const ccsEl = this.$.ccs;
- if (ccsEl) {
- for (let reviewer of ccsEl.additions()) {
- if (reviewer.account) {
- accountAdditions[reviewer.account._account_id] = true;
- }
- reviewer = this._mapReviewer(reviewer);
- reviewer.state = 'CC';
- reviewInput.reviewers.push(reviewer);
- }
- }
-
- this.disabled = true;
-
- const errFn = this._handle400Error.bind(this);
- return this._saveReview(reviewInput, errFn)
- .then(response => {
- if (!response) {
- // Null or undefined response indicates that an error handler
- // took responsibility, so just return.
- return {};
- }
- if (!response.ok) {
- this.dispatchEvent(new CustomEvent('server-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- return {};
- }
-
- this.draft = '';
- this._includeComments = true;
- this.dispatchEvent(new CustomEvent('send', {
- composed: true, bubbles: false,
- }));
- return accountAdditions;
- })
- .then(result => {
- this.disabled = false;
- return result;
- })
- .catch(err => {
- this.disabled = false;
- throw err;
- });
- }
-
- _focusOn(section) {
- // Safeguard- always want to focus on something.
- if (!section || section === FocusTarget.ANY) {
- section = this._chooseFocusTarget();
- }
- if (section === FocusTarget.BODY) {
- const textarea = this.$.textarea;
- textarea.async(textarea.getNativeTextarea()
- .focus.bind(textarea.getNativeTextarea()));
- } else if (section === FocusTarget.REVIEWERS) {
- const reviewerEntry = this.$.reviewers.focusStart;
- reviewerEntry.async(reviewerEntry.focus);
- } else if (section === FocusTarget.CCS) {
- const ccEntry = this.$.ccs.focusStart;
- ccEntry.async(ccEntry.focus);
- }
- }
-
- _chooseFocusTarget() {
- // If we are the owner and the reviewers field is empty, focus on that.
- if (this._account && this.change && this.change.owner &&
- this._account._account_id === this.change.owner._account_id &&
- (!this._reviewers || this._reviewers.length === 0)) {
- return FocusTarget.REVIEWERS;
- }
-
- // Default to BODY.
- return FocusTarget.BODY;
- }
-
- _isOwner(account, change) {
- if (!account || !change || !change.owner) return false;
- return account._account_id === change.owner._account_id;
- }
-
- _handle400Error(response) {
- // A call to _saveReview could fail with a server error if erroneous
- // reviewers were requested. This is signalled with a 400 Bad Request
- // status. The default gr-rest-api-interface error handling would
- // result in a large JSON response body being displayed to the user in
- // the gr-error-manager toast.
- //
- // We can modify the error handling behavior by passing this function
- // through to restAPI as a custom error handling function. Since we're
- // short-circuiting restAPI we can do our own response parsing and fire
- // the server-error ourselves.
- //
- this.disabled = false;
-
- // Using response.clone() here, because getResponseObject() and
- // potentially the generic error handler will want to call text() on the
- // response object, which can only be done once per object.
- const jsonPromise = this.$.restAPI.getResponseObject(response.clone());
- return jsonPromise.then(result => {
- // Only perform custom error handling for 400s and a parseable
- // ReviewResult response.
- if (response.status === 400 && result) {
- const errors = [];
- for (const state of ['reviewers', 'ccs']) {
- if (!result.hasOwnProperty(state)) { continue; }
- for (const reviewer of Object.values(result[state])) {
- if (reviewer.error) {
- errors.push(reviewer.error);
- }
- }
- }
- response = {
- ok: false,
- status: response.status,
- text() { return Promise.resolve(errors.join(', ')); },
- };
- }
- this.dispatchEvent(new CustomEvent('server-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- return null; // Means that the error has been handled.
- });
- }
-
- _computeHideDraftList(draftCommentThreads) {
- return !draftCommentThreads || draftCommentThreads.length === 0;
- }
-
- _computeDraftsTitle(draftCommentThreads) {
- const total = draftCommentThreads ? draftCommentThreads.length : 0;
- if (total == 0) { return ''; }
- if (total == 1) { return '1 Draft'; }
- if (total > 1) { return total + ' Drafts'; }
- }
-
- _computeMessagePlaceholder(canBeStarted) {
- return canBeStarted ?
- 'Add a note for your reviewers...' :
- 'Say something nice...';
- }
-
- _changeUpdated(changeRecord, owner) {
- // Polymer 2: check for undefined
- if ([changeRecord, owner].includes(undefined)) {
- return;
- }
-
- this._rebuildReviewerArrays(changeRecord.base, owner);
- }
-
- _rebuildReviewerArrays(change, owner) {
- this._owner = owner;
-
- const reviewers = [];
- const ccs = [];
-
- for (const key in change) {
- if (change.hasOwnProperty(key)) {
- if (key !== 'REVIEWER' && key !== 'CC') {
- console.warn('unexpected reviewer state:', key);
- continue;
- }
- for (const entry of change[key]) {
- if (entry._account_id === owner._account_id) {
- continue;
- }
- switch (key) {
- case 'REVIEWER':
- reviewers.push(entry);
- break;
- case 'CC':
- ccs.push(entry);
- break;
- }
- }
- }
- }
-
- this._ccs = ccs;
- this._reviewers = reviewers;
- }
-
- _handleAttentionModify() {
- this._attentionModified = true;
- }
-
- _showAttentionSummary(config, attentionModified) {
- return this._isAttentionSetEnabled(config) && !attentionModified;
- }
-
- _showAttentionDetails(config, attentionModified) {
- return this._isAttentionSetEnabled(config) && attentionModified;
- }
-
- _isAttentionSetEnabled(config) {
- return !!config && !!config.change && config.change.enable_attention_set;
- }
-
- _handleAttentionClick(e) {
- const id = e.target.account._account_id;
- if (!id) return;
- if (this._newAttentionSet.has(id)) {
- this._newAttentionSet.delete(id);
- } else {
- this._newAttentionSet.add(id);
- }
- // Ensure that Polymer picks up the change.
- this._newAttentionSet = new Set(this._newAttentionSet);
- }
-
- _computeHasNewAttention(account, newAttention) {
- return newAttention && account && newAttention.has(account._account_id);
- }
-
- _computeNewAttention(user, reviewers, change) {
- if ([user, reviewers, change].includes(undefined)) {
- return;
- }
- this._attentionModified = false;
- this._currentAttentionSet =
- new Set(Object.keys(change.attention_set || {})
- .map(id => parseInt(id)));
- const newAttention = new Set(this._currentAttentionSet);
- if (this._isOwner(user, change)) {
- reviewers.forEach(r => newAttention.add(r._account_id));
- } else {
- if (change.owner) {
- newAttention.add(change.owner._account_id);
- }
- }
- if (user) newAttention.delete(user._account_id);
- this._newAttentionSet = newAttention;
- }
-
- _accountOrGroupKey(entry) {
- return entry.id || entry._account_id;
- }
-
- /**
- * Generates a function to filter out reviewer/CC entries. When isCCs is
- * truthy, the function filters out entries that already exist in this._ccs.
- * When falsy, the function filters entries that exist in this._reviewers.
- *
- * @param {boolean} isCCs
- * @return {!Function}
- */
- _filterReviewerSuggestionGenerator(isCCs) {
- return suggestion => {
- let entry;
- if (suggestion.account) {
- entry = suggestion.account;
- } else if (suggestion.group) {
- entry = suggestion.group;
- } else {
- console.warn(
- 'received suggestion that was neither account nor group:',
- suggestion);
- }
- if (entry._account_id === this._owner._account_id) {
- return false;
- }
-
- const key = this._accountOrGroupKey(entry);
- const finder = entry => this._accountOrGroupKey(entry) === key;
- if (isCCs) {
- return this._ccs.find(finder) === undefined;
- }
- return this._reviewers.find(finder) === undefined;
- };
- }
-
- _getAccount() {
- return this.$.restAPI.getAccount();
- }
-
- _cancelTapHandler(e) {
- e.preventDefault();
- this.cancel();
- }
-
- cancel() {
- this.dispatchEvent(new CustomEvent('cancel', {
- composed: true, bubbles: false,
- }));
- this.$.textarea.closeDropdown();
- this._purgeReviewersPendingRemove(true);
- this._rebuildReviewerArrays(this.change.reviewers, this._owner);
- }
-
- _saveClickHandler(e) {
- e.preventDefault();
- if (!this.$.ccs.submitEntryText()) {
- // Do not proceed with the save if there is an invalid email entry in
- // the text field of the CC entry.
- return;
- }
- this.send(this._includeComments, false).then(keepReviewers => {
- this._purgeReviewersPendingRemove(false, keepReviewers);
- });
- }
-
- _sendTapHandler(e) {
- e.preventDefault();
- this._submit();
- }
-
- _submit() {
- if (!this.$.ccs.submitEntryText()) {
- // Do not proceed with the send if there is an invalid email entry in
- // the text field of the CC entry.
- return;
- }
- if (this._sendDisabled) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- bubbles: true,
- composed: true,
- detail: {message: EMPTY_REPLY_MESSAGE},
- }));
- return;
- }
- return this.send(this._includeComments, this.canBeStarted)
- .then(keepReviewers => {
- this._purgeReviewersPendingRemove(false, keepReviewers);
- })
- .catch(err => {
- this.dispatchEvent(new CustomEvent('show-error', {
- bubbles: true,
- composed: true,
- detail: {message: `Error submitting review ${err}`},
- }));
- });
- }
-
- _saveReview(review, opt_errFn) {
- return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum,
- review, opt_errFn);
- }
-
- _reviewerPendingConfirmationUpdated(reviewer) {
- if (reviewer === null) {
- this.$.reviewerConfirmationOverlay.close();
- } else {
- this._pendingConfirmationDetails =
- this._ccPendingConfirmation || this._reviewerPendingConfirmation;
- this.$.reviewerConfirmationOverlay.open();
- }
- }
-
- _confirmPendingReviewer() {
- if (this._ccPendingConfirmation) {
- this.$.ccs.confirmGroup(this._ccPendingConfirmation.group);
- this._focusOn(FocusTarget.CCS);
- } else {
- this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
- this._focusOn(FocusTarget.REVIEWERS);
- }
- }
-
- _cancelPendingReviewer() {
- this._ccPendingConfirmation = null;
- this._reviewerPendingConfirmation = null;
-
- const target =
- this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS;
- this._focusOn(target);
- }
-
- _getStorageLocation() {
- // Tests trigger this method without setting change.
- if (!this.change) { return {}; }
- return {
- changeNum: this.change._number,
- patchNum: '@change',
- path: '@change',
- };
- }
-
- _loadStoredDraft() {
- const draft = this.$.storage.getDraftComment(this._getStorageLocation());
- return draft ? draft.message : '';
- }
-
- _handleAccountTextEntry() {
- // When either of the account entries has input added to the autocomplete,
- // it should trigger the save button to enable/
- //
- // Note: if the text is removed, the save button will not get disabled.
- this._reviewersMutated = true;
- }
-
- _draftChanged(newDraft, oldDraft) {
- this.debounce('store', () => {
- if (!newDraft.length && oldDraft) {
- // If the draft has been modified to be empty, then erase the storage
- // entry.
- this.$.storage.eraseDraftComment(this._getStorageLocation());
- } else if (newDraft.length) {
- this.$.storage.setDraftComment(this._getStorageLocation(),
- this.draft);
- }
- }, STORAGE_DEBOUNCE_INTERVAL_MS);
- }
-
- _handleHeightChanged(e) {
- this.dispatchEvent(new CustomEvent('autogrow', {
- composed: true, bubbles: true,
- }));
- }
-
- _handleLabelsChanged() {
- this._labelsChanged = Object.keys(
- this.$.labelScores.getLabelValues()).length !== 0;
- }
-
- _isState(knownLatestState, value) {
- return knownLatestState === value;
- }
-
- _reload() {
- // Load the current change without any patch range.
- GerritNav.navigateToChange(this.change);
- this.cancel();
- }
-
- _computeSendButtonLabel(canBeStarted) {
- return canBeStarted ? ButtonLabels.SEND + ' and ' +
- ButtonLabels.START_REVIEW : ButtonLabels.SEND;
- }
-
- _computeSendButtonTooltip(canBeStarted) {
- return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
- }
-
- _computeSavingLabelClass(savingComments) {
- return savingComments ? 'saving' : '';
- }
-
- _computeSendButtonDisabled(
- canBeStarted, draftCommentThreads, text, reviewersMutated,
- labelsChanged, includeComments, disabled, commentEditing,
- attentionModified) {
- // Polymer 2: check for undefined
- if ([
- canBeStarted,
- draftCommentThreads,
- text,
- reviewersMutated,
- labelsChanged,
- includeComments,
- disabled,
- commentEditing,
- attentionModified,
- ].includes(undefined)) {
- return undefined;
- }
- if (commentEditing || disabled) { return true; }
- if (canBeStarted === true) { return false; }
- const hasDrafts = includeComments && draftCommentThreads.length;
- return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged &&
- !attentionModified;
- }
-
- _computePatchSetWarning(patchNum, labelsChanged) {
- let str = `Patch ${patchNum} is not latest.`;
- if (labelsChanged) {
- str += ' Voting will have no effect.';
- }
- return str;
- }
-
- setPluginMessage(message) {
- this._pluginMessage = message;
- }
-
- _sendDisabledChanged(sendDisabled) {
- this.dispatchEvent(new CustomEvent('send-disabled-changed'));
- }
-
- _getReviewerSuggestionsProvider(change) {
- const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
- change._number, SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
- provider.init();
- return provider;
- }
-
- _getCcSuggestionsProvider(change) {
- const provider = GrReviewerSuggestionsProvider.create(this.$.restAPI,
- change._number, SUGGESTIONS_PROVIDERS_USERS_TYPES.CC);
- provider.init();
- return provider;
- }
-
- _onThreadListModified() {
- // TODO(taoalpha): this won't propogate the changes to the files
- // should consider replacing this with either top level events
- // or gerrit level events
-
- // emit the event so change-view can also get updated with latest changes
- this.dispatchEvent(new CustomEvent('comment-refresh', {
- composed: true, bubbles: true,
- }));
- }
-
- reportAttentionSetChanges(modified, addedSet, removedSet) {
- const actions = modified ? ['MODIFIED'] : ['NOT_MODIFIED'];
- const ownerId = (this.change && this.change.owner
- && this.change.owner._account_id) || -1;
- const selfId = (this._account && this._account._account_id) || -1;
- for (const added of (addedSet || [])) {
- const addedId = added.user;
- const self = addedId === selfId ? '_SELF' : '';
- const role = addedId === ownerId ? '_OWNER' : '_REVIEWER';
- actions.push('ADD' + self + role);
- }
- for (const removed of (removedSet || [])) {
- const removedId = removed.user;
- const self = removedId === selfId ? '_SELF' : '';
- const role = removedId === ownerId ? '_OWNER' : '_REVIEWER';
- actions.push('REMOVE' + self + role);
- }
- this.reporting.reportInteraction('attention-set-actions', {actions});
- }
-
- _computeAllReviewers() {
- return [...this._reviewers];
- }
-}
-
-customElements.define(GrReplyDialog.is, GrReplyDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
new file mode 100644
index 0000000..7b34263
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -0,0 +1,1546 @@
+/**
+ * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-textarea/gr-textarea';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-formatted-text/gr-formatted-text';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-storage/gr-storage';
+import '../../shared/gr-account-list/gr-account-list';
+import '../gr-label-scores/gr-label-scores';
+import '../gr-thread-list/gr-thread-list';
+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-reply-dialog_html';
+import {
+ GrReviewerSuggestionsProvider,
+ SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {appContext} from '../../../services/app-context';
+import {
+ ChangeStatus,
+ DraftsAction,
+ ReviewerState,
+ SpecialFilePath,
+} from '../../../constants/constants';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {fetchChangeUpdates} from '../../../utils/patch-set-util';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {accountKey, removeServiceUsers} from '../../../utils/account-util';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import {TargetElement} from '../../plugins/gr-plugin-types';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+ ErrorCallback,
+ RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {FixIronA11yAnnouncer} from '../../../types/types';
+import {
+ AccountAddition,
+ AccountInfoInput,
+ GrAccountList,
+ GroupInfoInput,
+ GroupObjectInput,
+ RawAccountInput,
+} from '../../shared/gr-account-list/gr-account-list';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {
+ AccountId,
+ AccountInfo,
+ AttentionSetInput,
+ ChangeInfo,
+ CommentInput,
+ EmailAddress,
+ GroupId,
+ GroupInfo,
+ isAccount,
+ isGroup,
+ isReviewerAccountSuggestion,
+ isReviewerGroupSuggestion,
+ LabelNameToValueMap,
+ ParsedJSON,
+ PatchSetNum,
+ ProjectInfo,
+ ReviewerInput,
+ Reviewers,
+ ReviewInput,
+ ReviewResult,
+ ServerInfo,
+ Suggestion,
+} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
+import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
+import {
+ PolymerDeepPropertyChange,
+ PolymerSplice,
+ PolymerSpliceChange,
+} from '@polymer/polymer/interfaces';
+import {
+ areSetsEqual,
+ assertNever,
+ containsAll,
+} from '../../../utils/common-util';
+import {CommentThread} from '../../../utils/comment-util';
+import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrStorage, StorageLocation} from '../../shared/gr-storage/gr-storage';
+import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
+import {CODE_REVIEW, getMaxAccounts} from '../../../utils/label-util';
+import {isUnresolved} from '../../../utils/comment-util';
+
+const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+
+export enum FocusTarget {
+ ANY = 'any',
+ BODY = 'body',
+ CCS = 'cc',
+ REVIEWERS = 'reviewers',
+}
+
+enum ReviewerType {
+ REVIEWER = 'REVIEWER',
+ CC = 'CC',
+}
+
+enum LatestPatchState {
+ LATEST = 'latest',
+ CHECKING = 'checking',
+ NOT_LATEST = 'not-latest',
+}
+
+const ButtonLabels = {
+ START_REVIEW: 'Start review',
+ SEND: 'Send',
+};
+
+const ButtonTooltips = {
+ SAVE: 'Save but do not send notification or change review state',
+ START_REVIEW: 'Mark as ready for review and send reply',
+ SEND: 'Send reply',
+};
+
+const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
+
+const SEND_REPLY_TIMING_LABEL = 'SendReply';
+
+interface PendingRemovals {
+ CC: (AccountInfoInput | GroupInfoInput)[];
+ REVIEWER: (AccountInfoInput | GroupInfoInput)[];
+}
+const PENDING_REMOVAL_KEYS: (keyof PendingRemovals)[] = [
+ ReviewerType.CC,
+ ReviewerType.REVIEWER,
+];
+
+export interface GrReplyDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ jsAPI: JsApiService & Element;
+ reviewers: GrAccountList;
+ ccs: GrAccountList;
+ cancelButton: GrButton;
+ sendButton: GrButton;
+ labelScores: GrLabelScores;
+ textarea: GrTextarea;
+ reviewerConfirmationOverlay: GrOverlay;
+ storage: GrStorage;
+ };
+}
+
+@customElement('gr-reply-dialog')
+export class GrReplyDialog extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when a reply is successfully sent.
+ *
+ * @event send
+ */
+
+ /**
+ * Fired when the user presses the cancel button.
+ *
+ * @event cancel
+ */
+
+ /**
+ * Fired when the main textarea's value changes, which may have triggered
+ * a change in size for the dialog.
+ *
+ * @event autogrow
+ */
+
+ /**
+ * Fires to show an alert when a send is attempted on the non-latest patch.
+ *
+ * @event show-alert
+ */
+
+ /**
+ * Fires when the reply dialog believes that the server side diff drafts
+ * have been updated and need to be refreshed.
+ *
+ * @event comment-refresh
+ */
+
+ /**
+ * Fires when the state of the send button (enabled/disabled) changes.
+ *
+ * @event send-disabled-changed
+ */
+
+ /**
+ * Fired to reload the change page.
+ *
+ * @event reload
+ */
+
+ FocusTarget = FocusTarget;
+
+ reporting = appContext.reportingService;
+
+ flagsService = appContext.flagsService;
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: String})
+ patchNum?: PatchSetNum;
+
+ @property({type: Boolean})
+ canBeStarted = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ disabled = false;
+
+ @property({
+ type: Boolean,
+ computed: '_computeHasDrafts(draft, draftCommentThreads.*)',
+ })
+ hasDrafts = false;
+
+ @property({type: String, observer: '_draftChanged'})
+ draft = '';
+
+ @property({type: String})
+ quote = '';
+
+ @property({type: Object})
+ filterReviewerSuggestion: (input: Suggestion) => boolean;
+
+ @property({type: Object})
+ filterCCSuggestion: (input: Suggestion) => boolean;
+
+ @property({type: Object})
+ permittedLabels?: LabelNameToValueMap;
+
+ @property({type: Object})
+ projectConfig?: ProjectInfo;
+
+ @property({type: Object})
+ serverConfig?: ServerInfo;
+
+ @property({type: String})
+ knownLatestState?: LatestPatchState;
+
+ @property({type: Boolean})
+ underReview = true;
+
+ @property({type: Object})
+ _account?: AccountInfo;
+
+ @property({type: Array})
+ _ccs: (AccountInfo | GroupInfo)[] = [];
+
+ @property({type: Number})
+ _attentionCcsCount = 0;
+
+ @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
+ _ccPendingConfirmation: GroupObjectInput | null = null;
+
+ @property({
+ type: String,
+ computed: '_computeMessagePlaceholder(canBeStarted)',
+ })
+ _messagePlaceholder?: string;
+
+ @property({type: Object})
+ _owner?: AccountInfo;
+
+ @property({type: Object, computed: '_computeUploader(change)'})
+ _uploader?: AccountInfo;
+
+ @property({type: Object})
+ _pendingConfirmationDetails: GroupObjectInput | null = null;
+
+ @property({type: Boolean})
+ _includeComments = true;
+
+ @property({type: Array})
+ _reviewers: (AccountInfo | GroupInfo)[] = [];
+
+ @property({type: Object, observer: '_reviewerPendingConfirmationUpdated'})
+ _reviewerPendingConfirmation: GroupObjectInput | null = null;
+
+ @property({type: Boolean, observer: '_handleHeightChanged'})
+ _previewFormatting = false;
+
+ @property({type: Object})
+ _reviewersPendingRemove: PendingRemovals = {
+ CC: [],
+ REVIEWER: [],
+ };
+
+ @property({type: String, computed: '_computeSendButtonLabel(canBeStarted)'})
+ _sendButtonLabel?: string;
+
+ @property({type: Boolean})
+ _savingComments = false;
+
+ @property({type: Boolean})
+ _reviewersMutated = false;
+
+ /**
+ * Signifies that the user has changed their vote on a label or (if they have
+ * not yet voted on a label) if a selected vote is different from the default
+ * vote.
+ */
+ @property({type: Boolean})
+ _labelsChanged = false;
+
+ @property({type: String})
+ readonly _saveTooltip: string = ButtonTooltips.SAVE;
+
+ @property({type: String})
+ _pluginMessage = '';
+
+ @property({type: Boolean})
+ _commentEditing = false;
+
+ @property({type: Boolean})
+ _attentionExpanded = false;
+
+ @property({type: Object})
+ _currentAttentionSet: Set<AccountId> = new Set();
+
+ @property({type: Object})
+ _newAttentionSet: Set<AccountId> = new Set();
+
+ @property({
+ type: Boolean,
+ computed:
+ '_computeSendButtonDisabled(canBeStarted, ' +
+ 'draftCommentThreads, draft, _reviewersMutated, _labelsChanged, ' +
+ '_includeComments, disabled, _commentEditing, _attentionExpanded, ' +
+ '_currentAttentionSet, _newAttentionSet)',
+ observer: '_sendDisabledChanged',
+ })
+ _sendDisabled?: boolean;
+
+ @property({type: Array, observer: '_handleHeightChanged'})
+ draftCommentThreads: CommentThread[] | undefined;
+
+ @property({type: Boolean})
+ _isResolvedPatchsetLevelComment = true;
+
+ @property({type: Array, computed: '_computeAllReviewers(_reviewers.*)'})
+ _allReviewers: (AccountInfo | GroupInfo)[] = [];
+
+ get keyBindings() {
+ return {
+ esc: '_handleEscKey',
+ 'ctrl+enter meta+enter': '_handleEnterKey',
+ };
+ }
+
+ _isPatchsetCommentsExperimentEnabled = false;
+
+ constructor() {
+ super();
+ this.filterReviewerSuggestion = this._filterReviewerSuggestionGenerator(
+ false
+ );
+ this.filterCCSuggestion = this._filterReviewerSuggestionGenerator(true);
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
+ this._getAccount().then(account => {
+ if (account) this._account = account;
+ });
+
+ this.addEventListener('comment-editing-changed', e => {
+ this._commentEditing = (e as CustomEvent).detail;
+ });
+
+ // Plugins on reply-reviewers endpoint can take advantage of these
+ // events to add / remove reviewers
+
+ this.addEventListener('add-reviewer', e => {
+ // Only support account type, see more from:
+ // elements/shared/gr-account-list/gr-account-list.js#addAccountItem
+ this.$.reviewers.addAccountItem({
+ account: (e as CustomEvent).detail.reviewer,
+ });
+ });
+
+ this.addEventListener('remove-reviewer', e => {
+ this.$.reviewers.removeAccount((e as CustomEvent).detail.reviewer);
+ });
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._isPatchsetCommentsExperimentEnabled = this.flagsService.isEnabled(
+ KnownExperimentId.PATCHSET_COMMENTS
+ );
+ this.$.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
+ }
+
+ open(focusTarget?: FocusTarget) {
+ if (!this.change) throw new Error('missing required change property');
+ this.knownLatestState = LatestPatchState.CHECKING;
+ fetchChangeUpdates(this.change, this.$.restAPI).then(result => {
+ this.knownLatestState = result.isLatest
+ ? LatestPatchState.LATEST
+ : LatestPatchState.NOT_LATEST;
+ });
+
+ this._focusOn(focusTarget);
+ if (this.quote && this.quote.length) {
+ // If a reply quote has been provided, use it and clear the property.
+ this.draft = this.quote;
+ this.quote = '';
+ } else {
+ // Otherwise, check for an unsaved draft in localstorage.
+ this.draft = this._loadStoredDraft();
+ }
+ if (this.$.restAPI.hasPendingDiffDrafts()) {
+ this._savingComments = true;
+ this.$.restAPI.awaitPendingDiffDrafts().then(() => {
+ this.dispatchEvent(
+ new CustomEvent('comment-refresh', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._savingComments = false;
+ });
+ }
+ }
+
+ _computeHasDrafts(
+ draft: string,
+ draftCommentThreads: PolymerDeepPropertyChange<
+ CommentThread[] | undefined,
+ CommentThread[] | undefined
+ >
+ ) {
+ if (draftCommentThreads.base === undefined) return false;
+ return draft.length > 0 || draftCommentThreads.base.length > 0;
+ }
+
+ focus() {
+ this._focusOn(FocusTarget.ANY);
+ }
+
+ getFocusStops() {
+ const end = this._sendDisabled ? this.$.cancelButton : this.$.sendButton;
+ return {
+ start: this.$.reviewers.focusStart,
+ end,
+ };
+ }
+
+ setLabelValue(label: string, value: string) {
+ const selectorEl = this.$.labelScores.shadowRoot?.querySelector(
+ `gr-label-score-row[name="${label}"]`
+ );
+ if (!selectorEl) {
+ return;
+ }
+ (selectorEl as GrLabelScoreRow).setSelectedValue(value);
+ }
+
+ getLabelValue(label: string) {
+ const selectorEl = this.$.labelScores.shadowRoot?.querySelector(
+ `gr-label-score-row[name="${label}"]`
+ );
+ if (!selectorEl) {
+ return null;
+ }
+
+ return (selectorEl as GrLabelScoreRow).selectedValue;
+ }
+
+ _handleEscKey() {
+ this.cancel();
+ }
+
+ _handleEnterKey() {
+ this._submit();
+ }
+
+ @observe('_ccs.splices')
+ _ccsChanged(splices: PolymerSpliceChange<AccountInfo[]>) {
+ this._reviewerTypeChanged(splices, ReviewerType.CC);
+ }
+
+ @observe('_reviewers.splices')
+ _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[]>) {
+ this._reviewerTypeChanged(splices, ReviewerType.REVIEWER);
+ }
+
+ _reviewerTypeChanged(
+ splices: PolymerSpliceChange<AccountInfo[]>,
+ reviewerType: ReviewerType
+ ) {
+ if (splices && splices.indexSplices) {
+ this._reviewersMutated = true;
+ this._processReviewerChange(splices.indexSplices, reviewerType);
+ let key: AccountId | EmailAddress | GroupId | undefined;
+ let index;
+ let account;
+ // Remove any accounts that already exist as a CC for reviewer
+ // or vice versa.
+ const isReviewer = ReviewerType.REVIEWER === reviewerType;
+ for (const splice of splices.indexSplices) {
+ for (let i = 0; i < splice.addedCount; i++) {
+ account = splice.object[splice.index + i];
+ key = this._accountOrGroupKey(account);
+ const array = isReviewer ? this._ccs : this._reviewers;
+ index = array.findIndex(
+ account => this._accountOrGroupKey(account) === key
+ );
+ if (index >= 0) {
+ this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
+ const moveFrom = isReviewer ? 'CC' : 'reviewer';
+ const moveTo = isReviewer ? 'reviewer' : 'CC';
+ const id = account.name || account.email || key;
+ const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ }
+ }
+ }
+ }
+
+ _processReviewerChange(
+ indexSplices: Array<PolymerSplice<AccountInfo[]>>,
+ type: ReviewerType
+ ) {
+ for (const splice of indexSplices) {
+ for (const account of splice.removed) {
+ if (!this._reviewersPendingRemove[type]) {
+ console.error('Invalid type ' + type + ' for reviewer.');
+ return;
+ }
+ this._reviewersPendingRemove[type].push(account);
+ }
+ }
+ }
+
+ /**
+ * Resets the state of the _reviewersPendingRemove object, and removes
+ * accounts if necessary.
+ *
+ * @param isCancel true if the action is a cancel.
+ * @param keep map of account IDs that must
+ * not be removed, because they have been readded in another state.
+ */
+ _purgeReviewersPendingRemove(
+ isCancel: boolean,
+ keep = new Map<AccountId | EmailAddress, boolean>()
+ ) {
+ let reviewerArr: (AccountInfoInput | GroupInfoInput)[];
+ for (const type of PENDING_REMOVAL_KEYS) {
+ if (!isCancel) {
+ reviewerArr = this._reviewersPendingRemove[type];
+ for (let i = 0; i < reviewerArr.length; i++) {
+ const reviewer = reviewerArr[i];
+ if (!isAccount(reviewer) || !keep.get(accountKey(reviewer))) {
+ this._removeAccount(reviewer, type as ReviewerType);
+ }
+ }
+ }
+ this._reviewersPendingRemove[type] = [];
+ }
+ }
+
+ /**
+ * Removes an account from the change, both on the backend and the client.
+ * Does nothing if the account is a pending addition.
+ */
+ _removeAccount(
+ account: AccountInfoInput | GroupInfoInput,
+ type: ReviewerType
+ ) {
+ if (!this.change) throw new Error('missing required change property');
+ if (account._pendingAdd || !isAccount(account)) {
+ return;
+ }
+
+ return this.$.restAPI
+ .removeChangeReviewer(this.change._number, accountKey(account))
+ .then((response?: Response) => {
+ if (!response?.ok || !this.change) return;
+
+ const reviewers = this.change.reviewers[type] || [];
+ for (let i = 0; i < reviewers.length; i++) {
+ if (reviewers[i]._account_id === account._account_id) {
+ this.splice(`change.reviewers.${type}`, i, 1);
+ break;
+ }
+ }
+ });
+ }
+
+ _mapReviewer(addition: AccountAddition): ReviewerInput {
+ if (addition.account) {
+ return {reviewer: accountKey(addition.account)};
+ }
+ if (addition.group) {
+ const reviewer = decodeURIComponent(addition.group.id) as GroupId;
+ const confirmed = addition.group.confirmed;
+ return {reviewer, confirmed};
+ }
+ throw new Error('Reviewer must be either an account or a group.');
+ }
+
+ send(
+ includeComments: boolean,
+ startReview: boolean
+ ): Promise<Map<AccountId | EmailAddress, boolean>> {
+ this.reporting.time(SEND_REPLY_TIMING_LABEL);
+ const labels = this.$.labelScores.getLabelValues();
+
+ const reviewInput: ReviewInput = {
+ drafts: includeComments
+ ? DraftsAction.PUBLISH_ALL_REVISIONS
+ : DraftsAction.KEEP,
+ labels,
+ };
+
+ if (startReview) {
+ reviewInput.ready = true;
+ }
+
+ if (isAttentionSetEnabled(this.serverConfig)) {
+ const selfName = getDisplayName(this.serverConfig, this._account);
+ const reason = `${selfName} replied on the change`;
+
+ reviewInput.ignore_automatic_attention_set_rules = true;
+ reviewInput.add_to_attention_set = [];
+ for (const user of this._newAttentionSet) {
+ if (!this._currentAttentionSet.has(user)) {
+ reviewInput.add_to_attention_set.push({user, reason});
+ }
+ }
+ reviewInput.remove_from_attention_set = [];
+ for (const user of this._currentAttentionSet) {
+ if (!this._newAttentionSet.has(user)) {
+ reviewInput.remove_from_attention_set.push({user, reason});
+ }
+ }
+ this.reportAttentionSetChanges(
+ this._attentionExpanded,
+ reviewInput.add_to_attention_set,
+ reviewInput.remove_from_attention_set
+ );
+ }
+
+ if (this.draft) {
+ if (this._isPatchsetCommentsExperimentEnabled) {
+ const comment: CommentInput = {
+ message: this.draft,
+ unresolved: !this._isResolvedPatchsetLevelComment,
+ };
+ reviewInput.comments = {
+ [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [comment],
+ };
+ } else {
+ reviewInput.message = this.draft;
+ }
+ }
+
+ const accountAdditions = new Map<AccountId | EmailAddress, boolean>();
+ reviewInput.reviewers = this.$.reviewers.additions().map(reviewer => {
+ if (reviewer.account) {
+ accountAdditions.set(accountKey(reviewer.account), true);
+ }
+ return this._mapReviewer(reviewer);
+ });
+ const ccsEl = this.$.ccs;
+ if (ccsEl) {
+ for (const addition of ccsEl.additions()) {
+ if (addition.account) {
+ accountAdditions.set(accountKey(addition.account), true);
+ }
+ const reviewer = this._mapReviewer(addition);
+ reviewer.state = ReviewerState.CC;
+ reviewInput.reviewers.push(reviewer);
+ }
+ }
+
+ this.disabled = true;
+
+ const errFn = (r?: Response | null) => this._handle400Error(r);
+ return this._saveReview(reviewInput, errFn)
+ .then(response => {
+ if (!response) {
+ // Null or undefined response indicates that an error handler
+ // took responsibility, so just return.
+ return new Map<AccountId | EmailAddress, boolean>();
+ }
+ if (!response.ok) {
+ this.dispatchEvent(
+ new CustomEvent('server-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return new Map<AccountId | EmailAddress, boolean>();
+ }
+
+ this.draft = '';
+ this._includeComments = true;
+ this.dispatchEvent(
+ new CustomEvent('send', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ this.fire('iron-announce', {text: 'Reply sent'}, {bubbles: true});
+ return accountAdditions;
+ })
+ .then(result => {
+ this.disabled = false;
+ return result;
+ })
+ .catch(err => {
+ this.disabled = false;
+ throw err;
+ });
+ }
+
+ _focusOn(section?: FocusTarget) {
+ // Safeguard- always want to focus on something.
+ if (!section || section === FocusTarget.ANY) {
+ section = this._chooseFocusTarget();
+ }
+ if (section === FocusTarget.BODY) {
+ const textarea = this.$.textarea;
+ textarea.async(() => textarea.getNativeTextarea().focus());
+ } else if (section === FocusTarget.REVIEWERS) {
+ const reviewerEntry = this.$.reviewers.focusStart;
+ reviewerEntry.async(() => reviewerEntry.focus());
+ } else if (section === FocusTarget.CCS) {
+ const ccEntry = this.$.ccs.focusStart;
+ ccEntry.async(() => ccEntry.focus());
+ }
+ }
+
+ _chooseFocusTarget() {
+ // If we are the owner and the reviewers field is empty, focus on that.
+ if (
+ this._account &&
+ this.change &&
+ this.change.owner &&
+ this._account._account_id === this.change.owner._account_id &&
+ (!this._reviewers || this._reviewers.length === 0)
+ ) {
+ return FocusTarget.REVIEWERS;
+ }
+
+ // Default to BODY.
+ return FocusTarget.BODY;
+ }
+
+ _isOwner(account?: AccountInfo, change?: ChangeInfo) {
+ if (!account || !change || !change.owner) return false;
+ return account._account_id === change.owner._account_id;
+ }
+
+ _handle400Error(response?: Response | null) {
+ if (!response) throw new Error('Reponse is empty.');
+ // A call to _saveReview could fail with a server error if erroneous
+ // reviewers were requested. This is signalled with a 400 Bad Request
+ // status. The default gr-rest-api-interface error handling would
+ // result in a large JSON response body being displayed to the user in
+ // the gr-error-manager toast.
+ //
+ // We can modify the error handling behavior by passing this function
+ // through to restAPI as a custom error handling function. Since we're
+ // short-circuiting restAPI we can do our own response parsing and fire
+ // the server-error ourselves.
+ //
+ this.disabled = false;
+
+ // Using response.clone() here, because getResponseObject() and
+ // potentially the generic error handler will want to call text() on the
+ // response object, which can only be done once per object.
+ const jsonPromise = this.$.restAPI.getResponseObject(response.clone());
+ return jsonPromise.then((parsed: ParsedJSON) => {
+ const result = parsed as ReviewResult;
+ // Only perform custom error handling for 400s and a parseable
+ // ReviewResult response.
+ if (response && response.status === 400 && result && result.reviewers) {
+ const errors: string[] = [];
+ const addReviewers = Object.values(result.reviewers);
+ addReviewers.forEach(r => errors.push(r.error ?? 'no explanation'));
+ response = {
+ ...response,
+ ok: false,
+ text: () => Promise.resolve(errors.join(', ')),
+ };
+ }
+ this.dispatchEvent(
+ new CustomEvent('server-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }
+
+ _computeHideDraftList(draftCommentThreads?: CommentThread[]) {
+ return !draftCommentThreads || draftCommentThreads.length === 0;
+ }
+
+ _computeDraftsTitle(draftCommentThreads?: CommentThread[]) {
+ const total = draftCommentThreads ? draftCommentThreads.length : 0;
+ if (total === 0) {
+ return '';
+ }
+ if (total === 1) {
+ return '1 Draft';
+ }
+ return `${total} Drafts`;
+ }
+
+ _computeMessagePlaceholder(canBeStarted: boolean) {
+ return canBeStarted
+ ? 'Add a note for your reviewers...'
+ : 'Say something nice...';
+ }
+
+ @observe('change.reviewers.*', 'change.owner')
+ _changeUpdated(
+ changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
+ owner: AccountInfo
+ ) {
+ if (changeRecord === undefined || owner === undefined) return;
+ this._rebuildReviewerArrays(changeRecord.base, owner);
+ }
+
+ _rebuildReviewerArrays(changeReviewers: Reviewers, owner: AccountInfo) {
+ this._owner = owner;
+
+ const reviewers = [];
+ const ccs = [];
+
+ if (changeReviewers) {
+ for (const key of Object.keys(changeReviewers)) {
+ if (key !== 'REVIEWER' && key !== 'CC') {
+ console.warn('unexpected reviewer state:', key);
+ continue;
+ }
+ if (!changeReviewers[key]) continue;
+ for (const entry of changeReviewers[key]!) {
+ if (entry._account_id === owner._account_id) {
+ continue;
+ }
+ switch (key) {
+ case 'REVIEWER':
+ reviewers.push(entry);
+ break;
+ case 'CC':
+ ccs.push(entry);
+ break;
+ }
+ }
+ }
+ }
+
+ this._ccs = ccs;
+ this._reviewers = reviewers;
+ }
+
+ _handleAttentionModify() {
+ this._attentionExpanded = true;
+ }
+
+ @observe('_attentionExpanded')
+ _onAttentionExpandedChange() {
+ // If the attention-detail section is expanded without dispatching this
+ // event, then the dialog may expand beyond the screen's bottom border.
+ this.dispatchEvent(
+ new CustomEvent('iron-resize', {composed: true, bubbles: true})
+ );
+ }
+
+ _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
+ return isAttentionSetEnabled(config) && !attentionExpanded;
+ }
+
+ _showAttentionDetails(config?: ServerInfo, attentionExpanded?: boolean) {
+ return isAttentionSetEnabled(config) && attentionExpanded;
+ }
+
+ _computeAttentionButtonTitle(sendDisabled?: boolean) {
+ return sendDisabled
+ ? 'Modify the attention set by adding a comment or use the account ' +
+ 'hovercard in the change page.'
+ : 'Edit attention set changes';
+ }
+
+ _handleAttentionClick(e: Event) {
+ const id = (e.target as GrAccountChip)?.account?._account_id;
+ if (!id) return;
+
+ const selfId = (this._account && this._account._account_id) || -1;
+ const ownerId =
+ (this.change && this.change.owner && this.change.owner._account_id) || -1;
+ const self = id === selfId ? '_SELF' : '';
+ const role = id === ownerId ? '_OWNER' : '_REVIEWER';
+
+ if (this._newAttentionSet.has(id)) {
+ this._newAttentionSet.delete(id);
+ this.reporting.reportInteraction('attention-set-chip', {
+ action: `REMOVE${self}${role}`,
+ });
+ } else {
+ this._newAttentionSet.add(id);
+ this.reporting.reportInteraction('attention-set-chip', {
+ action: `ADD${self}${role}`,
+ });
+ }
+
+ // Ensure that Polymer picks up the change.
+ this._newAttentionSet = new Set(this._newAttentionSet);
+ }
+
+ _computeHasNewAttention(
+ account?: AccountInfo,
+ newAttention?: Set<AccountId>
+ ) {
+ return (
+ newAttention &&
+ account &&
+ account._account_id &&
+ newAttention.has(account._account_id)
+ );
+ }
+
+ @observe(
+ '_account',
+ '_reviewers.*',
+ '_ccs.*',
+ 'change',
+ 'draftCommentThreads',
+ '_includeComments',
+ '_labelsChanged',
+ 'hasDrafts'
+ )
+ _computeNewAttention(
+ currentUser?: AccountInfo,
+ reviewers?: PolymerDeepPropertyChange<
+ AccountInfoInput[],
+ AccountInfoInput[]
+ >,
+ ccs?: PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>,
+ change?: ChangeInfo,
+ draftCommentThreads?: CommentThread[],
+ includeComments?: boolean,
+ _labelsChanged?: boolean,
+ hasDrafts?: boolean
+ ) {
+ if (
+ currentUser === undefined ||
+ currentUser._account_id === undefined ||
+ reviewers === undefined ||
+ ccs === undefined ||
+ change === undefined ||
+ draftCommentThreads === undefined ||
+ includeComments === undefined
+ ) {
+ return;
+ }
+ // The draft comments are only relevant for the attention set as long as the
+ // user actually plans to publish their drafts.
+ draftCommentThreads = includeComments ? draftCommentThreads : [];
+ const hasVote = !!_labelsChanged;
+ const isOwner = this._isOwner(currentUser, change);
+ const isUploader = this._uploader?._account_id === currentUser._account_id;
+ this._attentionCcsCount = removeServiceUsers(ccs.base).length;
+ this._currentAttentionSet = new Set(
+ Object.keys(change.attention_set || {}).map(id => Number(id) as AccountId)
+ );
+ const newAttention = new Set(this._currentAttentionSet);
+ if (change.status === ChangeStatus.NEW) {
+ // Add everyone that the user is replying to in a comment thread.
+ this._computeCommentAccounts(draftCommentThreads).forEach(id =>
+ newAttention.add(id)
+ );
+ // Remove the current user.
+ newAttention.delete(currentUser._account_id);
+ // Add all new reviewers, but not the current reviewer, if they are also
+ // sending a draft or a label vote.
+ const notIsReviewerAndHasDraftOrLabel = (r: AccountInfo) =>
+ !(r._account_id === currentUser._account_id && (hasDrafts || hasVote));
+ reviewers.base
+ .filter(r => r._pendingAdd && r._account_id)
+ .filter(notIsReviewerAndHasDraftOrLabel)
+ .forEach(r => newAttention.add(r._account_id!));
+ // Add owner and uploader, if someone else replies.
+ if (hasDrafts || hasVote) {
+ if (this._uploader?._account_id && !isUploader) {
+ newAttention.add(this._uploader._account_id);
+ }
+ if (change.owner?._account_id && !isOwner) {
+ newAttention.add(change.owner._account_id);
+ }
+ }
+ } else {
+ // The only reason for adding someone to the attention set for merged or
+ // abandoned changes is that someone makes a comment thread unresolved.
+ const hasUnresolvedDraft = draftCommentThreads.some(isUnresolved);
+ if (change.owner && hasUnresolvedDraft) {
+ // A change owner must have an _account_id.
+ newAttention.add(change.owner._account_id!);
+ }
+ // Remove the current user.
+ newAttention.delete(currentUser._account_id);
+ }
+ // Finally make sure that everyone in the attention set is still active as
+ // owner, reviewer or cc.
+ const allAccountIds = this._allAccounts()
+ .map(a => a._account_id)
+ .filter(id => !!id);
+ this._newAttentionSet = new Set(
+ [...newAttention].filter(id => allAccountIds.includes(id))
+ );
+ this._attentionExpanded = this._computeShowAttentionTip(
+ currentUser,
+ change.owner,
+ this._currentAttentionSet,
+ this._newAttentionSet
+ );
+ }
+
+ _computeShowAttentionTip(
+ currentUser?: AccountInfo,
+ owner?: AccountInfo,
+ currentAttentionSet?: Set<AccountId>,
+ newAttentionSet?: Set<AccountId>
+ ) {
+ if (!currentUser || !owner || !currentAttentionSet || !newAttentionSet)
+ return false;
+ const isOwner = currentUser._account_id === owner._account_id;
+ const addedIds = [...newAttentionSet].filter(
+ id => !currentAttentionSet.has(id)
+ );
+ return isOwner && addedIds.length > 2;
+ }
+
+ _computeCommentAccounts(threads: CommentThread[]) {
+ const crLabel = this.change?.labels?.[CODE_REVIEW];
+ const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
+ const accountIds = new Set<AccountId>();
+ threads.forEach(thread => {
+ const unresolved = isUnresolved(thread);
+ thread.comments.forEach(comment => {
+ if (comment.author) {
+ // A comment author must have an _account_id.
+ const authorId = comment.author._account_id!;
+ const hasGivenMaxReviewVote = maxCrVoteAccountIds.includes(authorId);
+ if (unresolved || !hasGivenMaxReviewVote) accountIds.add(authorId);
+ }
+ });
+ });
+ return accountIds;
+ }
+
+ _computeShowNoAttentionUpdate(
+ config?: ServerInfo,
+ currentAttentionSet?: Set<AccountId>,
+ newAttentionSet?: Set<AccountId>,
+ sendDisabled?: boolean
+ ) {
+ return (
+ sendDisabled ||
+ this._computeNewAttentionAccounts(
+ config,
+ currentAttentionSet,
+ newAttentionSet
+ ).length === 0
+ );
+ }
+
+ _computeDoNotUpdateMessage(
+ currentAttentionSet?: Set<AccountId>,
+ newAttentionSet?: Set<AccountId>,
+ sendDisabled?: boolean
+ ) {
+ if (!currentAttentionSet || !newAttentionSet) return '';
+ if (sendDisabled || areSetsEqual(currentAttentionSet, newAttentionSet)) {
+ return 'No changes to the attention set.';
+ }
+ if (containsAll(currentAttentionSet, newAttentionSet)) {
+ return 'No additions to the attention set.';
+ }
+ console.error(
+ '_computeDoNotUpdateMessage() should not be called when users were added to the attention set.'
+ );
+ return '';
+ }
+
+ _computeNewAttentionAccounts(
+ _?: ServerInfo,
+ currentAttentionSet?: Set<AccountId>,
+ newAttentionSet?: Set<AccountId>
+ ) {
+ if (currentAttentionSet === undefined || newAttentionSet === undefined) {
+ return [];
+ }
+ return [...newAttentionSet]
+ .filter(id => !currentAttentionSet.has(id))
+ .map(id => this._findAccountById(id))
+ .filter(account => !!account);
+ }
+
+ _findAccountById(accountId: AccountId) {
+ return this._allAccounts().find(r => r._account_id === accountId);
+ }
+
+ _allAccounts() {
+ let allAccounts: (AccountInfoInput | GroupInfoInput)[] = [];
+ if (this.change && this.change.owner) allAccounts.push(this.change.owner);
+ if (this._uploader) allAccounts.push(this._uploader);
+ if (this._reviewers) allAccounts = [...allAccounts, ...this._reviewers];
+ if (this._ccs) allAccounts = [...allAccounts, ...this._ccs];
+ return removeServiceUsers(allAccounts.filter(isAccount));
+ }
+
+ /**
+ * The newAttentionSet param is only used to force re-computation.
+ */
+ _removeServiceUsers(accounts: AccountInfo[], _: Set<AccountId>) {
+ return removeServiceUsers(accounts);
+ }
+
+ _computeUploader(change: ChangeInfo) {
+ if (
+ !change ||
+ !change.current_revision ||
+ !change.revisions ||
+ !change.revisions[change.current_revision]
+ ) {
+ return undefined;
+ }
+ const rev = change.revisions[change.current_revision];
+
+ if (
+ !rev.uploader ||
+ change.owner._account_id === rev.uploader._account_id
+ ) {
+ return undefined;
+ }
+ return rev.uploader;
+ }
+
+ _accountOrGroupKey(entry: AccountInfo | GroupInfo) {
+ if (isAccount(entry)) return accountKey(entry);
+ if (isGroup(entry)) return entry.id;
+ assertNever(entry, 'entry must be account or group');
+ }
+
+ /**
+ * Generates a function to filter out reviewer/CC entries. When isCCs is
+ * truthy, the function filters out entries that already exist in this._ccs.
+ * When falsy, the function filters entries that exist in this._reviewers.
+ */
+ _filterReviewerSuggestionGenerator(
+ isCCs: boolean
+ ): (input: Suggestion) => boolean {
+ return suggestion => {
+ let entry: AccountInfo | GroupInfo;
+ if (isReviewerAccountSuggestion(suggestion)) {
+ entry = suggestion.account;
+ if (entry._account_id === this._owner?._account_id) {
+ return false;
+ }
+ } else if (isReviewerGroupSuggestion(suggestion)) {
+ entry = suggestion.group;
+ } else {
+ console.warn(
+ 'received suggestion that was neither account nor group:',
+ suggestion
+ );
+ return false;
+ }
+
+ const key = this._accountOrGroupKey(entry);
+ const finder = (entry: AccountInfo | GroupInfo) =>
+ this._accountOrGroupKey(entry) === key;
+ if (isCCs) {
+ return this._ccs.find(finder) === undefined;
+ }
+ return this._reviewers.find(finder) === undefined;
+ };
+ }
+
+ _getAccount() {
+ return this.$.restAPI.getAccount();
+ }
+
+ _cancelTapHandler(e: Event) {
+ e.preventDefault();
+ this.cancel();
+ }
+
+ cancel() {
+ if (!this.change) throw new Error('missing required change property');
+ if (!this._owner) throw new Error('missing required _owner property');
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ this.$.textarea.closeDropdown();
+ this._purgeReviewersPendingRemove(true);
+ this._rebuildReviewerArrays(this.change.reviewers, this._owner);
+ }
+
+ _saveClickHandler(e: Event) {
+ e.preventDefault();
+ if (!this.$.ccs.submitEntryText()) {
+ // Do not proceed with the save if there is an invalid email entry in
+ // the text field of the CC entry.
+ return;
+ }
+ this.send(this._includeComments, false).then(keepReviewers => {
+ this._purgeReviewersPendingRemove(false, keepReviewers);
+ });
+ }
+
+ _sendTapHandler(e: Event) {
+ e.preventDefault();
+ this._submit();
+ }
+
+ _submit() {
+ if (!this.$.ccs.submitEntryText()) {
+ // Do not proceed with the send if there is an invalid email entry in
+ // the text field of the CC entry.
+ return;
+ }
+ if (this._sendDisabled) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ bubbles: true,
+ composed: true,
+ detail: {message: EMPTY_REPLY_MESSAGE},
+ })
+ );
+ return;
+ }
+ return this.send(this._includeComments, this.canBeStarted)
+ .then(keepReviewers => {
+ this._purgeReviewersPendingRemove(false, keepReviewers);
+ })
+ .catch(err => {
+ this.dispatchEvent(
+ new CustomEvent('show-error', {
+ bubbles: true,
+ composed: true,
+ detail: {message: `Error submitting review ${err}`},
+ })
+ );
+ });
+ }
+
+ _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
+ if (!this.change) throw new Error('missing required change property');
+ if (!this.patchNum) throw new Error('missing required patchNum property');
+ return this.$.restAPI.saveChangeReview(
+ this.change._number,
+ this.patchNum,
+ review,
+ errFn
+ );
+ }
+
+ _reviewerPendingConfirmationUpdated(reviewer: RawAccountInput | null) {
+ if (reviewer === null) {
+ this.$.reviewerConfirmationOverlay.close();
+ } else {
+ this._pendingConfirmationDetails =
+ this._ccPendingConfirmation || this._reviewerPendingConfirmation;
+ this.$.reviewerConfirmationOverlay.open();
+ }
+ }
+
+ _confirmPendingReviewer() {
+ if (this._ccPendingConfirmation) {
+ this.$.ccs.confirmGroup(this._ccPendingConfirmation.group);
+ this._focusOn(FocusTarget.CCS);
+ return;
+ }
+ if (this._reviewerPendingConfirmation) {
+ this.$.reviewers.confirmGroup(this._reviewerPendingConfirmation.group);
+ this._focusOn(FocusTarget.REVIEWERS);
+ return;
+ }
+ console.error('_confirmPendingReviewer called without pending confirm');
+ }
+
+ _cancelPendingReviewer() {
+ this._ccPendingConfirmation = null;
+ this._reviewerPendingConfirmation = null;
+
+ const target = this._ccPendingConfirmation
+ ? FocusTarget.CCS
+ : FocusTarget.REVIEWERS;
+ this._focusOn(target);
+ }
+
+ _getStorageLocation(): StorageLocation {
+ if (!this.change) throw new Error('missing required change property');
+ return {
+ changeNum: this.change._number,
+ patchNum: '@change',
+ path: '@change',
+ };
+ }
+
+ _loadStoredDraft() {
+ const draft = this.$.storage.getDraftComment(this._getStorageLocation());
+ return draft?.message ?? '';
+ }
+
+ _handleAccountTextEntry() {
+ // When either of the account entries has input added to the autocomplete,
+ // it should trigger the save button to enable/
+ //
+ // Note: if the text is removed, the save button will not get disabled.
+ this._reviewersMutated = true;
+ }
+
+ _draftChanged(newDraft: string, oldDraft?: string) {
+ this.debounce(
+ 'store',
+ () => {
+ if (!newDraft.length && oldDraft) {
+ // If the draft has been modified to be empty, then erase the storage
+ // entry.
+ this.$.storage.eraseDraftComment(this._getStorageLocation());
+ } else if (newDraft.length) {
+ this.$.storage.setDraftComment(
+ this._getStorageLocation(),
+ this.draft
+ );
+ }
+ },
+ STORAGE_DEBOUNCE_INTERVAL_MS
+ );
+ }
+
+ _handleHeightChanged() {
+ this.dispatchEvent(
+ new CustomEvent('autogrow', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _handleLabelsChanged() {
+ this._labelsChanged =
+ Object.keys(this.$.labelScores.getLabelValues(false)).length !== 0;
+ }
+
+ _isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) {
+ return knownLatestState === value;
+ }
+
+ _reload() {
+ this.dispatchEvent(
+ new CustomEvent('reload', {
+ detail: {clearPatchset: true},
+ bubbles: false,
+ composed: true,
+ })
+ );
+ this.cancel();
+ }
+
+ _computeSendButtonLabel(canBeStarted: boolean) {
+ return canBeStarted
+ ? ButtonLabels.SEND + ' and ' + ButtonLabels.START_REVIEW
+ : ButtonLabels.SEND;
+ }
+
+ _computeSendButtonTooltip(canBeStarted: boolean) {
+ return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
+ }
+
+ _computeSavingLabelClass(savingComments: boolean) {
+ return savingComments ? 'saving' : '';
+ }
+
+ _computeSendButtonDisabled(
+ canBeStarted?: boolean,
+ draftCommentThreads?: CommentThread[],
+ text?: string,
+ reviewersMutated?: boolean,
+ labelsChanged?: boolean,
+ includeComments?: boolean,
+ disabled?: boolean,
+ commentEditing?: boolean
+ ) {
+ if (
+ canBeStarted === undefined ||
+ draftCommentThreads === undefined ||
+ text === undefined ||
+ reviewersMutated === undefined ||
+ labelsChanged === undefined ||
+ includeComments === undefined ||
+ disabled === undefined ||
+ commentEditing === undefined
+ ) {
+ return undefined;
+ }
+ if (commentEditing || disabled) {
+ return true;
+ }
+ if (canBeStarted === true) {
+ return false;
+ }
+ const hasDrafts = includeComments && draftCommentThreads.length;
+ return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
+ }
+
+ _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
+ let str = `Patch ${patchNum} is not latest.`;
+ if (labelsChanged) {
+ str += ' Voting will have no effect.';
+ }
+ return str;
+ }
+
+ setPluginMessage(message: string) {
+ this._pluginMessage = message;
+ }
+
+ _sendDisabledChanged() {
+ this.dispatchEvent(new CustomEvent('send-disabled-changed'));
+ }
+
+ _getReviewerSuggestionsProvider(change: ChangeInfo) {
+ const provider = GrReviewerSuggestionsProvider.create(
+ this.$.restAPI,
+ change._number,
+ SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER
+ );
+ provider.init();
+ return provider;
+ }
+
+ _getCcSuggestionsProvider(change: ChangeInfo) {
+ const provider = GrReviewerSuggestionsProvider.create(
+ this.$.restAPI,
+ change._number,
+ SUGGESTIONS_PROVIDERS_USERS_TYPES.CC
+ );
+ provider.init();
+ return provider;
+ }
+
+ _onThreadListModified() {
+ // TODO(taoalpha): this won't propogate the changes to the files
+ // should consider replacing this with either top level events
+ // or gerrit level events
+
+ // emit the event so change-view can also get updated with latest changes
+ this.dispatchEvent(
+ new CustomEvent('comment-refresh', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ reportAttentionSetChanges(
+ modified: boolean,
+ addedSet?: AttentionSetInput[],
+ removedSet?: AttentionSetInput[]
+ ) {
+ const actions = modified ? ['MODIFIED'] : ['NOT_MODIFIED'];
+ const ownerId =
+ (this.change && this.change.owner && this.change.owner._account_id) || -1;
+ const selfId = (this._account && this._account._account_id) || -1;
+ for (const added of addedSet || []) {
+ const addedId = added.user;
+ const self = addedId === selfId ? '_SELF' : '';
+ const role = addedId === ownerId ? '_OWNER' : '_REVIEWER';
+ actions.push('ADD' + self + role);
+ }
+ for (const removed of removedSet || []) {
+ const removedId = removed.user;
+ const self = removedId === selfId ? '_SELF' : '';
+ const role = removedId === ownerId ? '_OWNER' : '_REVIEWER';
+ actions.push('REMOVE' + self + role);
+ }
+ this.reporting.reportInteraction('attention-set-actions', {actions});
+ }
+
+ _computeAllReviewers() {
+ return [...this._reviewers];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-reply-dialog': GrReplyDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
index ad5b6a3..c56a5c9 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_html.ts
@@ -44,15 +44,19 @@
/* We want the :hover highlight to extend to the border of the dialog. */
padding: var(--spacing-m) 0;
}
- .actions {
+ .stickyBottom {
background-color: var(--dialog-background-color);
+ box-shadow: 0px 0px 8px 0px rgba(60, 64, 67, 0.15);
+ margin-top: var(--spacing-s);
bottom: 0;
- display: flex;
- justify-content: space-between;
position: sticky;
/* @see Issue 8602 */
z-index: 1;
}
+ .actions {
+ display: flex;
+ justify-content: space-between;
+ }
.actions .right gr-button {
margin-left: var(--spacing-l);
}
@@ -101,6 +105,12 @@
display: flex;
width: 100%;
}
+ gr-endpoint-decorator[name='reply-text'] {
+ flex-direction: column;
+ }
+ #textarea {
+ flex: 1;
+ }
.previewContainer gr-formatted-text {
background: var(--table-header-background-color);
padding: var(--spacing-l);
@@ -150,28 +160,68 @@
.attention .edit-attention-button iron-icon {
color: inherit;
}
- .attention-detail .peopleList {
- margin-top: var(--spacing-s);
+ .attention a,
+ .attention-detail a {
+ text-decoration: none;
+ }
+ .attentionSummary {
+ display: flex;
+ justify-content: space-between;
+ }
+ .attentionSummary {
+ /* The account label for selection is misbehaving currently: It consumes
+ 26px height instead of 20px, which is the default line-height and thus
+ the max that can be nicely fit into an inline layout flow. We
+ acknowledge that using a fixed 26px value here is a hack and not a
+ great solution. */
+ line-height: 26px;
+ }
+ .attention-detail .peopleList .accountList {
+ display: flex;
+ flex-wrap: wrap;
+ }
+ .attentionSummary gr-account-label,
+ .attention-detail gr-account-label {
+ --account-max-length: 150px;
+ display: inline-block;
+ padding: var(--spacing-xs) var(--spacing-m);
+ user-select: none;
+ --label-border-radius: 8px;
+ }
+ .attentionSummary gr-account-label {
+ margin: 0 var(--spacing-xs);
+ line-height: var(--line-height-normal);
+ vertical-align: top;
}
.attention-detail gr-account-label {
- background-color: var(--background-color-tertiary);
- padding: 0 var(--spacing-m) 0 var(--spacing-s);
- margin-right: var(--spacing-m);
- user-select: none;
- --label-border-radius: 10px;
+ vertical-align: baseline;
}
+ .attentionSummary gr-account-label:focus,
.attention-detail gr-account-label:focus {
outline: none;
}
+ .attentionSummary gr-account-label:hover,
.attention-detail gr-account-label:hover {
box-shadow: var(--elevation-level-1);
cursor: pointer;
}
.attention-detail .attentionDetailsTitle {
- margin-bottom: var(--spacing-s);
+ display: flex;
+ justify-content: space-between;
}
.attention-detail .selectUsers {
color: var(--deemphasized-text-color);
+ margin-bottom: var(--spacing-m);
+ }
+ .attentionTip {
+ padding: var(--spacing-m);
+ border: 1px solid var(--border-color);
+ border-radius: var(--border-radius);
+ margin-top: var(--spacing-m);
+ background-color: var(--assignee-highlight-color);
+ }
+ .attentionTip div iron-icon {
+ margin-right: var(--spacing-s);
}
</style>
<div class="container" tabindex="-1">
@@ -286,82 +336,6 @@
<div id="pluginMessage">[[_pluginMessage]]</div>
</section>
<section
- hidden$="[[!_showAttentionSummary(serverConfig, _attentionModified)]]"
- class="attention"
- >
- <div>
- <iron-icon class="attention-icon" icon="gr-icons:attention"></iron-icon>
- <span hidden$="[[_isOwner(_account, change)]]"
- >Bring to owner's attention.</span
- >
- <span hidden$="[[!_isOwner(_account, change)]]"
- >Bring to all reviewer's attention.</span
- >
- <gr-button
- class="edit-attention-button"
- on-click="_handleAttentionModify"
- link=""
- position-below=""
- data-label="Edit"
- data-action-type="change"
- data-action-key="edit"
- title="Edit attention set changes"
- role="button"
- tabindex="0"
- >
- <iron-icon icon="gr-icons:edit" class=""></iron-icon>
- Modify
- </gr-button>
- </div>
- </section>
- <section
- hidden$="[[!_showAttentionDetails(serverConfig, _attentionModified)]]"
- class="attention-detail"
- >
- <div class="attentionDetailsTitle">
- <iron-icon class="attention-icon" icon="gr-icons:attention"></iron-icon>
- <span>Bring to attention of ...</span>
- <span class="selectUsers">(select users)</span>
- </div>
- <div class="peopleList">
- <div class="peopleListLabel">Owner</div>
- <gr-account-label
- account="[[_owner]]"
- show-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
- blurred="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
- hide-hovercard=""
- on-click="_handleAttentionClick"
- >
- </gr-account-label>
- </div>
- <div class="peopleList">
- <div class="peopleListLabel">Reviewers</div>
- <template is="dom-repeat" items="[[_reviewers]]" as="account">
- <gr-account-label
- account="[[account]]"
- show-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
- blurred="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
- hide-hovercard=""
- on-click="_handleAttentionClick"
- >
- </gr-account-label>
- </template>
- </div>
- <div class="peopleList">
- <div class="peopleListLabel">CC</div>
- <template is="dom-repeat" items="[[_ccs]]" as="account">
- <gr-account-label
- account="[[account]]"
- show-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
- blurred="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
- hide-hovercard=""
- on-click="_handleAttentionClick"
- >
- </gr-account-label>
- </template>
- </div>
- </section>
- <section
class="draftsContainer"
hidden$="[[_computeHideDraftList(draftCommentThreads)]]"
>
@@ -393,56 +367,254 @@
Saving comments...
</span>
</section>
- <section class="actions">
- <div class="left">
- <span
- id="checkingStatusLabel"
- hidden$="[[!_isState(knownLatestState, 'checking')]]"
+ <div class="stickyBottom">
+ <section
+ hidden$="[[!_showAttentionSummary(serverConfig, _attentionExpanded)]]"
+ class="attention"
+ >
+ <div class="attentionSummary">
+ <div>
+ <template
+ is="dom-if"
+ if="[[_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
+ >
+ <span
+ >[[_computeDoNotUpdateMessage(_currentAttentionSet,
+ _newAttentionSet, _sendDisabled)]]</span
+ >
+ </template>
+ <template
+ is="dom-if"
+ if="[[!_computeShowNoAttentionUpdate(serverConfig, _currentAttentionSet, _newAttentionSet, _sendDisabled)]]"
+ >
+ <span>Bring to attention of</span>
+ <template
+ is="dom-repeat"
+ items="[[_computeNewAttentionAccounts(serverConfig, _currentAttentionSet, _newAttentionSet)]]"
+ as="account"
+ >
+ <gr-account-label
+ account="[[account]]"
+ force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+ selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+ deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+ hide-hovercard=""
+ on-click="_handleAttentionClick"
+ ></gr-account-label>
+ </template>
+ </template>
+ <gr-button
+ class="edit-attention-button"
+ on-click="_handleAttentionModify"
+ disabled="[[_sendDisabled]]"
+ link=""
+ position-below=""
+ data-label="Edit"
+ data-action-type="change"
+ data-action-key="edit"
+ has-tooltip=""
+ title="[[_computeAttentionButtonTitle(_sendDisabled)]]"
+ role="button"
+ tabindex="0"
+ >
+ <iron-icon icon="gr-icons:edit"></iron-icon>
+ Modify
+ </gr-button>
+ </div>
+ <div>
+ <a
+ href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
+ target="_blank"
+ >
+ <iron-icon
+ icon="gr-icons:bug"
+ title="report a problem"
+ ></iron-icon>
+ </a>
+ <a
+ href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+ target="_blank"
+ >
+ <iron-icon
+ icon="gr-icons:help-outline"
+ title="read documentation"
+ ></iron-icon>
+ </a>
+ </div>
+ </div>
+ </section>
+ <section
+ hidden$="[[!_showAttentionDetails(serverConfig, _attentionExpanded)]]"
+ class="attention-detail"
+ >
+ <div class="attentionDetailsTitle">
+ <div>
+ <span>Modify attention to</span>
+ </div>
+ <div></div>
+ <div>
+ <a
+ href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
+ target="_blank"
+ >
+ <iron-icon
+ icon="gr-icons:bug"
+ title="report a problem"
+ ></iron-icon>
+ </a>
+ <a
+ href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+ target="_blank"
+ >
+ <iron-icon
+ icon="gr-icons:help-outline"
+ title="read documentation"
+ ></iron-icon>
+ </a>
+ </div>
+ </div>
+ <div class="selectUsers">
+ <span
+ >Select chips to set who will be in the attention set after sending
+ this reply</span
+ >
+ </div>
+ <div class="peopleList">
+ <div class="peopleListLabel">Owner</div>
+ <div>
+ <gr-account-label
+ account="[[_owner]]"
+ force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
+ selected$="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
+ deselected$="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
+ hide-hovercard=""
+ on-click="_handleAttentionClick"
+ >
+ </gr-account-label>
+ </div>
+ </div>
+ <template is="dom-if" if="[[_uploader]]">
+ <div class="peopleList">
+ <div class="peopleListLabel">Uploader</div>
+ <div>
+ <gr-account-label
+ account="[[_uploader]]"
+ force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+ selected$="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+ deselected$="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
+ hide-hovercard=""
+ on-click="_handleAttentionClick"
+ >
+ </gr-account-label>
+ </div>
+ </div>
+ </template>
+ <div class="peopleList">
+ <div class="peopleListLabel">Reviewers</div>
+ <div>
+ <template
+ is="dom-repeat"
+ items="[[_removeServiceUsers(_reviewers, _newAttentionSet)]]"
+ as="account"
+ >
+ <gr-account-label
+ account="[[account]]"
+ force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+ selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+ deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+ hide-hovercard=""
+ on-click="_handleAttentionClick"
+ >
+ </gr-account-label>
+ </template>
+ </div>
+ </div>
+ <template is="dom-if" if="[[_attentionCcsCount]]">
+ <div class="peopleList">
+ <div class="peopleListLabel">CC</div>
+ <div>
+ <template
+ is="dom-repeat"
+ items="[[_removeServiceUsers(_ccs, _newAttentionSet)]]"
+ as="account"
+ >
+ <gr-account-label
+ account="[[account]]"
+ force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+ selected$="[[_computeHasNewAttention(account, _newAttentionSet)]]"
+ deselected$="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
+ hide-hovercard=""
+ on-click="_handleAttentionClick"
+ >
+ </gr-account-label>
+ </template>
+ </div>
+ </div>
+ </template>
+ <template
+ is="dom-if"
+ if="[[_computeShowAttentionTip(_account, _owner, _currentAttentionSet, _newAttentionSet)]]"
>
- Checking whether patch [[patchNum]] is latest...
- </span>
- <span
- id="notLatestLabel"
- hidden$="[[!_isState(knownLatestState, 'not-latest')]]"
- >
- [[_computePatchSetWarning(patchNum, _labelsChanged)]]
- <gr-button link="" on-click="_reload">Reload</gr-button>
- </span>
- </div>
- <div class="right">
- <gr-button
- link=""
- id="cancelButton"
- class="action cancel"
- on-click="_cancelTapHandler"
- >Cancel</gr-button
- >
- <template is="dom-if" if="[[canBeStarted]]">
- <!-- Use 'Send' here as the change may only about reviewers / ccs
- and when this button is visible, the next button will always
- be 'Start review' -->
+ <div class="attentionTip">
+ <iron-icon
+ class="pointer"
+ icon="gr-icons:lightbulb-outline"
+ ></iron-icon>
+ Be mindful of requiring attention from too many users.
+ </div>
+ </template>
+ </section>
+ <section class="actions">
+ <div class="left">
+ <span
+ id="checkingStatusLabel"
+ hidden$="[[!_isState(knownLatestState, 'checking')]]"
+ >
+ Checking whether patch [[patchNum]] is latest...
+ </span>
+ <span
+ id="notLatestLabel"
+ hidden$="[[!_isState(knownLatestState, 'not-latest')]]"
+ >
+ [[_computePatchSetWarning(patchNum, _labelsChanged)]]
+ <gr-button link="" on-click="_reload">Reload</gr-button>
+ </span>
+ </div>
+ <div class="right">
<gr-button
link=""
- disabled="[[_isState(knownLatestState, 'not-latest')]]"
- class="action save"
- has-tooltip=""
- title="[[_saveTooltip]]"
- on-click="_saveClickHandler"
- >Save</gr-button
+ id="cancelButton"
+ class="action cancel"
+ on-click="_cancelTapHandler"
+ >Cancel</gr-button
>
- </template>
- <gr-button
- id="sendButton"
- primary=""
- disabled="[[_sendDisabled]]"
- class="action send"
- has-tooltip=""
- title$="[[_computeSendButtonTooltip(canBeStarted)]]"
- on-click="_sendTapHandler"
- >[[_sendButtonLabel]]</gr-button
- >
- </div>
- </section>
+ <template is="dom-if" if="[[canBeStarted]]">
+ <!-- Use 'Send' here as the change may only about reviewers / ccs
+ and when this button is visible, the next button will always
+ be 'Start review' -->
+ <gr-button
+ link=""
+ disabled="[[_isState(knownLatestState, 'not-latest')]]"
+ class="action save"
+ has-tooltip=""
+ title="[[_saveTooltip]]"
+ on-click="_saveClickHandler"
+ >Save</gr-button
+ >
+ </template>
+ <gr-button
+ id="sendButton"
+ primary=""
+ disabled="[[_sendDisabled]]"
+ class="action send"
+ has-tooltip=""
+ title$="[[_computeSendButtonTooltip(canBeStarted)]]"
+ on-click="_sendTapHandler"
+ >[[_sendButtonLabel]]</gr-button
+ >
+ </div>
+ </section>
+ </div>
</div>
<gr-js-api-interface id="jsAPI"></gr-js-api-interface>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
index 0254652..b612a57 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
@@ -74,6 +74,7 @@
_number: changeNum,
owner: {
_account_id: 999,
+ display_name: 'Kermit',
},
labels: {
'Verified': {
@@ -119,7 +120,7 @@
// .returns(Promise.resolve({isLatest: true}));
// Allow the elements created by dom-repeat to be stamped.
- flushAsynchronousOperations();
+ flush();
});
function stubSaveReview(jsonResponseProducer) {
@@ -181,16 +182,19 @@
});
test('modified attention set', done => {
+ element.serverConfig = {
+ change: {enable_attention_set: true},
+ };
element._newAttentionSet = new Set([314]);
const buttonEl = element.shadowRoot.querySelector('.edit-attention-button');
MockInteractions.tap(buttonEl);
- flushAsynchronousOperations();
+ flush();
stubSaveReview(review => {
- assert.isTrue(review.ignore_default_attention_set_rules);
+ assert.isTrue(review.ignore_automatic_attention_set_rules);
assert.deepEqual(review.add_to_attention_set, [{
user: 314,
- reason: 'manually added in reply dialog',
+ reason: 'Anonymous replied on the change',
}]);
assert.deepEqual(review.remove_from_attention_set, []);
done();
@@ -198,32 +202,175 @@
MockInteractions.tap(element.shadowRoot.querySelector('.send'));
});
- function checkComputeAttention(
- userId, reviewerIds, ownerId, attSetIds, expectedIds) {
+ function checkComputeAttention(status, userId, reviewerIds, ownerId,
+ attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
+ includeComments = true) {
const user = {_account_id: userId};
- const reviewers = reviewerIds.map(id => {
+ const reviewers = {base: reviewerIds.map(id => {
return {_account_id: id};
- });
+ })};
+ const draftThreads = [
+ {comments: []},
+ ];
+ if (hasDraft) {
+ draftThreads[0].comments.push({__draft: true, unresolved: true});
+ }
+ replyToIds.forEach(id => draftThreads[0].comments.push({
+ author: {_account_id: id},
+ }));
const change = {
owner: {_account_id: ownerId},
+ status,
attention_set: {},
};
attSetIds.forEach(id => change.attention_set[id] = {});
- element._computeNewAttention(user, reviewers, change);
- assert.deepEqual(element._newAttentionSet, new Set(expectedIds));
+ if (uploaderId) {
+ change.current_revision = 1;
+ change.revisions = [{}, {uploader: {_account_id: uploaderId}}];
+ }
+ element.change = change;
+ element._reviewers = reviewers.base;
+
+ flush();
+ const hasDrafts = draftThreads.length > 0;
+ element._computeNewAttention(
+ user, reviewers, [], change, draftThreads, includeComments, undefined,
+ hasDrafts);
+ assert.sameMembers([...element._newAttentionSet], expectedIds);
}
- test('computeNewAttention', () => {
- checkComputeAttention(null, [], 999, [], [999]);
- checkComputeAttention(1, [], 999, [], [999]);
- checkComputeAttention(1, [], 999, [1], [999]);
- checkComputeAttention(1, [22], 999, [], [999]);
- checkComputeAttention(1, [22], 999, [22], [22, 999]);
- checkComputeAttention(1, [], 1, [], []);
- checkComputeAttention(1, [], 1, [1], []);
- checkComputeAttention(1, [22], 1, [], [22]);
- checkComputeAttention(1, [22, 33], 1, [], [22, 33]);
- checkComputeAttention(1, [22, 33], 1, [22, 33], [22, 33]);
+ test('computeNewAttention NEW', () => {
+ checkComputeAttention('NEW', null, [], 999, [], [], [999]);
+ checkComputeAttention('NEW', 1, [], 999, [], [], [999]);
+ checkComputeAttention('NEW', 1, [], 999, [1], [], [999]);
+ checkComputeAttention('NEW', 1, [22], 999, [], [], [999]);
+ checkComputeAttention('NEW', 1, [22], 999, [22], [], [22, 999]);
+ checkComputeAttention('NEW', 1, [22], 999, [], [22], [22, 999]);
+ checkComputeAttention('NEW', 1, [22, 33], 999, [33], [22], [22, 33, 999]);
+ // If the owner replies, then do not add them.
+ checkComputeAttention('NEW', 1, [], 1, [], [], []);
+ checkComputeAttention('NEW', 1, [], 1, [1], [], []);
+ checkComputeAttention('NEW', 1, [22], 1, [], [], []);
+
+ checkComputeAttention('NEW', 1, [22], 1, [], [22], [22]);
+ checkComputeAttention('NEW', 1, [22, 33], 1, [33], [22], [22, 33]);
+ checkComputeAttention('NEW', 1, [22, 33], 1, [], [22], [22]);
+ checkComputeAttention('NEW', 1, [22, 33], 1, [], [22, 33], [22, 33]);
+ checkComputeAttention('NEW', 1, [22, 33], 1, [22, 33], [], [22, 33]);
+ // with uploader
+ checkComputeAttention('NEW', 1, [], 1, [], [2], [2], 2);
+ checkComputeAttention('NEW', 1, [], 1, [2], [], [2], 2);
+ checkComputeAttention('NEW', 1, [], 3, [], [], [2, 3], 2);
+ });
+
+ test('computeNewAttention MERGED', () => {
+ checkComputeAttention('MERGED', null, [], 999, [], [], []);
+ checkComputeAttention('MERGED', 1, [], 999, [], [], []);
+ checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
+ checkComputeAttention(
+ 'MERGED', 1, [], 999, [], [], [], undefined, true, false);
+ checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
+ checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
+ checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
+ checkComputeAttention('MERGED', 1, [22], 999, [], [22], []);
+ checkComputeAttention('MERGED', 1, [22, 33], 999, [33], [22], [33]);
+ checkComputeAttention('MERGED', 1, [], 1, [], [], []);
+ checkComputeAttention('MERGED', 1, [], 1, [], [], [], undefined, true);
+ checkComputeAttention('MERGED', 1, [], 1, [1], [], []);
+ checkComputeAttention('MERGED', 1, [], 1, [1], [], [], undefined, true);
+ checkComputeAttention('MERGED', 1, [22], 1, [], [], []);
+ checkComputeAttention('MERGED', 1, [22], 1, [], [22], []);
+ checkComputeAttention('MERGED', 1, [22, 33], 1, [33], [22], [33]);
+ checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22], []);
+ checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22, 33], []);
+ checkComputeAttention('MERGED', 1, [22, 33], 1, [22, 33], [], [22, 33]);
+ });
+
+ test('computeNewAttention when adding reviewers', () => {
+ const user = {_account_id: 1};
+ const reviewers = {base: [
+ {_account_id: 1, _pendingAdd: true},
+ {_account_id: 2, _pendingAdd: true},
+ ]};
+ const change = {
+ owner: {_account_id: 5},
+ status: 'NEW',
+ attention_set: {},
+ };
+ element.change = change;
+ element._reviewers = reviewers.base;
+ flush();
+
+ element._computeNewAttention(user, reviewers, [], change, [], true);
+ assert.sameMembers([...element._newAttentionSet], [1, 2]);
+
+ // If the user votes on the change, then they should not be added to the
+ // attention set, even if they have just added themselves as reviewer.
+ // But voting should also add the owner (5).
+ const labelsChanged = true;
+ element._computeNewAttention(
+ user, reviewers, [], change, [], true, labelsChanged);
+ assert.sameMembers([...element._newAttentionSet], [2, 5]);
+ });
+
+ test('computeNewAttentionAccounts', () => {
+ element._reviewers = [
+ {_account_id: 123, display_name: 'Ernie'},
+ {_account_id: 321, display_name: 'Bert'},
+ ];
+ element._ccs = [
+ {_account_id: 7, display_name: 'Elmo'},
+ ];
+ const compute = (currentAtt, newAtt) =>
+ element._computeNewAttentionAccounts(
+ undefined, new Set(currentAtt), new Set(newAtt))
+ .map(a => a._account_id);
+
+ assert.sameMembers(compute([], []), []);
+ assert.sameMembers(compute([], [999]), [999]);
+ assert.sameMembers(compute([999], []), []);
+ assert.sameMembers(compute([999], [999]), []);
+ assert.sameMembers(compute([123, 321], [999]), [999]);
+ assert.sameMembers(compute([999], [7, 123, 999]), [7, 123]);
+ });
+
+ test('_computeCommentAccounts', () => {
+ element.change = {
+ labels: {
+ 'Code-Review': {
+ all: [
+ {_account_id: 1, value: 0},
+ {_account_id: 2, value: 1},
+ {_account_id: 3, value: 2},
+ ],
+ values: {
+ '-2': 'Do not submit',
+ '-1': 'I would prefer that you didnt submit this',
+ ' 0': 'No score',
+ '+1': 'Looks good to me, but someone else must approve',
+ '+2': 'Looks good to me, approved',
+ },
+ },
+ },
+ };
+ const threads = [
+ {
+ comments: [
+ {author: {_account_id: 1}, unresolved: false},
+ {author: {_account_id: 2}, unresolved: true},
+ ],
+ },
+ {
+ comments: [
+ {author: {_account_id: 3}, unresolved: false},
+ {author: {_account_id: 4}, unresolved: false},
+ ],
+ },
+ ];
+ const actualAccounts = [...element._computeCommentAccounts(threads)];
+ // Account 3 is not included, because the comment is resolved *and* they
+ // have given the highest possible vote on the Code-Review label.
+ assert.sameMembers(actualAccounts, [1, 2, 4]);
});
test('toggle resolved checkbox', done => {
@@ -439,7 +586,7 @@
element._ccPendingConfirmation = null;
element._reviewerPendingConfirmation = null;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
// Cause the confirmation dialog to display.
@@ -459,7 +606,7 @@
count: 10,
};
}
- flushAsynchronousOperations();
+ flush();
if (cc) {
assert.deepEqual(
@@ -570,7 +717,7 @@
});
test('_reviewersMutated when account-text-change is fired from ccs', () => {
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element._reviewersMutated);
assert.isTrue(element.$.ccs.allowAnyInput);
assert.isFalse(element.shadowRoot
@@ -631,26 +778,23 @@
assert.isTrue(eraseDraftCommentStub.calledWith(location));
});
- test('400 converts to human-readable server-error', done => {
+ test('400 converts to human-readable server-error', async () => {
sinon.stub(window, 'fetch').callsFake(() => {
- const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
- '"ccs":{"id2":{"error":"second error"}}}';
+ const text = '....{"reviewers":{"id1":{"error":"human readable"}}}';
return Promise.resolve(cloneableResponse(400, text));
});
- element.addEventListener('server-error', event => {
- if (event.target !== element) {
- return;
- }
- event.detail.response.text().then(body => {
- assert.equal(body, 'first error, second error');
- done();
- });
- });
+ let resolver;
+ const promise = new Promise(r => resolver = r);
+ element.addEventListener('server-error', resolver);
- // Async tick is needed because iron-selector content is distributed and
- // distributed content requires an observer to be set up.
- flush(() => { element.send(); });
+ await flush();
+ element.send();
+
+ const event = await promise;
+ assert.equal(event.target, element);
+ const text = await event.detail.response.text();
+ assert.equal(text, 'human readable');
});
test('non-json 400 is treated as a normal server-error', done => {
@@ -705,7 +849,7 @@
test('_focusOn', () => {
sinon.spy(element, '_chooseFocusTarget');
- flushAsynchronousOperations();
+ flush();
const textareaStub = sinon.stub(element.$.textarea, 'async');
const reviewerEntryStub = sinon.stub(element.$.reviewers.focusStart,
'async');
@@ -742,7 +886,7 @@
});
test('_chooseFocusTarget', () => {
- element._account = null;
+ element._account = undefined;
assert.strictEqual(
element._chooseFocusTarget(), element.FocusTarget.BODY);
@@ -811,8 +955,8 @@
const removeStub = sinon.stub(element, '_removeAccount');
const mock = function() {
element._reviewersPendingRemove = {
- test: [makeAccount()],
- test2: [makeAccount(), makeAccount()],
+ CC: [makeAccount()],
+ REVIEWER: [makeAccount(), makeAccount()],
};
};
const checkObjEmpty = function(obj) {
@@ -852,7 +996,7 @@
CC: [],
REVIEWER: [],
};
- flushAsynchronousOperations();
+ flush();
const reviewer1 = makeAccount();
const reviewer2 = makeAccount();
@@ -864,7 +1008,7 @@
element._reviewers = [reviewer1, reviewer2, reviewer3];
element._ccs = [cc1, cc2, cc3, cc4];
element.push('_reviewers', cc1);
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(element._reviewers,
[reviewer1, reviewer2, reviewer3, cc1]);
@@ -872,7 +1016,7 @@
assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
element.push('_reviewers', cc4, cc3);
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(element._reviewers,
[reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
@@ -880,12 +1024,64 @@
assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
});
+ test('update attention section when reviewers and ccs change', () => {
+ element._account = makeAccount();
+ element._reviewers = [makeAccount(), makeAccount()];
+ element._ccs = [makeAccount(), makeAccount()];
+ element.draftCommentThreads = [];
+ const modifyButton =
+ element.shadowRoot.querySelector('.edit-attention-button');
+ MockInteractions.tap(modifyButton);
+ flush();
+
+ // "Modify" button disabled, because "Send" button is disabled.
+ assert.isFalse(element._attentionExpanded);
+ element.draft = 'a test comment';
+ MockInteractions.tap(modifyButton);
+ flush();
+ assert.isTrue(element._attentionExpanded);
+
+ let accountLabels = Array.from(element.shadowRoot.querySelectorAll(
+ '.attention-detail gr-account-label'));
+ assert.equal(accountLabels.length, 5);
+
+ element.push('_reviewers', makeAccount());
+ element.push('_ccs', makeAccount());
+ flush();
+
+ // The 'attention modified' section collapses and resets when reviewers or
+ // ccs change.
+ assert.isFalse(element._attentionExpanded);
+
+ MockInteractions.tap(
+ element.shadowRoot.querySelector('.edit-attention-button'));
+ flush();
+
+ assert.isTrue(element._attentionExpanded);
+ accountLabels = Array.from(element.shadowRoot.querySelectorAll(
+ '.attention-detail gr-account-label'));
+ assert.equal(accountLabels.length, 7);
+
+ element.pop('_reviewers', makeAccount());
+ element.pop('_reviewers', makeAccount());
+ element.pop('_ccs', makeAccount());
+ element.pop('_ccs', makeAccount());
+
+ MockInteractions.tap(
+ element.shadowRoot.querySelector('.edit-attention-button'));
+ flush();
+
+ accountLabels = Array.from(element.shadowRoot.querySelectorAll(
+ '.attention-detail gr-account-label'));
+ assert.equal(accountLabels.length, 3);
+ });
+
test('moving from reviewer to cc', () => {
element._reviewersPendingRemove = {
CC: [],
REVIEWER: [],
};
- flushAsynchronousOperations();
+ flush();
const reviewer1 = makeAccount();
const reviewer2 = makeAccount();
@@ -897,7 +1093,7 @@
element._reviewers = [reviewer1, reviewer2, reviewer3];
element._ccs = [cc1, cc2, cc3, cc4];
element.push('_ccs', reviewer1);
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(element._reviewers,
[reviewer2, reviewer3]);
@@ -905,7 +1101,7 @@
assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
element.push('_ccs', reviewer3, reviewer2);
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(element._reviewers, []);
assert.deepEqual(element._ccs,
@@ -914,12 +1110,12 @@
[reviewer1, reviewer3, reviewer2]);
});
- test('migrate reviewers between states', done => {
+ test('migrate reviewers between states', async () => {
element._reviewersPendingRemove = {
CC: [],
REVIEWER: [],
};
- flushAsynchronousOperations();
+ flush();
const reviewers = element.$.reviewers;
const ccs = element.$.ccs;
const reviewer1 = makeAccount();
@@ -979,7 +1175,7 @@
composed: true, bubbles: true,
}));
const mapReviewer = function(reviewer, opt_state) {
- const result = {reviewer: reviewer._account_id, confirmed: undefined};
+ const result = {reviewer: reviewer._account_id};
if (opt_state) {
result.state = opt_state;
}
@@ -987,27 +1183,22 @@
};
// Send and purge and verify moves, delete cc3.
- element.send()
+ await element.send()
.then(keepReviewers =>
- element._purgeReviewersPendingRemove(false, keepReviewers))
- .then(() => {
- assert.deepEqual(
- mutations, [
- mapReviewer(cc1),
- mapReviewer(cc2),
- mapReviewer(reviewer1, 'CC'),
- mapReviewer(reviewer2, 'CC'),
- {account: cc3, state: 'REMOVED'},
- ]);
- done();
- });
+ element._purgeReviewersPendingRemove(false, keepReviewers));
+ expect(mutations).to.have.lengthOf(5);
+ expect(mutations[0]).to.deep.equal(mapReviewer(cc1));
+ expect(mutations[1]).to.deep.equal(mapReviewer(cc2));
+ expect(mutations[2]).to.deep.equal(mapReviewer(reviewer1, 'CC'));
+ expect(mutations[3]).to.deep.equal(mapReviewer(reviewer2, 'CC'));
+ expect(mutations[4]).to.deep.equal({account: cc3, state: 'REMOVED'});
});
test('emits cancel on esc key', () => {
const cancelHandler = sinon.spy();
element.addEventListener('cancel', cancelHandler);
MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(cancelHandler.called);
});
@@ -1016,14 +1207,14 @@
stubSaveReview(() => undefined);
element.addEventListener('send', () => assert.fail('wrongly called'));
MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
- flushAsynchronousOperations();
+ flush();
});
test('emit send on ctrl+enter key', done => {
stubSaveReview(() => undefined);
element.addEventListener('send', () => done());
MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
- flushAsynchronousOperations();
+ flush();
});
test('_computeMessagePlaceholder', () => {
@@ -1051,17 +1242,15 @@
const text = ')]}\'' + JSON.stringify({
reviewers: {
username1: {
- input: 'user 1',
+ input: 'username1',
error: error1,
},
username2: {
- input: 'user 2',
+ input: 'username2',
error: error2,
},
- },
- ccs: {
username3: {
- input: 'user 3',
+ input: 'username3',
error: error3,
},
},
@@ -1075,25 +1264,6 @@
element._handle400Error(cloneableResponse(400, text));
});
- test('_handle400Error CCs only', done => {
- const error1 = 'error 1';
- const text = ')]}\'' + JSON.stringify({
- ccs: {
- username1: {
- input: 'user 1',
- error: error1,
- },
- },
- });
- element.addEventListener('server-error', e => {
- e.detail.response.text().then(text => {
- assert.equal(text, error1);
- done();
- });
- });
- element._handle400Error(cloneableResponse(400, text));
- });
-
test('fires height change when the drafts comments load', done => {
// Flush DOM operations before binding to the autogrow event so we don't
// catch the events fired from the initial layout.
@@ -1145,20 +1315,20 @@
sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
element.canBeStarted = true;
// Flush to make both Start/Save buttons appear in DOM.
- flushAsynchronousOperations();
+ flush();
});
test('start review sets ready', () => {
MockInteractions.tap(element.shadowRoot
.querySelector('.send'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(sendStub.calledWith(true, true));
});
test('save review doesn\'t set ready', () => {
MockInteractions.tap(element.shadowRoot
.querySelector('.save'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(sendStub.calledWith(true, false));
});
});
@@ -1235,8 +1405,7 @@
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1251,24 +1420,7 @@
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
- ));
- });
-
- test('_computeSendButtonDisabled_attentionModified true', () => {
- const fn = element._computeSendButtonDisabled.bind(element);
- // Mock everything false
- assert.isFalse(fn(
- /* canBeStarted= */ false,
- /* draftCommentThreads= */ [],
- /* text= */ '',
- /* reviewersMutated= */ false,
- /* labelsChanged= */ false,
- /* includeComments= */ false,
- /* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ true
+ /* commentEditing= */ false
));
});
@@ -1283,8 +1435,7 @@
/* labelsChanged= */ false,
/* includeComments= */ true,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1299,8 +1450,7 @@
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1315,8 +1465,7 @@
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1331,8 +1480,7 @@
/* labelsChanged= */ false,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1347,8 +1495,7 @@
/* labelsChanged= */ true,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
});
@@ -1363,8 +1510,7 @@
/* labelsChanged= */ true,
/* includeComments= */ false,
/* disabled= */ true,
- /* commentEditing= */ false,
- /* attentionModified= */ false
+ /* commentEditing= */ false
));
assert.isTrue(fn(
/* buttonLabel= */ 'Send',
@@ -1374,8 +1520,7 @@
/* labelsChanged= */ true,
/* includeComments= */ false,
/* disabled= */ false,
- /* commentEditing= */ true,
- /* attentionModified= */ false
+ /* commentEditing= */ true
));
});
@@ -1385,14 +1530,16 @@
// resolving.
sinon.stub(element, '_purgeReviewersPendingRemove');
element.draftCommentThreads = [];
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('gr-button.send'));
assert.isFalse(sendStub.called);
- element.draftCommentThreads = [{comments: [{__draft: true}]}];
- flushAsynchronousOperations();
+ element.draftCommentThreads = [{comments: [
+ {__draft: true, path: 'test', line: 1, patch_set: 1},
+ ]}];
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('gr-button.send'));
@@ -1404,7 +1551,9 @@
// computed to false.
element.draftCommentThreads = [];
assert.equal(element.getFocusStops().end, element.$.cancelButton);
- element.draftCommentThreads = [{comments: [{__draft: true}]}];
+ element.draftCommentThreads = [{comments: [
+ {__draft: true, path: 'test', line: 1, patch_set: 1},
+ ]}];
assert.equal(element.getFocusStops().end, element.$.sendButton);
});
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
deleted file mode 100644
index 0c48aca..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ /dev/null
@@ -1,293 +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 '../../shared/gr-account-chip/gr-account-chip.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-reviewer-list_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrReviewerList extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-reviewer-list'; }
- /**
- * Fired when the "Add reviewer..." button is tapped.
- *
- * @event show-reply-dialog
- */
-
- static get properties() {
- return {
- change: Object,
- serverConfig: Object,
- disabled: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- mutable: {
- type: Boolean,
- value: false,
- },
- reviewersOnly: {
- type: Boolean,
- value: false,
- },
- ccsOnly: {
- type: Boolean,
- value: false,
- },
-
- _displayedReviewers: {
- type: Array,
- value() { return []; },
- },
- _reviewers: {
- type: Array,
- value() { return []; },
- },
- _showInput: {
- type: Boolean,
- value: false,
- },
- _addLabel: {
- type: String,
- computed: '_computeAddLabel(ccsOnly)',
- },
- _hiddenReviewerCount: {
- type: Number,
- computed: '_computeHiddenCount(_reviewers, _displayedReviewers)',
- },
-
- // Used for testing.
- _lastAutocompleteRequest: Object,
- _xhrPromise: Object,
- };
- }
-
- static get observers() {
- return [
- '_reviewersChanged(change.reviewers.*, change.owner, serverConfig)',
- ];
- }
-
- /**
- * Converts change.permitted_labels to an array of hashes of label keys to
- * numeric scores.
- * Example:
- * [{
- * 'Code-Review': ['-1', ' 0', '+1']
- * }]
- * will be converted to
- * [{
- * label: 'Code-Review',
- * scores: [-1, 0, 1]
- * }]
- */
- _permittedLabelsToNumericScores(labels) {
- if (!labels) return [];
- return Object.keys(labels).map(label => {
- return {
- label,
- scores: labels[label].map(v => parseInt(v, 10)),
- };
- });
- }
-
- /**
- * Returns hash of labels to max permitted score.
- *
- * @param {!Object} change
- * @returns {!Object} labels to max permitted scores hash
- */
- _getMaxPermittedScores(change) {
- return this._permittedLabelsToNumericScores(change.permitted_labels)
- .map(({label, scores}) => {
- return {
- [label]: scores
- .map(v => parseInt(v, 10))
- .reduce((a, b) => Math.max(a, b))};
- })
- .reduce((acc, i) => Object.assign(acc, i), {});
- }
-
- /**
- * Returns max permitted score for reviewer.
- *
- * @param {!Object} reviewer
- * @param {!Object} change
- * @param {string} label
- * @return {number}
- */
- _getReviewerPermittedScore(reviewer, change, label) {
- // Note (issue 7874): sometimes the "all" list is not included in change
- // detail responses, even when DETAILED_LABELS is included in options.
- if (!change.labels[label].all) { return NaN; }
- const detailed = change.labels[label].all.filter(
- ({_account_id}) => reviewer._account_id === _account_id).pop();
- if (!detailed) {
- return NaN;
- }
- if (detailed.hasOwnProperty('permitted_voting_range')) {
- return detailed.permitted_voting_range.max;
- } else if (detailed.hasOwnProperty('value')) {
- // If preset, user can vote on the label.
- return 0;
- }
- return NaN;
- }
-
- _computeVoteableText(reviewer, change) {
- if (!change || !change.labels) { return ''; }
- const maxScores = [];
- const maxPermitted = this._getMaxPermittedScores(change);
- for (const label of Object.keys(change.labels)) {
- const maxScore =
- this._getReviewerPermittedScore(reviewer, change, label);
- if (isNaN(maxScore) || maxScore < 0) { continue; }
- if (maxScore > 0 && maxScore === maxPermitted[label]) {
- maxScores.push(`${label}: +${maxScore}`);
- } else {
- maxScores.push(`${label}`);
- }
- }
- return maxScores.join(', ');
- }
-
- _reviewersChanged(changeRecord, owner, serverConfig) {
- // Polymer 2: check for undefined
- if ([changeRecord, owner, serverConfig].includes(undefined)) {
- return;
- }
-
- let result = [];
- const reviewers = changeRecord.base;
- for (const key in reviewers) {
- if (this.reviewersOnly && key !== 'REVIEWER') {
- continue;
- }
- if (this.ccsOnly && key !== 'CC') {
- continue;
- }
- if (key === 'REVIEWER' || key === 'CC') {
- result = result.concat(reviewers[key]);
- }
- }
- this._reviewers = result
- .filter(reviewer => reviewer._account_id != owner._account_id);
-
- const isFirstNameConfigured = serverConfig.accounts
- && serverConfig.accounts.default_display_name === 'FIRST_NAME';
- const maxReviewers = isFirstNameConfigured ? 6 : 3;
- // If there is one or two more than the max reviewers, don't show the
- // 'show more' button, because it takes up just as much space.
- if (this._reviewers.length > maxReviewers + 2) {
- this._displayedReviewers = this._reviewers.slice(0, maxReviewers);
- } else {
- this._displayedReviewers = this._reviewers;
- }
- }
-
- _computeHiddenCount(reviewers, displayedReviewers) {
- // Polymer 2: check for undefined
- if ([reviewers, displayedReviewers].includes(undefined)) {
- return undefined;
- }
-
- return reviewers.length - displayedReviewers.length;
- }
-
- _computeCanRemoveReviewer(reviewer, mutable) {
- if (!mutable) { return false; }
-
- let current;
- for (let i = 0; i < this.change.removable_reviewers.length; i++) {
- current = this.change.removable_reviewers[i];
- if (current._account_id === reviewer._account_id ||
- (!reviewer._account_id && current.email === reviewer.email)) {
- return true;
- }
- }
- return false;
- }
-
- _handleRemove(e) {
- e.preventDefault();
- const target = dom(e).rootTarget;
- if (!target.account) { return; }
- const accountID = target.account._account_id || target.account.email;
- this.disabled = true;
- this._xhrPromise = this._removeReviewer(accountID).then(response => {
- this.disabled = false;
- if (!response.ok) { return response; }
-
- const reviewers = this.change.reviewers;
-
- for (const type of ['REVIEWER', 'CC']) {
- reviewers[type] = reviewers[type] || [];
- for (let i = 0; i < reviewers[type].length; i++) {
- if (reviewers[type][i]._account_id == accountID ||
- reviewers[type][i].email == accountID) {
- this.splice('change.reviewers.' + type, i, 1);
- break;
- }
- }
- }
- })
- .catch(err => {
- this.disabled = false;
- throw err;
- });
- }
-
- _handleAddTap(e) {
- e.preventDefault();
- const value = {};
- if (this.reviewersOnly) {
- value.reviewersOnly = true;
- }
- if (this.ccsOnly) {
- value.ccsOnly = true;
- }
- this.dispatchEvent(new CustomEvent('show-reply-dialog', {
- detail: {value},
- composed: true, bubbles: true,
- }));
- }
-
- _handleViewAll(e) {
- this._displayedReviewers = this._reviewers;
- }
-
- _removeReviewer(id) {
- return this.$.restAPI.removeChangeReviewer(this.change._number, id);
- }
-
- _computeAddLabel(ccsOnly) {
- return ccsOnly ? 'Add CC' : 'Add reviewer';
- }
-}
-
-customElements.define(GrReviewerList.is, GrReviewerList);
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
new file mode 100644
index 0000000..9c3fa42
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.ts
@@ -0,0 +1,328 @@
+/**
+ * @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 '../../shared/gr-account-chip/gr-account-chip';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-reviewer-list_html';
+import {isServiceUser} from '../../../utils/account-util';
+import {hasAttention} from '../../../utils/attention-set-util';
+import {customElement, property, computed, observe} from '@polymer/decorators';
+import {
+ ChangeInfo,
+ ServerInfo,
+ LabelNameToValueMap,
+ AccountInfo,
+ ApprovalInfo,
+ Reviewers,
+ AccountId,
+ DetailedLabelInfo,
+ EmailAddress,
+} from '../../../types/common';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {isRemovableReviewer} from '../../../utils/change-util';
+import {ReviewerState} from '../../../constants/constants';
+
+export interface GrReviewerList {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-reviewer-list')
+export class GrReviewerList extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the "Add reviewer..." button is tapped.
+ *
+ * @event show-reply-dialog
+ */
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Object})
+ serverConfig?: ServerInfo;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ disabled = false;
+
+ @property({type: Boolean})
+ mutable = false;
+
+ @property({type: Boolean})
+ reviewersOnly = false;
+
+ @property({type: Boolean})
+ ccsOnly = false;
+
+ @property({type: Array})
+ _displayedReviewers: AccountInfo[] = [];
+
+ @property({type: Array})
+ _reviewers: AccountInfo[] = [];
+
+ @property({type: Boolean})
+ _showInput = false;
+
+ @property({type: Object})
+ _xhrPromise?: Promise<Response | undefined>;
+
+ @computed('ccsOnly')
+ get _addLabel() {
+ return this.ccsOnly ? 'Add CC' : 'Add reviewer';
+ }
+
+ @computed('_reviewers', '_displayedReviewers')
+ get _hiddenReviewerCount() {
+ // Polymer 2: check for undefined
+ if (
+ this._reviewers === undefined ||
+ this._displayedReviewers === undefined
+ ) {
+ return undefined;
+ }
+ return this._reviewers.length - this._displayedReviewers.length;
+ }
+
+ /**
+ * Converts change.permitted_labels to an array of hashes of label keys to
+ * numeric scores.
+ * Example:
+ * [{
+ * 'Code-Review': ['-1', ' 0', '+1']
+ * }]
+ * will be converted to
+ * [{
+ * label: 'Code-Review',
+ * scores: [-1, 0, 1]
+ * }]
+ */
+ _permittedLabelsToNumericScores(labels: LabelNameToValueMap | undefined) {
+ if (!labels) return [];
+ return Object.keys(labels).map(label => {
+ return {
+ label,
+ scores: labels[label].map(v => Number(v)),
+ };
+ });
+ }
+
+ /**
+ * Returns hash of labels to max permitted score.
+ *
+ * @returns labels to max permitted scores hash
+ */
+ _getMaxPermittedScores(change: ChangeInfo) {
+ return this._permittedLabelsToNumericScores(change.permitted_labels)
+ .map(({label, scores}) => {
+ return {
+ [label]: scores.reduce((a, b) => Math.max(a, b)),
+ };
+ })
+ .reduce((acc, i) => Object.assign(acc, i), {});
+ }
+
+ /**
+ * Returns max permitted score for reviewer.
+ */
+ _getReviewerPermittedScore(
+ reviewer: AccountInfo,
+ change: ChangeInfo,
+ label: string
+ ) {
+ // Note (issue 7874): sometimes the "all" list is not included in change
+ // detail responses, even when DETAILED_LABELS is included in options.
+ if (!change.labels) {
+ return NaN;
+ }
+ const detailedLabel = change.labels[label] as DetailedLabelInfo;
+ if (!detailedLabel.all) {
+ return NaN;
+ }
+ const detailed = detailedLabel.all
+ .filter(
+ (approval: ApprovalInfo) =>
+ reviewer._account_id === approval._account_id
+ )
+ .pop();
+ if (!detailed) {
+ return NaN;
+ }
+ if (hasOwnProperty(detailed, 'permitted_voting_range')) {
+ if (!detailed.permitted_voting_range) return NaN;
+ return detailed.permitted_voting_range.max;
+ } else if (hasOwnProperty(detailed, 'value')) {
+ // If preset, user can vote on the label.
+ return 0;
+ }
+ return NaN;
+ }
+
+ _computeVoteableText(reviewer: AccountInfo, change: ChangeInfo) {
+ if (!change || !change.labels) {
+ return '';
+ }
+ const maxScores = [];
+ const maxPermitted = this._getMaxPermittedScores(change);
+ for (const label of Object.keys(change.labels)) {
+ const maxScore = this._getReviewerPermittedScore(reviewer, change, label);
+ if (isNaN(maxScore) || maxScore < 0) {
+ continue;
+ }
+ if (maxScore > 0 && maxScore === maxPermitted[label]) {
+ maxScores.push(`${label}: +${maxScore}`);
+ } else {
+ maxScores.push(`${label}`);
+ }
+ }
+ return maxScores.join(', ');
+ }
+
+ @observe('change.reviewers.*', 'change.owner', 'serverConfig')
+ _reviewersChanged(
+ changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
+ owner: AccountInfo,
+ serverConfig: ServerInfo
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ changeRecord === undefined ||
+ owner === undefined ||
+ serverConfig === undefined ||
+ this.change === undefined
+ ) {
+ return;
+ }
+ let result: AccountInfo[] = [];
+ const reviewers = changeRecord.base;
+ for (const key in reviewers) {
+ if (this.reviewersOnly && key !== 'REVIEWER') {
+ continue;
+ }
+ if (this.ccsOnly && key !== 'CC') {
+ continue;
+ }
+ if (key === 'REVIEWER' || key === 'CC') {
+ result = result.concat(reviewers[key]!);
+ }
+ }
+ this._reviewers = result
+ .filter(reviewer => reviewer._account_id !== owner._account_id)
+ // Sort order:
+ // 1. Human users in the attention set.
+ // 2. Other human users.
+ // 3. Service users.
+ .sort((r1, r2) => {
+ const a1 = hasAttention(serverConfig, r1, this.change!) ? 1 : 0;
+ const a2 = hasAttention(serverConfig, r2, this.change!) ? 1 : 0;
+ const s1 = isServiceUser(r1) ? -2 : 0;
+ const s2 = isServiceUser(r2) ? -2 : 0;
+ return a2 - a1 + s2 - s1;
+ });
+
+ if (this._reviewers.length > 8) {
+ this._displayedReviewers = this._reviewers.slice(0, 6);
+ } else {
+ this._displayedReviewers = this._reviewers;
+ }
+ }
+
+ _computeCanRemoveReviewer(reviewer: AccountInfo, mutable: boolean) {
+ return mutable && isRemovableReviewer(this.change, reviewer);
+ }
+
+ _handleRemove(e: Event) {
+ e.preventDefault();
+ const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
+ if (!target.account || !this.change) {
+ return;
+ }
+ const accountID = target.account._account_id || target.account.email;
+ this.disabled = true;
+ if (!accountID) return;
+ this._xhrPromise = this._removeReviewer(accountID)
+ .then((response: Response | undefined) => {
+ this.disabled = false;
+ if (!response || !response.ok) {
+ return response;
+ }
+ if (!this.change || !this.change.reviewers) return;
+ const reviewers = this.change.reviewers;
+ for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+ const reviewerStateByType = reviewers[type] || [];
+ reviewers[type] = reviewerStateByType;
+ for (let i = 0; i < reviewerStateByType.length; i++) {
+ if (
+ reviewerStateByType[i]._account_id === accountID ||
+ reviewerStateByType[i].email === accountID
+ ) {
+ this.splice('change.reviewers.' + type, i, 1);
+ break;
+ }
+ }
+ }
+ return;
+ })
+ .catch((err: Error) => {
+ this.disabled = false;
+ throw err;
+ });
+ }
+
+ _handleAddTap(e: Event) {
+ e.preventDefault();
+ const value = {
+ reviewersOnly: false,
+ ccsOnly: false,
+ };
+ if (this.reviewersOnly) {
+ value.reviewersOnly = true;
+ }
+ if (this.ccsOnly) {
+ value.ccsOnly = true;
+ }
+ this.dispatchEvent(
+ new CustomEvent('show-reply-dialog', {
+ detail: {value},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _handleViewAll() {
+ this._displayedReviewers = this._reviewers;
+ }
+
+ _removeReviewer(id: AccountId | EmailAddress): Promise<Response | undefined> {
+ if (!this.change) return Promise.resolve(undefined);
+ return this.$.restAPI.removeChangeReviewer(this.change._number, id);
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
index 809a768..d29abfc 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-reviewer-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-reviewer-list');
@@ -97,9 +96,9 @@
},
],
};
- flushAsynchronousOperations();
+ flush();
const chips =
- dom(element.root).querySelectorAll('gr-account-chip');
+ element.root.querySelectorAll('gr-account-chip');
assert.equal(chips.length, 4);
for (const el of Array.from(chips)) {
@@ -117,6 +116,101 @@
}
});
+ suite('_handleRemove', () => {
+ let removeReviewerStub;
+ let reviewersChangedSpy;
+
+ const reviewerWithId = {
+ _account_id: 2,
+ name: 'Some name',
+ };
+
+ const reviewerWithIdAndEmail = {
+ _account_id: 4,
+ name: 'Some other name',
+ email: 'example@',
+ };
+
+ const reviewerWithEmailOnly = {
+ email: 'example2@example',
+ };
+
+ let chips;
+
+ setup(() => {
+ removeReviewerStub = sinon
+ .stub(element, '_removeReviewer')
+ .returns(Promise.resolve(new Response({status: 200})));
+ element.mutable = true;
+
+ const allReviewers = [
+ reviewerWithId,
+ reviewerWithIdAndEmail,
+ reviewerWithEmailOnly,
+ ];
+
+ element.change = {
+ owner: {
+ _account_id: 1,
+ },
+ reviewers: {
+ REVIEWER: allReviewers,
+ },
+ removable_reviewers: allReviewers,
+ };
+ flush();
+ chips = Array.from(element.root.querySelectorAll('gr-account-chip'));
+ assert.equal(chips.length, allReviewers.length);
+ reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
+ });
+
+ test('_handleRemove for account with accountId only', async () => {
+ const accountChip = chips.find(chip =>
+ chip.account._account_id === reviewerWithId._account_id
+ );
+ accountChip._handleRemoveTap(new MouseEvent('click'));
+ await flush();
+ assert.isTrue(removeReviewerStub.calledOnce);
+ assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
+ assert.isTrue(reviewersChangedSpy.called);
+ expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+ reviewerWithIdAndEmail,
+ reviewerWithEmailOnly,
+ ]);
+ });
+
+ test('_handleRemove for account with accountId and email', async () => {
+ const accountChip = chips.find(chip =>
+ chip.account._account_id === reviewerWithIdAndEmail._account_id
+ );
+ accountChip._handleRemoveTap(new MouseEvent('click'));
+ await flush();
+ assert.isTrue(removeReviewerStub.calledOnce);
+ assert.isTrue(
+ removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id));
+ assert.isTrue(reviewersChangedSpy.called);
+ expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+ reviewerWithId,
+ reviewerWithEmailOnly,
+ ]);
+ });
+
+ test('_handleRemove for account with email only', async () => {
+ const accountChip = chips.find(
+ chip => chip.account.email === reviewerWithEmailOnly.email
+ );
+ accountChip._handleRemoveTap(new MouseEvent('click'));
+ await flush();
+ assert.isTrue(removeReviewerStub.calledOnce);
+ assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
+ assert.isTrue(reviewersChangedSpy.called);
+ expect(element.change.reviewers.REVIEWER).to.have.deep.members([
+ reviewerWithId,
+ reviewerWithIdAndEmail,
+ ]);
+ });
+ });
+
test('tracking reviewers and ccs', () => {
let counter = 0;
function makeAccount() {
@@ -164,21 +258,24 @@
element.reviewersOnly = false;
element._handleAddTap(e);
assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
- assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {}});
+ assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {
+ reviewersOnly: false,
+ ccsOnly: false,
+ }});
element.reviewersOnly = true;
element._handleAddTap(e);
assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
assert.deepEqual(
fireStub.lastCall.args[0].detail,
- {value: {reviewersOnly: true}});
+ {value: {reviewersOnly: true, ccsOnly: false}});
element.ccsOnly = true;
element.reviewersOnly = false;
element._handleAddTap(e);
assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
assert.deepEqual(fireStub.lastCall.args[0].detail,
- {value: {ccsOnly: true}});
+ {value: {ccsOnly: true, reviewersOnly: false}});
});
test('dont show all reviewers button with 4 reviewers', () => {
@@ -205,9 +302,9 @@
.querySelector('.hiddenReviewers').hidden);
});
- test('show all reviewers button with 6 reviewers', () => {
+ test('show all reviewers button with 9 reviewers', () => {
const reviewers = [];
- for (let i = 0; i < 6; i++) {
+ for (let i = 0; i < 9; i++) {
reviewers.push(
{email: i+'reviewer@google.com', name: 'reviewer-' + i});
}
@@ -222,8 +319,8 @@
},
};
assert.equal(element._hiddenReviewerCount, 3);
- assert.equal(element._displayedReviewers.length, 3);
- assert.equal(element._reviewers.length, 6);
+ assert.equal(element._displayedReviewers.length, 6);
+ assert.equal(element._reviewers.length, 9);
assert.isFalse(element.shadowRoot
.querySelector('.hiddenReviewers').hidden);
});
@@ -244,8 +341,8 @@
CC: reviewers,
},
};
- assert.equal(element._hiddenReviewerCount, 97);
- assert.equal(element._displayedReviewers.length, 3);
+ assert.equal(element._hiddenReviewerCount, 94);
+ assert.equal(element._displayedReviewers.length, 6);
assert.equal(element._reviewers.length, 100);
assert.isFalse(element.shadowRoot
.querySelector('.hiddenReviewers').hidden);
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
deleted file mode 100644
index 28a8d9a..0000000
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.js
+++ /dev/null
@@ -1,308 +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/paper-toggle-button/paper-toggle-button.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-comment-thread/gr-comment-thread.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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-thread-list_html.js';
-import {parseDate} from '../../../utils/date-util.js';
-
-import {NO_THREADS_MSG} from '../../../constants/messages.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-
-/**
- * Fired when a comment is saved or deleted
- *
- * @event thread-list-modified
- * @extends PolymerElement
- */
-class GrThreadList extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-thread-list'; }
-
- static get properties() {
- return {
- /** @type {?} */
- change: Object,
- threads: Array,
- changeNum: String,
- loggedIn: Boolean,
- _sortedThreads: {
- type: Array,
- },
- _unresolvedOnly: {
- type: Boolean,
- value: false,
- },
- _draftsOnly: {
- type: Boolean,
- value: false,
- },
- /* Boolean properties used must default to false if passed as attribute
- by the parent */
- onlyShowRobotCommentsWithHumanReply: {
- type: Boolean,
- value: false,
- },
- hideToggleButtons: {
- type: Boolean,
- value: false,
- },
- emptyThreadMsg: {
- type: String,
- value: NO_THREADS_MSG,
- },
- };
- }
-
- static get observers() {
- return ['_updateSortedThreads(threads, threads.splices)'];
- }
-
- _computeShowDraftToggle(loggedIn) {
- return loggedIn ? 'show' : '';
- }
-
- _compareThreads(c1, c2) {
- if (c1.thread.path !== c2.thread.path) {
- // '/PATCHSET' will not come before '/COMMIT' when sorting
- // alphabetically so move it to the front explicitly
- if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return -1;
- }
- if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return 1;
- }
- return c1.thread.path.localeCompare(c2.thread.path);
- }
-
- // Patchset comments have no line/range associated with them
- if (c1.thread.line !== c2.thread.line) {
- if (!c1.thread.line || !c2.thread.line) {
- // one of them is a file level comment, show first
- return c1.thread.line ? 1 : -1;
- }
- return c1.thread.line < c2.thread.line ? -1 : 1;
- }
-
- if (c1.thread.patchNum !== c2.thread.patchNum) {
- return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
- }
-
- if (c2.unresolved !== c1.unresolved) {
- if (!c1.unresolved) { return 1; }
- if (!c2.unresolved) { return -1; }
- }
-
- if (c2.hasDraft !== c1.hasDraft) {
- if (!c1.hasDraft) { return 1; }
- if (!c2.hasDraft) { return -1; }
- }
-
- const c1Date = c1.__date || parseDate(c1.updated);
- const c2Date = c2.__date || parseDate(c2.updated);
- const dateCompare = c2Date - c1Date;
- if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) {
- return 0;
- }
- return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
- }
-
- /**
- * Observer on threads and update _sortedThreads when needed.
- * Order as follows:
- * - Patchset level threads (descending based on patchset number)
- * - unresolved
- - comments with drafts
- - comments without drafts
- * - resolved
- - comments with drafts
- - comments without drafts
- * - File name
- * - Line number
- * - Unresolved (descending based on patchset number)
- * - comments with drafts
- * - comments without drafts
- * - Resolved (descending based on patchset number)
- * - comments with drafts
- * - comments without drafts
- *
- * @param {Array<Object>} threads
- * @param {!Object} spliceRecord
- */
- _updateSortedThreads(threads, spliceRecord) {
- if (!threads) {
- this._sortedThreads = [];
- return;
- }
- // We only want to sort on thread additions / removals to avoid
- // re-rendering on modifications (add new reply / edit draft etc)
- // https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
- const isArrayMutation = spliceRecord &&
- (spliceRecord.indexSplices.addedCount !== 0
- || spliceRecord.indexSplices.removed.length);
-
- if (this._sortedThreads
- && this._sortedThreads.length === threads.length
- && !isArrayMutation) {
- // Instead of replacing the _sortedThreads which will trigger a re-render,
- // we override all threads inside of it
-
- for (const thread of threads) {
- const idxInSortedThreads = this._sortedThreads
- .findIndex(t => t.rootId === thread.rootId);
- this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread});
- }
- return;
- }
-
- const threadsWithInfo = threads
- .map(thread => this._getThreadWithStatusInfo(thread));
- this._sortedThreads = threadsWithInfo.sort((t1, t2) =>
- this._compareThreads(t1, t2)).map(threadInfo => threadInfo.thread);
- }
-
- _isFirstThreadWithFileName(sortedThreads, thread, unresolvedOnly, draftsOnly,
- onlyShowRobotCommentsWithHumanReply) {
- const threads = sortedThreads.filter(t => this._shouldShowThread(
- t, unresolvedOnly, draftsOnly,
- onlyShowRobotCommentsWithHumanReply));
- const index = threads.findIndex(t => t.rootId === thread.rootId);
- if (index === -1) {
- return false;
- }
- return index === 0 || (threads[index - 1].path !== threads[index].path);
- }
-
- _shouldRenderSeparator(sortedThreads, thread, unresolvedOnly, draftsOnly,
- onlyShowRobotCommentsWithHumanReply) {
- const threads = sortedThreads.filter(t => this._shouldShowThread(
- t, unresolvedOnly, draftsOnly,
- onlyShowRobotCommentsWithHumanReply));
- const index = threads.findIndex(t => t.rootId === thread.rootId);
- if (index === -1) {
- return false;
- }
- return index > 0 && this._isFirstThreadWithFileName(sortedThreads,
- thread, unresolvedOnly, draftsOnly,
- onlyShowRobotCommentsWithHumanReply);
- }
-
- _shouldShowThread(thread, unresolvedOnly, draftsOnly,
- onlyShowRobotCommentsWithHumanReply) {
- if ([
- thread,
- unresolvedOnly,
- draftsOnly,
- onlyShowRobotCommentsWithHumanReply,
- ].includes(undefined)) {
- return false;
- }
-
- if (!draftsOnly
- && !unresolvedOnly
- && !onlyShowRobotCommentsWithHumanReply) {
- return true;
- }
-
- const threadInfo = this._getThreadWithStatusInfo(thread);
-
- if (threadInfo.isEditing) {
- return true;
- }
-
- if (threadInfo.hasRobotComment
- && onlyShowRobotCommentsWithHumanReply
- && !threadInfo.hasHumanReplyToRobotComment) {
- return false;
- }
-
- let filtersCheck = true;
- if (draftsOnly && unresolvedOnly) {
- filtersCheck = threadInfo.hasDraft && threadInfo.unresolved;
- } else if (draftsOnly) {
- filtersCheck = threadInfo.hasDraft;
- } else if (unresolvedOnly) {
- filtersCheck = threadInfo.unresolved;
- }
-
- return filtersCheck;
- }
-
- _getThreadWithStatusInfo(thread) {
- const comments = thread.comments;
- const lastComment = comments[comments.length - 1] || {};
- let hasRobotComment = false;
- let hasHumanReplyToRobotComment = false;
- comments.forEach(comment => {
- if (comment.robot_id) {
- hasRobotComment = true;
- } else if (hasRobotComment) {
- hasHumanReplyToRobotComment = true;
- }
- });
-
- return {
- thread,
- hasRobotComment,
- hasHumanReplyToRobotComment,
- unresolved: !!lastComment.unresolved,
- isEditing: !!lastComment.__editing,
- hasDraft: !!lastComment.__draft,
- updated: lastComment.updated || lastComment.__date,
- };
- }
-
- removeThread(rootId) {
- for (let i = 0; i < this.threads.length; i++) {
- if (this.threads[i].rootId === rootId) {
- this.splice('threads', i, 1);
- // Needed to ensure threads get re-rendered in the correct order.
- flush();
- return;
- }
- }
- }
-
- _handleThreadDiscard(e) {
- this.removeThread(e.detail.rootId);
- }
-
- _handleCommentsChanged(e) {
- this.dispatchEvent(new CustomEvent('thread-list-modified',
- {detail: {rootId: e.detail.rootId, path: e.detail.path}}));
- }
-
- _isOnParent(side) {
- return !!side;
- }
-
- /**
- * Work around a issue on iOS when clicking turns into double tap
- */
- _onTapUnresolvedToggle(e) {
- e.preventDefault();
- }
-}
-
-customElements.define(GrThreadList.is, GrThreadList);
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
new file mode 100644
index 0000000..6a32834
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list.ts
@@ -0,0 +1,449 @@
+/**
+ * @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/paper-toggle-button/paper-toggle-button';
+import '../../../styles/shared-styles';
+import '../../shared/gr-comment-thread/gr-comment-thread';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-thread-list_html';
+import {parseDate} from '../../../utils/date-util';
+
+import {CommentSide, SpecialFilePath} from '../../../constants/constants';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+ PolymerSpliceChange,
+ PolymerDeepPropertyChange,
+} from '@polymer/polymer/interfaces';
+import {ChangeInfo} from '../../../types/common';
+import {CommentThread, isDraft, UIRobot} from '../../../utils/comment-util';
+
+interface CommentThreadWithInfo {
+ thread: CommentThread;
+ hasRobotComment: boolean;
+ hasHumanReplyToRobotComment: boolean;
+ unresolved: boolean;
+ isEditing: boolean;
+ hasDraft: boolean;
+ updated?: Date;
+}
+
+@customElement('gr-thread-list')
+export class GrThreadList extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Array})
+ threads: CommentThread[] = [];
+
+ @property({type: String})
+ changeNum?: string;
+
+ @property({type: Boolean})
+ loggedIn?: boolean;
+
+ @property({type: Array})
+ _sortedThreads: CommentThread[] = [];
+
+ @property({
+ computed:
+ '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
+ '_draftsOnly, onlyShowRobotCommentsWithHumanReply)',
+ type: Array,
+ })
+ _displayedThreads: CommentThread[] = [];
+
+ // thread-list is used in multiple places like the change log, hence
+ // keeping the default to be false. When used in comments tab, it's
+ // set as true.
+ @property({type: Boolean})
+ unresolvedOnly = false;
+
+ @property({type: Boolean})
+ _draftsOnly = false;
+
+ @property({type: Boolean})
+ onlyShowRobotCommentsWithHumanReply = false;
+
+ @property({type: Boolean})
+ hideToggleButtons = false;
+
+ _computeShowDraftToggle(loggedIn?: boolean) {
+ return loggedIn ? 'show' : '';
+ }
+
+ _showEmptyThreadsMessage(
+ threads: CommentThread[],
+ displayedThreads: CommentThread[],
+ unresolvedOnly: boolean
+ ) {
+ if (!threads || !displayedThreads) return false;
+ return !threads.length || (unresolvedOnly && !displayedThreads.length);
+ }
+
+ _computeEmptyThreadsMessage(threads: CommentThread[]) {
+ return !threads.length ? 'No comments.' : 'No unresolved comments';
+ }
+
+ _showPartyPopper(threads: CommentThread[]) {
+ return !!threads.length;
+ }
+
+ _computeResolvedCommentsMessage(
+ threads: CommentThread[],
+ displayedThreads: CommentThread[],
+ unresolvedOnly: boolean
+ ) {
+ if (unresolvedOnly && threads.length && !displayedThreads.length) {
+ return (
+ `Show ${threads.length} resolved comment` +
+ (threads.length > 1 ? 's' : '')
+ );
+ }
+ return '';
+ }
+
+ _showResolvedCommentsButton(
+ threads: CommentThread[],
+ displayedThreads: CommentThread[],
+ unresolvedOnly: boolean
+ ) {
+ return unresolvedOnly && threads.length && !displayedThreads.length;
+ }
+
+ _handleResolvedCommentsMessageClick() {
+ this.unresolvedOnly = !this.unresolvedOnly;
+ }
+
+ _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
+ if (c1.thread.path !== c2.thread.path) {
+ // '/PATCHSET' will not come before '/COMMIT' when sorting
+ // alphabetically so move it to the front explicitly
+ if (c1.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+ return -1;
+ }
+ if (c2.thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+ return 1;
+ }
+ return c1.thread.path.localeCompare(c2.thread.path);
+ }
+
+ // Patchset comments have no line/range associated with them
+ if (c1.thread.line !== c2.thread.line) {
+ if (!c1.thread.line || !c2.thread.line) {
+ // one of them is a file level comment, show first
+ return c1.thread.line ? 1 : -1;
+ }
+ return c1.thread.line < c2.thread.line ? -1 : 1;
+ }
+
+ if (c1.thread.patchNum !== c2.thread.patchNum) {
+ if (!c1.thread.patchNum) return 1;
+ if (!c2.thread.patchNum) return -1;
+ // TODO(TS): Explicit comparison for 'edit' and 'PARENT' missing?
+ return c1.thread.patchNum > c2.thread.patchNum ? -1 : 1;
+ }
+
+ if (c2.unresolved !== c1.unresolved) {
+ if (!c1.unresolved) return 1;
+ if (!c2.unresolved) return -1;
+ }
+
+ if (c2.hasDraft !== c1.hasDraft) {
+ if (!c1.hasDraft) return 1;
+ if (!c2.hasDraft) return -1;
+ }
+
+ if (c2.updated !== c1.updated) {
+ if (!c1.updated) return 1;
+ if (!c2.updated) return -1;
+ return c2.updated.getTime() - c1.updated.getTime();
+ }
+
+ if (c2.thread.rootId !== c1.thread.rootId) {
+ if (!c1.thread.rootId) return 1;
+ if (!c2.thread.rootId) return -1;
+ return c1.thread.rootId.localeCompare(c2.thread.rootId);
+ }
+
+ return 0;
+ }
+
+ /**
+ * Observer on threads and update _sortedThreads when needed.
+ * Order as follows:
+ * - Patchset level threads (descending based on patchset number)
+ * - unresolved
+ * - comments with drafts
+ * - comments without drafts
+ * - resolved
+ * - comments with drafts
+ * - comments without drafts
+ * - File name
+ * - Line number
+ * - Unresolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ * - Resolved (descending based on patchset number)
+ * - comments with drafts
+ * - comments without drafts
+ *
+ * @param threads
+ * @param spliceRecord
+ */
+ @observe('threads', 'threads.splices')
+ _updateSortedThreads(
+ threads: CommentThread[],
+ _: PolymerSpliceChange<CommentThread[]>
+ ) {
+ if (!threads || threads.length === 0) {
+ this._sortedThreads = [];
+ this._displayedThreads = [];
+ return;
+ }
+ // We only want to sort on thread additions / removals to avoid
+ // re-rendering on modifications (add new reply / edit draft etc.).
+ // https://polymer-library.polymer-project.org/3.0/docs/devguide/observers#array-observation
+ // TODO(TS): We have removed a buggy check of the splices here. A splice
+ // with addedCount > 0 or removed.length > 0 should also cause re-sorting
+ // and re-rendering, but apparently spliceRecord is always undefined for
+ // whatever reason.
+ if (this._sortedThreads.length === threads.length) {
+ // Instead of replacing the _sortedThreads which will trigger a re-render,
+ // we override all threads inside of it.
+
+ for (const thread of threads) {
+ const idxInSortedThreads = this._sortedThreads.findIndex(
+ t => t.rootId === thread.rootId
+ );
+ this.set(`_sortedThreads.${idxInSortedThreads}`, {...thread});
+ }
+ return;
+ }
+
+ const threadsWithInfo = threads.map(thread =>
+ this._getThreadWithStatusInfo(thread)
+ );
+ this._sortedThreads = threadsWithInfo
+ .sort((t1, t2) => this._compareThreads(t1, t2))
+ .map(threadInfo => threadInfo.thread);
+ }
+
+ _computeDisplayedThreads(
+ sortedThreadsRecord?: PolymerDeepPropertyChange<
+ CommentThread[],
+ CommentThread[]
+ >,
+ unresolvedOnly?: boolean,
+ draftsOnly?: boolean,
+ onlyShowRobotCommentsWithHumanReply?: boolean
+ ) {
+ if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
+ return sortedThreadsRecord.base.filter(t =>
+ this._shouldShowThread(
+ t,
+ unresolvedOnly,
+ draftsOnly,
+ onlyShowRobotCommentsWithHumanReply
+ )
+ );
+ }
+
+ _isFirstThreadWithFileName(
+ displayedThreads: CommentThread[],
+ thread: CommentThread,
+ unresolvedOnly?: boolean,
+ draftsOnly?: boolean,
+ onlyShowRobotCommentsWithHumanReply?: boolean
+ ) {
+ const threads = displayedThreads.filter(t =>
+ this._shouldShowThread(
+ t,
+ unresolvedOnly,
+ draftsOnly,
+ onlyShowRobotCommentsWithHumanReply
+ )
+ );
+ const index = threads.findIndex(t => t.rootId === thread.rootId);
+ if (index === -1) {
+ return false;
+ }
+ return index === 0 || threads[index - 1].path !== threads[index].path;
+ }
+
+ _shouldRenderSeparator(
+ displayedThreads: CommentThread[],
+ thread: CommentThread,
+ unresolvedOnly?: boolean,
+ draftsOnly?: boolean,
+ onlyShowRobotCommentsWithHumanReply?: boolean
+ ) {
+ const threads = displayedThreads.filter(t =>
+ this._shouldShowThread(
+ t,
+ unresolvedOnly,
+ draftsOnly,
+ onlyShowRobotCommentsWithHumanReply
+ )
+ );
+ const index = threads.findIndex(t => t.rootId === thread.rootId);
+ if (index === -1) {
+ return false;
+ }
+ return (
+ index > 0 &&
+ this._isFirstThreadWithFileName(
+ displayedThreads,
+ thread,
+ unresolvedOnly,
+ draftsOnly,
+ onlyShowRobotCommentsWithHumanReply
+ )
+ );
+ }
+
+ _shouldShowThread(
+ thread: CommentThread,
+ unresolvedOnly?: boolean,
+ draftsOnly?: boolean,
+ onlyShowRobotCommentsWithHumanReply?: boolean
+ ) {
+ if (
+ [
+ thread,
+ unresolvedOnly,
+ draftsOnly,
+ onlyShowRobotCommentsWithHumanReply,
+ ].includes(undefined)
+ ) {
+ return false;
+ }
+
+ if (
+ !draftsOnly &&
+ !unresolvedOnly &&
+ !onlyShowRobotCommentsWithHumanReply
+ ) {
+ return true;
+ }
+
+ const threadInfo = this._getThreadWithStatusInfo(thread);
+
+ if (threadInfo.isEditing) {
+ return true;
+ }
+
+ if (
+ threadInfo.hasRobotComment &&
+ onlyShowRobotCommentsWithHumanReply &&
+ !threadInfo.hasHumanReplyToRobotComment
+ ) {
+ return false;
+ }
+
+ let filtersCheck = true;
+ if (draftsOnly && unresolvedOnly) {
+ filtersCheck = threadInfo.hasDraft && threadInfo.unresolved;
+ } else if (draftsOnly) {
+ filtersCheck = threadInfo.hasDraft;
+ } else if (unresolvedOnly) {
+ filtersCheck = threadInfo.unresolved;
+ }
+
+ return filtersCheck;
+ }
+
+ _getThreadWithStatusInfo(thread: CommentThread): CommentThreadWithInfo {
+ const comments = thread.comments;
+ const lastComment = comments.length
+ ? comments[comments.length - 1]
+ : undefined;
+ let hasRobotComment = false;
+ let hasHumanReplyToRobotComment = false;
+ comments.forEach(comment => {
+ if ((comment as UIRobot).robot_id) {
+ hasRobotComment = true;
+ } else if (hasRobotComment) {
+ hasHumanReplyToRobotComment = true;
+ }
+ });
+ let updated = undefined;
+ if (lastComment) {
+ if (isDraft(lastComment)) updated = lastComment.__date;
+ if (lastComment.updated) updated = parseDate(lastComment.updated);
+ }
+
+ return {
+ thread,
+ hasRobotComment,
+ hasHumanReplyToRobotComment,
+ unresolved: !!lastComment && !!lastComment.unresolved,
+ isEditing: !!lastComment && !!lastComment.__editing,
+ hasDraft: !!lastComment && isDraft(lastComment),
+ updated,
+ };
+ }
+
+ removeThread(rootId: string) {
+ for (let i = 0; i < this.threads.length; i++) {
+ if (this.threads[i].rootId === rootId) {
+ this.splice('threads', i, 1);
+ // Needed to ensure threads get re-rendered in the correct order.
+ flush();
+ return;
+ }
+ }
+ }
+
+ _handleThreadDiscard(e: CustomEvent) {
+ this.removeThread(e.detail.rootId);
+ }
+
+ _handleCommentsChanged(e: CustomEvent) {
+ this.dispatchEvent(
+ new CustomEvent('thread-list-modified', {
+ detail: {rootId: e.detail.rootId, path: e.detail.path},
+ })
+ );
+ }
+
+ _isOnParent(side?: CommentSide) {
+ // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
+ // classified as parent??
+ return !!side;
+ }
+
+ /**
+ * Work around a issue on iOS when clicking turns into double tap
+ */
+ _onTapUnresolvedToggle(e: Event) {
+ e.preventDefault();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-thread-list': GrThreadList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
index d74c985..e55f98a 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_html.ts
@@ -57,15 +57,23 @@
border-top: 1px solid var(--border-color);
margin-top: var(--spacing-xl);
}
+ .resolved-comments-message {
+ color: var(--link-color);
+ cursor: pointer;
+ }
+ .show-resolved-comments {
+ box-shadow: none;
+ padding-left: var(--spacing-m);
+ }
</style>
<template is="dom-if" if="[[!hideToggleButtons]]">
<div class="header">
<div class="toggleItem">
<paper-toggle-button
id="unresolvedToggle"
- checked="{{_unresolvedOnly}}"
+ checked="{{!unresolvedOnly}}"
on-tap="_onTapUnresolvedToggle"
- >Only unresolved threads</paper-toggle-button
+ >All comments</paper-toggle-button
>
</div>
<div
@@ -75,48 +83,63 @@
id="draftToggle"
checked="{{_draftsOnly}}"
on-tap="_onTapUnresolvedToggle"
- >Only threads with drafts</paper-toggle-button
+ >Comments with drafts</paper-toggle-button
>
</div>
</div>
</template>
<div id="threads">
- <template is="dom-if" if="[[!threads.length]]">
- [[emptyThreadMsg]]
+ <template
+ is="dom-if"
+ if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
+ >
+ <div>
+ <span>
+ <template is="dom-if" if="[[_showPartyPopper(threads)]]">
+ <span> \🎉 </span>
+ </template>
+ [[_computeEmptyThreadsMessage(threads, _displayedThreads,
+ unresolvedOnly)]]
+ <template is="dom-if" if="[[_showResolvedCommentsButton(threads, _displayedThreads, unresolvedOnly)]]">
+ <gr-button
+ class="show-resolved-comments"
+ link
+ on-click="_handleResolvedCommentsMessageClick">
+ [[_computeResolvedCommentsMessage(threads, _displayedThreads,
+ unresolvedOnly)]]
+ </gr-button>
+ </template>
+ </span>
+ </div>
</template>
<template
is="dom-repeat"
- items="[[_sortedThreads]]"
+ items="[[_displayedThreads]]"
as="thread"
initial-count="10"
target-framerate="60"
>
<template
is="dom-if"
- if="[[_shouldShowThread(thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+ if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
>
- <template
- is="dom-if"
- if="[[_shouldRenderSeparator(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
- >
- <div class="thread-separator"></div>
- </template>
- <gr-comment-thread
- show-file-path=""
- change-num="[[changeNum]]"
- comments="[[thread.comments]]"
- comment-side="[[thread.commentSide]]"
- show-file-name="[[_isFirstThreadWithFileName(_sortedThreads, thread, _unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
- project-name="[[change.project]]"
- is-on-parent="[[_isOnParent(thread.commentSide)]]"
- line-num="[[thread.line]]"
- patch-num="[[thread.patchNum]]"
- path="[[thread.path]]"
- root-id="{{thread.rootId}}"
- on-thread-changed="_handleCommentsChanged"
- on-thread-discard="_handleThreadDiscard"
- ></gr-comment-thread>
+ <div class="thread-separator"></div>
</template>
+ <gr-comment-thread
+ show-file-path=""
+ change-num="[[changeNum]]"
+ comments="[[thread.comments]]"
+ comment-side="[[thread.commentSide]]"
+ show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+ project-name="[[change.project]]"
+ is-on-parent="[[_isOnParent(thread.commentSide)]]"
+ line-num="[[thread.line]]"
+ patch-num="[[thread.patchNum]]"
+ path="[[thread.path]]"
+ root-id="{{thread.rootId}}"
+ on-thread-changed="_handleCommentsChanged"
+ on-thread-discard="_handleThreadDiscard"
+ ></gr-comment-thread>
</template>
</div>
`;
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index df4850c..efc072f 100644
--- a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
+++ b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
@@ -18,7 +18,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-thread-list.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {NO_THREADS_MSG} from '../../../constants/messages.js';
import {SpecialFilePath} from '../../../constants/constants.js';
const basicFixture = fixtureFromElement('gr-thread-list');
@@ -36,6 +35,10 @@
setup(done => {
element = basicFixture.instantiate();
+ element.changeNum = 123;
+ element.change = {
+ project: 'testRepo',
+ };
element.threads = [
{
comments: [
@@ -150,7 +153,7 @@
message: 'resolved draft',
unresolved: false,
__draft: true,
- __draftID: '0.m683trwff68',
+ __draftID: '0.m683trwff69',
__editing: false,
patch_set: '2',
},
@@ -283,19 +286,29 @@
assert.equal(getVisibleThreads().length, element.threads.length);
});
+ test('show unresolved threads if unresolvedOnly is set', done => {
+ element.unresolvedOnly = true;
+ flush();
+ const unresolvedThreads = element.threads.filter(t => t.comments.some(
+ c => c.unresolved
+ ));
+ assert.equal(getVisibleThreads().length, unresolvedThreads.length);
+ done();
+ });
+
test('showing file name takes visible threads into account', () => {
assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
- element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+ element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
element.onlyShowRobotCommentsWithHumanReply), true);
- element._unresolvedOnly = true;
+ element.unresolvedOnly = true;
assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
- element._sortedThreads[2], element._unresolvedOnly, element._draftsOnly,
+ element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
element.onlyShowRobotCommentsWithHumanReply), false);
});
test('onlyShowRobotCommentsWithHumanReply ', () => {
element.onlyShowRobotCommentsWithHumanReply = true;
- flushAsynchronousOperations();
+ flush();
assert.equal(
getVisibleThreads().length,
element.threads.length - 1);
@@ -457,7 +470,7 @@
detail: {rootId: 'rc2'},
composed: true, bubbles: true,
}));
- flushAsynchronousOperations();
+ flush();
assert.equal(element._sortedThreads.length, 8);
const expectedSortedRootIds = [
'patchset_level_2',
@@ -535,16 +548,16 @@
});
});
- test('toggle unresolved only shows unresolved comments', () => {
+ test('toggle unresolved shows all comments', () => {
MockInteractions.tap(element.shadowRoot.querySelector(
'#unresolvedToggle'));
- flushAsynchronousOperations();
+ flush();
assert.equal(getVisibleThreads().length, 4);
});
test('toggle drafts only shows threads with draft comments', () => {
MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
- flushAsynchronousOperations();
+ flush();
assert.equal(getVisibleThreads().length, 2);
});
@@ -560,7 +573,7 @@
MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
MockInteractions.tap(element.shadowRoot.querySelector(
'#unresolvedToggle'));
- flushAsynchronousOperations();
+ flush();
assert.equal(getVisibleThreads().length, 2);
});
@@ -569,7 +582,7 @@
MockInteractions.tap(element.shadowRoot.querySelector('#draftToggle'));
MockInteractions.tap(element.shadowRoot.querySelector(
'#unresolvedToggle'));
- flushAsynchronousOperations();
+ flush();
assert.equal(getVisibleThreads().length, 1);
});
@@ -613,18 +626,9 @@
});
test('default empty message should show', () => {
- assert.equal(
- element.shadowRoot.querySelector('#threads').textContent.trim(),
- NO_THREADS_MSG
- );
- });
-
- test('can override empty message', () => {
- element.emptyThreadMsg = 'test';
- assert.equal(
- element.shadowRoot.querySelector('#threads').textContent.trim(),
- 'test'
- );
+ assert.isTrue(
+ element.shadowRoot.querySelector('#threads').textContent.trim()
+ .includes('No comments.'));
});
});
});
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
deleted file mode 100644
index 15319e6..0000000
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.js
+++ /dev/null
@@ -1,151 +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 '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-shell-command/gr-shell-command.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-upload-help-dialog_html.js';
-
-const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
-const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
-
-// Command names correspond to download plugin definitions.
-const PREFERRED_FETCH_COMMAND_ORDER = [
- 'checkout',
- 'cherry pick',
- 'pull',
-];
-
-/**
- * @extends PolymerElement
- */
-class GrUploadHelpDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-upload-help-dialog'; }
- /**
- * Fired when the user presses the close button.
- *
- * @event close
- */
-
- static get properties() {
- return {
- revision: Object,
- targetBranch: String,
- _commitCommand: {
- type: String,
- value: COMMIT_COMMAND,
- readOnly: true,
- },
- _fetchCommand: {
- type: String,
- computed: '_computeFetchCommand(revision, ' +
- '_preferredDownloadCommand, _preferredDownloadScheme)',
- },
- _preferredDownloadCommand: String,
- _preferredDownloadScheme: String,
- _pushCommand: {
- type: String,
- computed: '_computePushCommand(targetBranch)',
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.$.restAPI.getLoggedIn()
- .then(loggedIn => {
- if (loggedIn) {
- return this.$.restAPI.getPreferences();
- }
- })
- .then(prefs => {
- if (prefs) {
- this._preferredDownloadCommand = prefs.download_command;
- this._preferredDownloadScheme = prefs.download_scheme;
- }
- });
- }
-
- _handleCloseTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('close', {
- composed: true, bubbles: false,
- }));
- }
-
- _computeFetchCommand(revision, preferredDownloadCommand,
- preferredDownloadScheme) {
- // Polymer 2: check for undefined
- if ([
- revision,
- preferredDownloadCommand,
- preferredDownloadScheme,
- ].includes(undefined)) {
- return undefined;
- }
-
- if (!revision) { return; }
- if (!revision || !revision.fetch) { return; }
-
- let scheme = preferredDownloadScheme;
- if (!scheme) {
- const keys = Object.keys(revision.fetch).sort();
- if (keys.length === 0) {
- return;
- }
- scheme = keys[0];
- }
-
- if (!revision.fetch[scheme] || !revision.fetch[scheme].commands) {
- return;
- }
-
- const cmds = {};
- Object.entries(revision.fetch[scheme].commands).forEach(([key, cmd]) => {
- cmds[key.toLowerCase()] = cmd;
- });
-
- if (preferredDownloadCommand &&
- cmds[preferredDownloadCommand.toLowerCase()]) {
- return cmds[preferredDownloadCommand.toLowerCase()];
- }
-
- // If no supported command preference is given, look for known commands
- // from the downloads plugin in order of preference.
- for (let i = 0; i < PREFERRED_FETCH_COMMAND_ORDER.length; i++) {
- if (cmds[PREFERRED_FETCH_COMMAND_ORDER[i]]) {
- return cmds[PREFERRED_FETCH_COMMAND_ORDER[i]];
- }
- }
-
- return undefined;
- }
-
- _computePushCommand(targetBranch) {
- return PUSH_COMMAND_PREFIX + targetBranch;
- }
-}
-
-customElements.define(GrUploadHelpDialog.is, GrUploadHelpDialog);
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
new file mode 100644
index 0000000..cab17dd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog.ts
@@ -0,0 +1,149 @@
+/**
+ * @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 '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-shell-command/gr-shell-command';
+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-upload-help-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {RevisionInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+const COMMIT_COMMAND = 'git add . && git commit --amend --no-edit';
+const PUSH_COMMAND_PREFIX = 'git push origin HEAD:refs/for/';
+
+// Command names correspond to download plugin definitions.
+const PREFERRED_FETCH_COMMAND_ORDER = ['checkout', 'cherry pick', 'pull'];
+
+export interface GrUploadHelpDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-upload-help-dialog')
+export class GrUploadHelpDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the user presses the close button.
+ *
+ * @event close
+ */
+
+ @property({type: Object})
+ revision?: RevisionInfo;
+
+ @property({type: String})
+ targetBranch?: string;
+
+ @property({type: String})
+ _commitCommand = COMMIT_COMMAND;
+
+ @property({
+ type: String,
+ computed: '_computeFetchCommand(revision, _preferredDownloadScheme)',
+ })
+ _fetchCommand?: string;
+
+ @property({type: String})
+ _preferredDownloadScheme?: string;
+
+ @property({type: String, computed: '_computePushCommand(targetBranch)'})
+ _pushCommand?: string;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.$.restAPI
+ .getLoggedIn()
+ .then(loggedIn =>
+ loggedIn ? this.$.restAPI.getPreferences() : Promise.resolve(undefined)
+ )
+ .then(prefs => {
+ if (prefs) {
+ // TODO(TS): The download_command pref was deleted in change 249223.
+ // this._preferredDownloadCommand = prefs.download_command;
+ this._preferredDownloadScheme = prefs.download_scheme;
+ }
+ });
+ }
+
+ _handleCloseTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('close', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _computeFetchCommand(
+ revision?: RevisionInfo,
+ scheme?: string
+ ): string | undefined {
+ if (!revision || !revision.fetch) return undefined;
+ if (!scheme) {
+ const keys = Object.keys(revision.fetch).sort();
+ if (keys.length === 0) {
+ return undefined;
+ }
+ scheme = keys[0];
+ }
+ if (
+ !scheme ||
+ !revision.fetch[scheme] ||
+ !revision.fetch[scheme].commands
+ ) {
+ return undefined;
+ }
+
+ const cmds: {[key: string]: string} = {};
+ Object.entries(revision.fetch[scheme].commands!).forEach(([key, cmd]) => {
+ cmds[key.toLowerCase()] = cmd;
+ });
+
+ // If no supported command preference is given, look for known commands
+ // from the downloads plugin in order of preference.
+ for (let i = 0; i < PREFERRED_FETCH_COMMAND_ORDER.length; i++) {
+ if (cmds[PREFERRED_FETCH_COMMAND_ORDER[i]]) {
+ return cmds[PREFERRED_FETCH_COMMAND_ORDER[i]];
+ }
+ }
+
+ return undefined;
+ }
+
+ _computePushCommand(targetBranch: string) {
+ return PUSH_COMMAND_PREFIX + targetBranch;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-upload-help-dialog': GrUploadHelpDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js
index d1af425..005b20d 100644
--- a/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-upload-help-dialog/gr-upload-help-dialog_test.js
@@ -60,51 +60,38 @@
assert.isUndefined(element._computeFetchCommand({fetch: {}}));
});
- test('not all defined', () => {
+ test('revision not defined', () => {
assert.isUndefined(
- element._computeFetchCommand(testRev, undefined, ''));
- assert.isUndefined(
- element._computeFetchCommand(testRev, '', undefined));
- assert.isUndefined(
- element._computeFetchCommand(undefined, '', ''));
+ element._computeFetchCommand(undefined, ''));
});
test('insufficiently defined scheme', () => {
assert.isUndefined(
- element._computeFetchCommand(testRev, '', 'badscheme'));
+ element._computeFetchCommand(testRev, 'badscheme'));
- const rev = Object.assign({}, testRev);
- rev.fetch = Object.assign({}, testRev.fetch, {nocmds: {commands: {}}});
+ const rev = {...testRev};
+ rev.fetch = {...testRev.fetch, nocmds: {commands: {}}};
assert.isUndefined(
- element._computeFetchCommand(rev, '', 'nocmds'));
+ element._computeFetchCommand(rev, 'nocmds'));
rev.fetch.nocmds.commands.unsupported = 'unsupported';
assert.isUndefined(
- element._computeFetchCommand(rev, '', 'nocmds'));
+ element._computeFetchCommand(rev, 'nocmds'));
});
test('default scheme and command', () => {
- const cmd = element._computeFetchCommand(testRev, '', '');
+ const cmd = element._computeFetchCommand(testRev, '');
assert.isTrue(cmd === 'http checkout' || cmd === 'ssh pull');
});
test('default command', () => {
assert.strictEqual(
- element._computeFetchCommand(testRev, '', 'http'),
+ element._computeFetchCommand(testRev, 'http'),
'http checkout');
assert.strictEqual(
- element._computeFetchCommand(testRev, '', 'ssh'),
+ element._computeFetchCommand(testRev, 'ssh'),
'ssh pull');
});
-
- test('user preferred scheme and command', () => {
- assert.strictEqual(
- element._computeFetchCommand(testRev, 'PULL', 'http'),
- 'http pull');
- assert.strictEqual(
- element._computeFetchCommand(testRev, 'badcmd', 'http'),
- 'http checkout');
- });
});
});
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
deleted file mode 100644
index 7608137..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ /dev/null
@@ -1,132 +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 '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-avatar/gr-avatar.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-account-dropdown_html.js';
-import {getUserName} from '../../../utils/display-name-util.js';
-
-const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
-
-/**
- * @extends PolymerElement
- */
-class GrAccountDropdown extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-account-dropdown'; }
-
- static get properties() {
- return {
- account: Object,
- config: Object,
- links: {
- type: Array,
- computed: '_getLinks(_switchAccountUrl, _path)',
- },
- topContent: {
- type: Array,
- computed: '_getTopContent(account)',
- },
- _path: {
- type: String,
- value: '/',
- },
- _hasAvatars: Boolean,
- _switchAccountUrl: String,
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this._handleLocationChange();
- this.listen(window, 'location-change', '_handleLocationChange');
- this.$.restAPI.getConfig().then(cfg => {
- this.config = cfg;
-
- if (cfg && cfg.auth && cfg.auth.switch_account_url) {
- this._switchAccountUrl = cfg.auth.switch_account_url;
- } else {
- this._switchAccountUrl = '';
- }
- this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
- });
- }
-
- /** @override */
- detached() {
- super.detached();
- this.unlisten(window, 'location-change', '_handleLocationChange');
- }
-
- _getLinks(switchAccountUrl, path) {
- // Polymer 2: check for undefined
- if ([switchAccountUrl, path].includes(undefined)) {
- return undefined;
- }
-
- const links = [];
- links.push({name: 'Settings', url: '/settings/'});
- links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
- if (switchAccountUrl) {
- const replacements = {path};
- const url = this._interpolateUrl(switchAccountUrl, replacements);
- links.push({name: 'Switch account', url, external: true});
- }
- links.push({name: 'Sign out', url: '/logout'});
- return links;
- }
-
- _getTopContent(account) {
- return [
- {text: this._accountName(account), bold: true},
- {text: account.email ? account.email : ''},
- ];
- }
-
- _handleShortcutsTap(e) {
- this.dispatchEvent(new CustomEvent('show-keyboard-shortcuts',
- {bubbles: true, composed: true}));
- }
-
- _handleLocationChange() {
- this._path =
- window.location.pathname +
- window.location.search +
- window.location.hash;
- }
-
- _interpolateUrl(url, replacements) {
- return url.replace(
- INTERPOLATE_URL_PATTERN,
- (match, p1) => replacements[p1] || '');
- }
-
- _accountName(account) {
- return getUserName(this.config, account);
- }
-}
-
-customElements.define(GrAccountDropdown.is, GrAccountDropdown);
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
new file mode 100644
index 0000000..ef0ced8
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.ts
@@ -0,0 +1,146 @@
+/**
+ * @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 '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../shared/gr-avatar/gr-avatar';
+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-account-dropdown_html';
+import {getUserName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-account-dropdown': GrAccountDropdown;
+ }
+}
+
+export interface GrAccountDropdown {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-account-dropdown')
+export class GrAccountDropdown extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ account?: AccountInfo;
+
+ @property({type: Object})
+ config?: ServerInfo;
+
+ @property({type: Array, computed: '_getLinks(_switchAccountUrl, _path)'})
+ links?: string[];
+
+ @property({type: Array, computed: '_getTopContent(account)'})
+ topContent?: string[];
+
+ @property({type: String})
+ _path = '/';
+
+ @property({type: Boolean})
+ _hasAvatars = false;
+
+ @property({type: String})
+ _switchAccountUrl = '';
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._handleLocationChange();
+ this.listen(window, 'location-change', '_handleLocationChange');
+ this.$.restAPI.getConfig().then(cfg => {
+ this.config = cfg;
+
+ if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+ this._switchAccountUrl = cfg.auth.switch_account_url;
+ } else {
+ this._switchAccountUrl = '';
+ }
+ this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+ });
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.unlisten(window, 'location-change', '_handleLocationChange');
+ }
+
+ _getLinks(switchAccountUrl: string, path: string) {
+ // Polymer 2: check for undefined
+ if (switchAccountUrl === undefined || path === undefined) {
+ return undefined;
+ }
+
+ const links = [];
+ links.push({name: 'Settings', url: '/settings/'});
+ links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
+ if (switchAccountUrl) {
+ const replacements = {path};
+ const url = this._interpolateUrl(switchAccountUrl, replacements);
+ links.push({name: 'Switch account', url, external: true});
+ }
+ links.push({name: 'Sign out', url: '/logout'});
+ return links;
+ }
+
+ _getTopContent(account?: AccountInfo) {
+ return [
+ {text: this._accountName(account), bold: true},
+ {text: account?.email ? account.email : ''},
+ ];
+ }
+
+ _handleShortcutsTap() {
+ this.dispatchEvent(
+ new CustomEvent('show-keyboard-shortcuts', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ _handleLocationChange() {
+ this._path =
+ window.location.pathname + window.location.search + window.location.hash;
+ }
+
+ _interpolateUrl(url: string, replacements: {[key: string]: string}) {
+ return url.replace(
+ INTERPOLATE_URL_PATTERN,
+ (_, p1) => replacements[p1] || ''
+ );
+ }
+
+ _accountName(account?: AccountInfo) {
+ return getUserName(this.config, account);
+ }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
deleted file mode 100644
index 99c4cb3..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.js
+++ /dev/null
@@ -1,62 +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 '../../shared/gr-dialog/gr-dialog.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-error-dialog_html.js';
-
-/** @extends PolymerElement */
-class GrErrorDialog extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-error-dialog'; }
- /**
- * Fired when the dismiss button is pressed.
- *
- * @event dismiss
- */
-
- static get properties() {
- return {
- text: String,
- /**
- * loginUrl to open on "sign in" button click
- */
- loginUrl: {
- type: String,
- value: '/login',
- },
- /**
- * Show/hide "Sign In" button in dialog
- */
- showSignInButton: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- _handleConfirm() {
- this.dispatchEvent(new CustomEvent('dismiss'));
- }
-}
-
-customElements.define(GrErrorDialog.is, GrErrorDialog);
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
new file mode 100644
index 0000000..b28b13e
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog.ts
@@ -0,0 +1,57 @@
+/**
+ * @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 '../../shared/gr-dialog/gr-dialog';
+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-error-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-error-dialog': GrErrorDialog;
+ }
+}
+
+@customElement('gr-error-dialog')
+export class GrErrorDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the dismiss button is pressed.
+ *
+ * @event dismiss
+ */
+
+ @property({type: String})
+ text?: string;
+
+ @property({type: String})
+ loginUrl = '/login';
+
+ @property({type: Boolean})
+ showSignInButton = false;
+
+ _handleConfirm() {
+ this.dispatchEvent(new CustomEvent('dismiss'));
+ }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
deleted file mode 100644
index a2ec48e..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ /dev/null
@@ -1,430 +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 to get Gerrit interface */
-/* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
-import '../gr-error-dialog/gr-error-dialog.js';
-import '../../shared/gr-alert/gr-alert.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.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-error-manager_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-import {appContext} from '../../../services/app-context.js';
-
-const HIDE_ALERT_TIMEOUT_MS = 5000;
-const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
-const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
-const SIGN_IN_WIDTH_PX = 690;
-const SIGN_IN_HEIGHT_PX = 500;
-const TOO_MANY_FILES = 'too many files to find conflicts';
-const AUTHENTICATION_REQUIRED = 'Authentication required\n';
-
-const ErrorType = {
- AUTH: 'AUTH',
- NETWORK: 'NETWORK',
- GENERIC: 'GENERIC',
-};
-
-// Bigger number has higher priority
-const ErrorTypePriority = {
- [ErrorType.AUTH]: 3,
- [ErrorType.NETWORK]: 2,
- [ErrorType.GENERIC]: 1,
-};
-
-export const __testOnly_ErrorType = ErrorType;
-
-/**
- * @extends PolymerElement
- */
-class GrErrorManager extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-error-manager'; }
-
- static get properties() {
- return {
- /**
- * The ID of the account that was logged in when the app was launched. If
- * not set, then there was no account at launch.
- */
- knownAccountId: Number,
-
- /** @type {?Object} */
- _alertElement: Object,
- /** @type {?number} */
- _hideAlertHandle: Number,
- _refreshingCredentials: {
- type: Boolean,
- value: false,
- },
-
- /**
- * The time (in milliseconds) since the most recent credential check.
- */
- _lastCredentialCheck: {
- type: Number,
- value() { return Date.now(); },
- },
-
- loginUrl: {
- type: String,
- value: '/login',
- },
- };
- }
-
- constructor() {
- super();
-
- /** @type {!Auth} */
- this._authService = appContext.authService;
-
- /** @type {?Function} */
- this._authErrorHandlerDeregistrationHook;
-
- this.reporting = appContext.reportingService;
- this.eventEmitter = appContext.eventEmitter;
- }
-
- /** @override */
- attached() {
- super.attached();
- this.listen(document, 'server-error', '_handleServerError');
- this.listen(document, 'network-error', '_handleNetworkError');
- this.listen(document, 'show-alert', '_handleShowAlert');
- this.listen(document, 'show-error', '_handleShowErrorDialog');
- this.listen(document, 'visibilitychange', '_handleVisibilityChange');
- this.listen(document, 'show-auth-required', '_handleAuthRequired');
-
- this._authErrorHandlerDeregistrationHook =
- this.eventEmitter.on('auth-error',
- event => {
- this._handleAuthError(event.message, event.action);
- });
- }
-
- /** @override */
- detached() {
- super.detached();
- this._clearHideAlertHandle();
- this.unlisten(document, 'server-error', '_handleServerError');
- this.unlisten(document, 'network-error', '_handleNetworkError');
- this.unlisten(document, 'show-alert', '_handleShowAlert');
- this.unlisten(document, 'show-error', '_handleShowErrorDialog');
- this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
- this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
-
- this._authErrorHandlerDeregistrationHook();
- }
-
- _shouldSuppressError(msg) {
- return msg.includes(TOO_MANY_FILES);
- }
-
- _handleAuthRequired() {
- this._showAuthErrorAlert(
- 'Log in is required to perform that action.', 'Log in.');
- }
-
- _handleAuthError(msg, action) {
- this.$.noInteractionOverlay.open().then(() => {
- this._showAuthErrorAlert(msg, action);
- });
- }
-
- _handleServerError(e) {
- const {request, response} = e.detail;
- response.text().then(errorText => {
- const url = request && (request.anonymizedUrl || request.url);
- const {status, statusText} = response;
- if (response.status === 403
- && !this._authService.isAuthed
- && errorText === AUTHENTICATION_REQUIRED) {
- // if not authed previously, this is trying to access auth required APIs
- // show auth required alert
- this._handleAuthRequired();
- } else if (response.status === 403
- && this._authService.isAuthed
- && errorText === AUTHENTICATION_REQUIRED) {
- // The app was logged at one point and is now getting auth errors.
- // This indicates the auth token may no longer valid.
- // Re-check on auth
- this._authService.clearCache();
- this.$.restAPI.getLoggedIn();
- } else if (!this._shouldSuppressError(errorText)) {
- const trace =
- response.headers && response.headers.get('X-Gerrit-Trace');
- if (response.status === 404) {
- this._showNotFoundMessageWithTip({
- status,
- statusText,
- errorText,
- url,
- trace,
- });
- } else {
- this._showErrorDialog(this._constructServerErrorMsg({
- status,
- statusText,
- errorText,
- url,
- trace,
- }));
- }
- }
- console.log(`server error: ${errorText}`);
- });
- }
-
- _showNotFoundMessageWithTip({status, statusText, errorText, url, trace}) {
- this.$.restAPI.getLoggedIn().then(isLoggedIn => {
- const tip = isLoggedIn ?
- 'You might have not enough privileges.' :
- 'You might have not enough privileges. Sign in and try again.';
- this._showErrorDialog(this._constructServerErrorMsg({
- status,
- statusText,
- errorText,
- url,
- trace,
- tip,
- }), {
- showSignInButton: !isLoggedIn,
- });
- });
- }
-
- _constructServerErrorMsg({errorText, status, statusText, url, trace, tip}) {
- let err = '';
- if (tip) {
- err += `${tip}\n\n`;
- }
- err += `Error ${status}`;
- if (statusText) { err += ` (${statusText})`; }
- if (errorText || url) { err += ': '; }
- if (errorText) { err += errorText; }
- if (url) { err += `\nEndpoint: ${url}`; }
- if (trace) { err += `\nTrace Id: ${trace}`; }
- return err;
- }
-
- _handleShowAlert(e) {
- this._showAlert(e.detail.message, e.detail.action, e.detail.callback,
- e.detail.dismissOnNavigation);
- }
-
- _handleNetworkError(e) {
- this._showAlert('Server unavailable');
- console.error(e.detail.error.message);
- }
-
- // TODO(dhruvsr): allow less priority alerts to override high priority alerts
- // In some use cases we may want generic alerts to show along/over errors
- _canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
- return ErrorTypePriority[incoming] >= ErrorTypePriority[existing];
- }
-
- /**
- * @param {string} text
- * @param {?string=} opt_actionText
- * @param {?Function=} opt_actionCallback
- * @param {?boolean=} opt_dismissOnNavigation
- * @param {?string=} opt_type
- */
- _showAlert(text, opt_actionText, opt_actionCallback,
- opt_dismissOnNavigation, opt_type) {
- if (this._alertElement) {
- // check priority before hiding
- if (!this._canOverride(opt_type, this._alertElement.type)) return;
- this._hideAlert();
- }
-
- this._clearHideAlertHandle();
- if (opt_dismissOnNavigation) {
- // Persist alert until navigation.
- this.listen(document, 'location-change', '_hideAlert');
- } else {
- this._hideAlertHandle =
- this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
- }
- const el = this._createToastAlert();
- el.show(text, opt_actionText, opt_actionCallback);
- this._alertElement = el;
- }
-
- _hideAlert() {
- if (!this._alertElement) { return; }
-
- this._alertElement.hide();
- this._alertElement = null;
-
- // Remove listener for page navigation, if it exists.
- this.unlisten(document, 'location-change', '_hideAlert');
- }
-
- _clearHideAlertHandle() {
- if (this._hideAlertHandle != null) {
- this.cancelAsync(this._hideAlertHandle);
- this._hideAlertHandle = null;
- }
- }
-
- _showAuthErrorAlert(errorText, actionText) {
- // hide any existing alert like `reload`
- // as auth error should have the highest priority
- if (this._alertElement) {
- this._alertElement.hide();
- }
-
- this._alertElement = this._createToastAlert();
- this._alertElement.type = ErrorType.AUTH;
- this._alertElement.show(errorText, actionText,
- this._createLoginPopup.bind(this));
-
- this._refreshingCredentials = true;
- this._requestCheckLoggedIn();
- if (!document.hidden) {
- this._handleVisibilityChange();
- }
- }
-
- _createToastAlert() {
- const el = document.createElement('gr-alert');
- el.toast = true;
- return el;
- }
-
- _handleVisibilityChange() {
- // Ignore when the page is transitioning to hidden (or hidden is
- // undefined).
- if (document.hidden !== false) { return; }
-
- // If not currently refreshing credentials and the credentials are old,
- // request them to confirm their validity or (display an auth toast if it
- // fails).
- const timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
- if (!this._refreshingCredentials &&
- this.knownAccountId !== undefined &&
- timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
- this._lastCredentialCheck = Date.now();
-
- // check auth status in case:
- // - user signed out
- // - user switched account
- this._checkSignedIn();
- }
- }
-
- _requestCheckLoggedIn() {
- this.debounce(
- 'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
- }
-
- _checkSignedIn() {
- this._lastCredentialCheck = Date.now();
-
- // force to refetch account info
- this.$.restAPI.invalidateAccountsCache();
- this._authService.clearCache();
-
- this.$.restAPI.getLoggedIn().then(isLoggedIn => {
- // do nothing if its refreshing
- if (!this._refreshingCredentials) return;
-
- if (!isLoggedIn) {
- // check later
- // 1. guest mode
- // 2. or signed out
- // in case #2, auth-error is taken care of separately
- this._requestCheckLoggedIn();
- } else {
- // check account
- this.$.restAPI.getAccount().then(account => {
- if (this._refreshingCredentials) {
- // If the credentials were refreshed but the account is different
- // then reload the page completely.
- if (account._account_id !== this.knownAccountId) {
- this._reloadPage();
- return;
- }
-
- this._handleCredentialRefreshed();
- }
- });
- }
- });
- }
-
- _reloadPage() {
- window.location.reload();
- }
-
- _createLoginPopup() {
- const left = window.screenLeft +
- (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
- const top = window.screenTop +
- (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
- const options = [
- 'width=' + SIGN_IN_WIDTH_PX,
- 'height=' + SIGN_IN_HEIGHT_PX,
- 'left=' + left,
- 'top=' + top,
- ];
- window.open(getBaseUrl() +
- '/login/%3FcloseAfterLogin', '_blank', options.join(','));
- this.listen(window, 'focus', '_handleWindowFocus');
- }
-
- _handleCredentialRefreshed() {
- this.unlisten(window, 'focus', '_handleWindowFocus');
- this._refreshingCredentials = false;
- this._hideAlert();
- this._showAlert('Credentials refreshed.');
- this.$.noInteractionOverlay.close();
-
- // Clear the cache for auth
- this._authService.clearCache();
- }
-
- _handleWindowFocus() {
- this.flushDebouncer('checkLoggedIn');
- }
-
- _handleShowErrorDialog(e) {
- this._showErrorDialog(e.detail.message);
- }
-
- _handleDismissErrorDialog() {
- this.$.errorOverlay.close();
- }
-
- _showErrorDialog(message, opt_options) {
- this.reporting.reportErrorDialog(message);
- this.$.errorDialog.text = message;
- this.$.errorDialog.showSignInButton =
- opt_options && opt_options.showSignInButton;
- this.$.errorOverlay.open();
- }
-}
-
-customElements.define(GrErrorManager.is, GrErrorManager);
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
new file mode 100644
index 0000000..7a56d1c
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.ts
@@ -0,0 +1,512 @@
+/**
+ * @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 to get Gerrit interface */
+/* TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface */
+import '../gr-error-dialog/gr-error-dialog';
+import '../../shared/gr-alert/gr-alert';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+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-error-manager_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {appContext} from '../../../services/app-context';
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import {customElement, property} from '@polymer/decorators';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {AuthService} from '../../../services/gr-auth/gr-auth';
+import {EventEmitterService} from '../../../services/gr-event-interface/gr-event-interface';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrErrorDialog} from '../gr-error-dialog/gr-error-dialog';
+import {GrAlert} from '../../shared/gr-alert/gr-alert';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {FetchRequest} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {ErrorType, FixIronA11yAnnouncer} from '../../../types/types';
+import {AccountId} from '../../../types/common';
+
+const HIDE_ALERT_TIMEOUT_MS = 5000;
+const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
+const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
+const SIGN_IN_WIDTH_PX = 690;
+const SIGN_IN_HEIGHT_PX = 500;
+const TOO_MANY_FILES = 'too many files to find conflicts';
+const AUTHENTICATION_REQUIRED = 'Authentication required\n';
+
+// Bigger number has higher priority
+const ErrorTypePriority = {
+ [ErrorType.AUTH]: 3,
+ [ErrorType.NETWORK]: 2,
+ [ErrorType.GENERIC]: 1,
+};
+
+interface ErrorMsg {
+ errorText?: string;
+ status?: number;
+ statusText?: string;
+ url?: string;
+ trace?: string | null;
+ tip?: string;
+}
+
+export const __testOnly_ErrorType = ErrorType;
+
+export interface GrErrorManager {
+ $: {
+ noInteractionOverlay: GrOverlay;
+ errorDialog: GrErrorDialog;
+ errorOverlay: GrOverlay;
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-error-manager')
+export class GrErrorManager extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * The ID of the account that was logged in when the app was launched. If
+ * not set, then there was no account at launch.
+ */
+ @property({type: Number})
+ knownAccountId?: AccountId | null;
+
+ @property({type: Object})
+ _alertElement: GrAlert | null = null;
+
+ @property({type: Number})
+ _hideAlertHandle: number | null = null;
+
+ @property({type: Boolean})
+ _refreshingCredentials = false;
+
+ /**
+ * The time (in milliseconds) since the most recent credential check.
+ */
+ @property({type: Number})
+ _lastCredentialCheck: number = Date.now();
+
+ @property({type: String})
+ loginUrl = '/login';
+
+ reporting: ReportingService;
+
+ _authService: AuthService;
+
+ eventEmitter: EventEmitterService;
+
+ _authErrorHandlerDeregistrationHook?: Function;
+
+ constructor() {
+ super();
+
+ this._authService = appContext.authService;
+
+ this.reporting = appContext.reportingService;
+ this.eventEmitter = appContext.eventEmitter;
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.listen(document, 'server-error', '_handleServerError');
+ this.listen(document, 'network-error', '_handleNetworkError');
+ this.listen(document, 'show-alert', '_handleShowAlert');
+ this.listen(document, 'hide-alert', '_hideAlert');
+ this.listen(document, 'show-error', '_handleShowErrorDialog');
+ this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+ this.listen(document, 'show-auth-required', '_handleAuthRequired');
+
+ this._authErrorHandlerDeregistrationHook = this.eventEmitter.on(
+ 'auth-error',
+ event => {
+ this._handleAuthError(event.message, event.action);
+ }
+ );
+
+ ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this._clearHideAlertHandle();
+ this.unlisten(document, 'server-error', '_handleServerError');
+ this.unlisten(document, 'network-error', '_handleNetworkError');
+ this.unlisten(document, 'show-alert', '_handleShowAlert');
+ this.unlisten(document, 'hide-alert', '_hideAlert');
+ this.unlisten(document, 'show-error', '_handleShowErrorDialog');
+ this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+ this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
+
+ if (this._authErrorHandlerDeregistrationHook) {
+ this._authErrorHandlerDeregistrationHook();
+ }
+ }
+
+ _shouldSuppressError(msg: string) {
+ return msg.includes(TOO_MANY_FILES);
+ }
+
+ _handleAuthRequired() {
+ this._showAuthErrorAlert(
+ 'Log in is required to perform that action.',
+ 'Log in.'
+ );
+ }
+
+ _handleAuthError(msg: string, action: string) {
+ this.$.noInteractionOverlay.open().then(() => {
+ this._showAuthErrorAlert(msg, action);
+ });
+ }
+
+ _handleServerError(
+ e: CustomEvent<{response: Response; request: FetchRequest}>
+ ) {
+ const {request, response} = e.detail;
+ response.text().then(errorText => {
+ const url = request && (request.anonymizedUrl || request.url);
+ const {status, statusText} = response;
+ if (
+ response.status === 403 &&
+ !this._authService.isAuthed &&
+ errorText === AUTHENTICATION_REQUIRED
+ ) {
+ // if not authed previously, this is trying to access auth required APIs
+ // show auth required alert
+ this._handleAuthRequired();
+ } else if (
+ response.status === 403 &&
+ this._authService.isAuthed &&
+ errorText === AUTHENTICATION_REQUIRED
+ ) {
+ // The app was logged at one point and is now getting auth errors.
+ // This indicates the auth token may no longer valid.
+ // Re-check on auth
+ this._authService.clearCache();
+ this.$.restAPI.getLoggedIn();
+ } else if (!this._shouldSuppressError(errorText)) {
+ const trace =
+ response.headers && response.headers.get('X-Gerrit-Trace');
+ if (response.status === 404) {
+ this._showNotFoundMessageWithTip({
+ status,
+ statusText,
+ errorText,
+ url,
+ trace,
+ });
+ } else {
+ this._showErrorDialog(
+ this._constructServerErrorMsg({
+ status,
+ statusText,
+ errorText,
+ url,
+ trace,
+ })
+ );
+ }
+ }
+ console.info(`server error: ${errorText}`);
+ });
+ }
+
+ _showNotFoundMessageWithTip({
+ status,
+ statusText,
+ errorText,
+ url,
+ trace,
+ }: ErrorMsg) {
+ this.$.restAPI.getLoggedIn().then(isLoggedIn => {
+ const tip = isLoggedIn
+ ? 'You might have not enough privileges.'
+ : 'You might have not enough privileges. Sign in and try again.';
+ this._showErrorDialog(
+ this._constructServerErrorMsg({
+ status,
+ statusText,
+ errorText,
+ url,
+ trace,
+ tip,
+ }),
+ {
+ showSignInButton: !isLoggedIn,
+ }
+ );
+ });
+ }
+
+ _constructServerErrorMsg({
+ errorText,
+ status,
+ statusText,
+ url,
+ trace,
+ tip,
+ }: ErrorMsg) {
+ let err = '';
+ if (tip) {
+ err += `${tip}\n\n`;
+ }
+ err += `Error ${status}`;
+ if (statusText) {
+ err += ` (${statusText})`;
+ }
+ if (errorText || url) {
+ err += ': ';
+ }
+ if (errorText) {
+ err += errorText;
+ }
+ if (url) {
+ err += `\nEndpoint: ${url}`;
+ }
+ if (trace) {
+ err += `\nTrace Id: ${trace}`;
+ }
+ return err;
+ }
+
+ _handleShowAlert(e: CustomEvent) {
+ this._showAlert(
+ e.detail.message,
+ e.detail.action,
+ e.detail.callback,
+ e.detail.dismissOnNavigation
+ );
+ }
+
+ _handleNetworkError(e: CustomEvent) {
+ this._showAlert('Server unavailable');
+ console.error(e.detail.error.message);
+ }
+
+ // TODO(dhruvsr): allow less priority alerts to override high priority alerts
+ // In some use cases we may want generic alerts to show along/over errors
+ _canOverride(incoming = ErrorType.GENERIC, existing = ErrorType.GENERIC) {
+ return ErrorTypePriority[incoming] >= ErrorTypePriority[existing];
+ }
+
+ _showAlert(
+ text: string,
+ actionText?: string,
+ actionCallback?: () => void,
+ dismissOnNavigation?: boolean,
+ type?: ErrorType
+ ) {
+ if (this._alertElement) {
+ // check priority before hiding
+ if (!this._canOverride(type, this._alertElement.type)) return;
+ this._hideAlert();
+ }
+
+ this._clearHideAlertHandle();
+ if (dismissOnNavigation) {
+ // Persist alert until navigation.
+ this.listen(document, 'location-change', '_hideAlert');
+ } else {
+ this._hideAlertHandle = this.async(
+ this._hideAlert,
+ HIDE_ALERT_TIMEOUT_MS
+ );
+ }
+ const el = this._createToastAlert();
+ el.show(text, actionText, actionCallback);
+ this._alertElement = el;
+ this.fire('iron-announce', {text}, {bubbles: true});
+ this.reporting.reportInteraction('show-alert', {text});
+ }
+
+ _hideAlert() {
+ if (!this._alertElement) {
+ return;
+ }
+
+ this._alertElement.hide();
+ this._alertElement = null;
+
+ // Remove listener for page navigation, if it exists.
+ this.unlisten(document, 'location-change', '_hideAlert');
+ }
+
+ _clearHideAlertHandle() {
+ if (this._hideAlertHandle !== null) {
+ this.cancelAsync(this._hideAlertHandle);
+ this._hideAlertHandle = null;
+ }
+ }
+
+ _showAuthErrorAlert(errorText: string, actionText?: string) {
+ // hide any existing alert like `reload`
+ // as auth error should have the highest priority
+ if (this._alertElement) {
+ this._alertElement.hide();
+ }
+
+ this._alertElement = this._createToastAlert();
+ this._alertElement.type = ErrorType.AUTH;
+ this._alertElement.show(errorText, actionText, () =>
+ this._createLoginPopup()
+ );
+ this.fire('iron-announce', {text: errorText}, {bubbles: true});
+ this._refreshingCredentials = true;
+ this._requestCheckLoggedIn();
+ if (!document.hidden) {
+ this._handleVisibilityChange();
+ }
+ }
+
+ _createToastAlert() {
+ const el = document.createElement('gr-alert');
+ el.toast = true;
+ return el;
+ }
+
+ _handleVisibilityChange() {
+ // Ignore when the page is transitioning to hidden (or hidden is
+ // undefined).
+ if (document.hidden !== false) {
+ return;
+ }
+
+ // If not currently refreshing credentials and the credentials are old,
+ // request them to confirm their validity or (display an auth toast if it
+ // fails).
+ const timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
+ if (
+ !this._refreshingCredentials &&
+ this.knownAccountId !== undefined &&
+ timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS
+ ) {
+ this._lastCredentialCheck = Date.now();
+
+ // check auth status in case:
+ // - user signed out
+ // - user switched account
+ this._checkSignedIn();
+ }
+ }
+
+ _requestCheckLoggedIn() {
+ this.debounce(
+ 'checkLoggedIn',
+ this._checkSignedIn,
+ CHECK_SIGN_IN_INTERVAL_MS
+ );
+ }
+
+ _checkSignedIn() {
+ this._lastCredentialCheck = Date.now();
+
+ // force to refetch account info
+ this.$.restAPI.invalidateAccountsCache();
+ this._authService.clearCache();
+
+ this.$.restAPI.getLoggedIn().then(isLoggedIn => {
+ // do nothing if its refreshing
+ if (!this._refreshingCredentials) return;
+
+ if (!isLoggedIn) {
+ // check later
+ // 1. guest mode
+ // 2. or signed out
+ // in case #2, auth-error is taken care of separately
+ this._requestCheckLoggedIn();
+ } else {
+ // check account
+ this.$.restAPI.getAccount().then(account => {
+ if (this._refreshingCredentials) {
+ // If the credentials were refreshed but the account is different
+ // then reload the page completely.
+ if (account?._account_id !== this.knownAccountId) {
+ this._reloadPage();
+ return;
+ }
+
+ this._handleCredentialRefreshed();
+ }
+ });
+ }
+ });
+ }
+
+ _reloadPage() {
+ window.location.reload();
+ }
+
+ _createLoginPopup() {
+ const left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
+ const top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
+ const options = [
+ `width=${SIGN_IN_WIDTH_PX}`,
+ `height=${SIGN_IN_HEIGHT_PX}`,
+ `left=${left}`,
+ `top=${top}`,
+ ];
+ window.open(
+ getBaseUrl() + '/login/%3FcloseAfterLogin',
+ '_blank',
+ options.join(',')
+ );
+ this.listen(window, 'focus', '_handleWindowFocus');
+ }
+
+ _handleCredentialRefreshed() {
+ this.unlisten(window, 'focus', '_handleWindowFocus');
+ this._refreshingCredentials = false;
+ this._hideAlert();
+ this._showAlert('Credentials refreshed.');
+ this.$.noInteractionOverlay.close();
+
+ // Clear the cache for auth
+ this._authService.clearCache();
+ }
+
+ _handleWindowFocus() {
+ this.flushDebouncer('checkLoggedIn');
+ }
+
+ _handleShowErrorDialog(e: CustomEvent) {
+ this._showErrorDialog(e.detail.message);
+ }
+
+ _handleDismissErrorDialog() {
+ this.$.errorOverlay.close();
+ }
+
+ _showErrorDialog(message: string, options?: {showSignInButton?: boolean}) {
+ this.reporting.reportErrorDialog(message);
+ this.$.errorDialog.text = message;
+ this.$.errorDialog.showSignInButton =
+ !!options && !!options.showSignInButton;
+ this.$.errorOverlay.open();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-error-manager': GrErrorManager;
+ }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
index b527786..f13276f 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-error-manager.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {__testOnly_ErrorType} from './gr-error-manager.js';
@@ -271,9 +270,9 @@
let toast = toastSpy.lastCall.returnValue;
assert.isOk(toast);
assert.include(
- dom(toast.root).textContent, 'Credentials expired.');
+ toast.root.textContent, 'Credentials expired.');
assert.include(
- dom(toast.root).textContent, 'Refresh credentials');
+ toast.root.textContent, 'Refresh credentials');
// noInteractionOverlay
const noInteractionOverlay = element.$.noInteractionOverlay;
@@ -306,7 +305,7 @@
toast = toastSpy.lastCall.returnValue;
assert.isOk(toast);
assert.include(
- dom(toast.root).textContent, 'Credentials refreshed');
+ toast.root.textContent, 'Credentials refreshed');
// close overlay
assert.isTrue(noInteractionOverlay.close.called);
@@ -326,7 +325,7 @@
let toast = toastSpy.lastCall.returnValue;
assert.isOk(toast);
assert.include(
- dom(toast.root).textContent, 'test reload');
+ toast.root.textContent, 'test reload');
// fake auth
window.fetch.returns(Promise.resolve({status: 403}));
@@ -347,9 +346,9 @@
// toast
toast = toastSpy.lastCall.returnValue;
assert.include(
- dom(toast.root).textContent, 'Credentials expired.');
+ toast.root.textContent, 'Credentials expired.');
assert.include(
- dom(toast.root).textContent, 'Refresh credentials');
+ toast.root.textContent, 'Refresh credentials');
});
test('regular toast should dismiss regular toast', () => {
@@ -365,7 +364,7 @@
let toast = toastSpy.lastCall.returnValue;
assert.isOk(toast);
assert.include(
- dom(toast.root).textContent, 'test reload');
+ toast.root.textContent, 'test reload');
// new alert
element.dispatchEvent(
@@ -375,7 +374,7 @@
}));
toast = toastSpy.lastCall.returnValue;
- assert.include(dom(toast.root).textContent, 'second-test');
+ assert.include(toast.root.textContent, 'second-test');
});
test('regular toast should not dismiss auth toast', done => {
@@ -399,9 +398,9 @@
flush(() => {
let toast = toastSpy.lastCall.returnValue;
assert.include(
- dom(toast.root).textContent, 'Credentials expired.');
+ toast.root.textContent, 'Credentials expired.');
assert.include(
- dom(toast.root).textContent, 'Refresh credentials');
+ toast.root.textContent, 'Refresh credentials');
// fake an alert
element.dispatchEvent(
@@ -415,7 +414,7 @@
toast = toastSpy.lastCall.returnValue;
assert.isOk(toast);
assert.include(
- dom(toast.root).textContent, 'Credentials expired.');
+ toast.root.textContent, 'Credentials expired.');
done();
});
});
@@ -497,7 +496,7 @@
detail: {message},
composed: true, bubbles: true,
}));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(openStub.called);
assert.isTrue(reportStub.called);
@@ -507,7 +506,7 @@
new CustomEvent('dismiss', {
composed: true, bubbles: true,
}));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(closeStub.called);
});
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
deleted file mode 100644
index b8b414d..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.js
+++ /dev/null
@@ -1,50 +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 '../../../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-key-binding-display_html.js';
-
-/** @extends PolymerElement */
-class GrKeyBindingDisplay extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-key-binding-display'; }
-
- static get properties() {
- return {
- /** @type {Array<Array<string>>}
- * Each entry in the binding represents an array that is a keyboard
- * shortcut containing [modifier, combination]
- */
- binding: Array,
- };
- }
-
- _computeModifiers(binding) {
- return binding.slice(0, binding.length - 1);
- }
-
- _computeKey(binding) {
- return binding[binding.length - 1];
- }
-}
-
-customElements.define(GrKeyBindingDisplay.is, GrKeyBindingDisplay);
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
new file mode 100644
index 0000000..796a167
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.ts
@@ -0,0 +1,48 @@
+/**
+ * @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 '../../../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-key-binding-display_html';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-key-binding-display': GrKeyBindingDisplay;
+ }
+}
+
+@customElement('gr-key-binding-display')
+export class GrKeyBindingDisplay extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Array})
+ binding: string[][] = [];
+
+ _computeModifiers(binding: string[][]) {
+ return binding.slice(0, binding.length - 1);
+ }
+
+ _computeKey(binding: string[][]) {
+ return binding[binding.length - 1];
+ }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
deleted file mode 100644
index 31fece5..0000000
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ /dev/null
@@ -1,130 +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-button/gr-button.js';
-import '../gr-key-binding-display/gr-key-binding-display.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-keyboard-shortcuts-dialog_html.js';
-import {KeyboardShortcutMixin, ShortcutSection} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-/**
- * @extends PolymerElement
- */
-class GrKeyboardShortcutsDialog extends KeyboardShortcutMixin(
- GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-keyboard-shortcuts-dialog'; }
- /**
- * Fired when the user presses the close button.
- *
- * @event close
- */
-
- static get properties() {
- return {
- _left: Array,
- _right: Array,
- };
- }
-
- /** @override */
- ready() {
- super.ready();
- this._ensureAttribute('role', 'dialog');
- }
-
- /** @override */
- attached() {
- super.attached();
- this.keyboardShortcutDirectoryListener =
- this._onDirectoryUpdated.bind(this);
- this.addKeyboardShortcutDirectoryListener(
- this.keyboardShortcutDirectoryListener);
- }
-
- /** @override */
- detached() {
- super.detached();
- this.removeKeyboardShortcutDirectoryListener(
- this.keyboardShortcutDirectoryListener);
- }
-
- _handleCloseTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('close', {
- composed: true, bubbles: false,
- }));
- }
-
- _onDirectoryUpdated(directory) {
- const left = [];
- const right = [];
-
- if (directory.has(ShortcutSection.EVERYWHERE)) {
- left.push({
- section: ShortcutSection.EVERYWHERE,
- shortcuts: directory.get(ShortcutSection.EVERYWHERE),
- });
- }
-
- if (directory.has(ShortcutSection.NAVIGATION)) {
- left.push({
- section: ShortcutSection.NAVIGATION,
- shortcuts: directory.get(ShortcutSection.NAVIGATION),
- });
- }
-
- if (directory.has(ShortcutSection.ACTIONS)) {
- right.push({
- section: ShortcutSection.ACTIONS,
- shortcuts: directory.get(ShortcutSection.ACTIONS),
- });
- }
-
- if (directory.has(ShortcutSection.REPLY_DIALOG)) {
- right.push({
- section: ShortcutSection.REPLY_DIALOG,
- shortcuts: directory.get(ShortcutSection.REPLY_DIALOG),
- });
- }
-
- if (directory.has(ShortcutSection.FILE_LIST)) {
- right.push({
- section: ShortcutSection.FILE_LIST,
- shortcuts: directory.get(ShortcutSection.FILE_LIST),
- });
- }
-
- if (directory.has(ShortcutSection.DIFFS)) {
- right.push({
- section: ShortcutSection.DIFFS,
- shortcuts: directory.get(ShortcutSection.DIFFS),
- });
- }
-
- this.set('_left', left);
- this.set('_right', right);
- }
-}
-
-customElements.define(GrKeyboardShortcutsDialog.is,
- GrKeyboardShortcutsDialog);
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
new file mode 100644
index 0000000..4bd90ea
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -0,0 +1,157 @@
+/**
+ * @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-button/gr-button';
+import '../gr-key-binding-display/gr-key-binding-display';
+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-keyboard-shortcuts-dialog_html';
+import {
+ KeyboardShortcutMixin,
+ ShortcutSection,
+ ShortcutListener,
+ SectionView,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {property, customElement} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-keyboard-shortcuts-dialog': GrKeyboardShortcutsDialog;
+ }
+}
+
+interface SectionShortcut {
+ section: ShortcutSection;
+ shortcuts?: SectionView;
+}
+
+@customElement('gr-keyboard-shortcuts-dialog')
+export class GrKeyboardShortcutsDialog extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the user presses the close button.
+ *
+ * @event close
+ */
+
+ @property({type: Array})
+ _left?: SectionShortcut[];
+
+ @property({type: Array})
+ _right?: SectionShortcut[];
+
+ private keyboardShortcutDirectoryListener: ShortcutListener;
+
+ constructor() {
+ super();
+ this.keyboardShortcutDirectoryListener = (
+ d?: Map<ShortcutSection, SectionView>
+ ) => this._onDirectoryUpdated(d);
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._ensureAttribute('role', 'dialog');
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.addKeyboardShortcutDirectoryListener(
+ this.keyboardShortcutDirectoryListener
+ );
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.removeKeyboardShortcutDirectoryListener(
+ this.keyboardShortcutDirectoryListener
+ );
+ }
+
+ _handleCloseTap(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('close', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _onDirectoryUpdated(directory?: Map<ShortcutSection, SectionView>) {
+ if (!directory) {
+ return;
+ }
+ const left = [] as SectionShortcut[];
+ const right = [] as SectionShortcut[];
+
+ if (directory.has(ShortcutSection.EVERYWHERE)) {
+ left.push({
+ section: ShortcutSection.EVERYWHERE,
+ shortcuts: directory.get(ShortcutSection.EVERYWHERE),
+ });
+ }
+
+ if (directory.has(ShortcutSection.NAVIGATION)) {
+ left.push({
+ section: ShortcutSection.NAVIGATION,
+ shortcuts: directory.get(ShortcutSection.NAVIGATION),
+ });
+ }
+
+ if (directory.has(ShortcutSection.ACTIONS)) {
+ right.push({
+ section: ShortcutSection.ACTIONS,
+ shortcuts: directory.get(ShortcutSection.ACTIONS),
+ });
+ }
+
+ if (directory.has(ShortcutSection.REPLY_DIALOG)) {
+ right.push({
+ section: ShortcutSection.REPLY_DIALOG,
+ shortcuts: directory.get(ShortcutSection.REPLY_DIALOG),
+ });
+ }
+
+ if (directory.has(ShortcutSection.FILE_LIST)) {
+ right.push({
+ section: ShortcutSection.FILE_LIST,
+ shortcuts: directory.get(ShortcutSection.FILE_LIST),
+ });
+ }
+
+ if (directory.has(ShortcutSection.DIFFS)) {
+ right.push({
+ section: ShortcutSection.DIFFS,
+ shortcuts: directory.get(ShortcutSection.DIFFS),
+ });
+ }
+
+ this.set('_left', left);
+ this.set('_right', right);
+ }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js
index cb7e87b8..f76041e 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog_test.js
@@ -30,7 +30,7 @@
function update(directory) {
element._onDirectoryUpdated(directory);
- flushAsynchronousOperations();
+ flush();
}
suite('_left and _right contents', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
deleted file mode 100644
index b36435e..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ /dev/null
@@ -1,351 +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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-account-dropdown/gr-account-dropdown.js';
-import '../gr-smart-search/gr-smart-search.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-main-header_html.js';
-import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {getAdminLinks} from '../../../utils/admin-nav-util.js';
-
-const DEFAULT_LINKS = [{
- title: 'Changes',
- links: [
- {
- url: '/q/status:open+-is:wip',
- name: 'Open',
- },
- {
- url: '/q/status:merged',
- name: 'Merged',
- },
- {
- url: '/q/status:abandoned',
- name: 'Abandoned',
- },
- ],
-}];
-
-const DOCUMENTATION_LINKS = [
- {
- url: '/index.html',
- name: 'Table of Contents',
- },
- {
- url: '/user-search.html',
- name: 'Searching',
- },
- {
- url: '/user-upload.html',
- name: 'Uploading',
- },
- {
- url: '/access-control.html',
- name: 'Access Control',
- },
- {
- url: '/rest-api.html',
- name: 'REST API',
- },
- {
- url: '/intro-project-owner.html',
- name: 'Project Owner Guide',
- },
-];
-
-// Set of authentication methods that can provide custom registration page.
-const AUTH_TYPES_WITH_REGISTER_URL = new Set([
- 'LDAP',
- 'LDAP_BIND',
- 'CUSTOM_EXTENSION',
-]);
-
-/**
- * @extends PolymerElement
- */
-class GrMainHeader extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-main-header'; }
-
- static get properties() {
- return {
- searchQuery: {
- type: String,
- notify: true,
- },
- loggedIn: {
- type: Boolean,
- reflectToAttribute: true,
- },
- loading: {
- type: Boolean,
- reflectToAttribute: true,
- },
-
- /** @type {?Object} */
- _account: Object,
- _adminLinks: {
- type: Array,
- value() { return []; },
- },
- _defaultLinks: {
- type: Array,
- value() {
- return DEFAULT_LINKS;
- },
- },
- _docBaseUrl: {
- type: String,
- value: null,
- },
- _links: {
- type: Array,
- computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
- '_topMenus, _docBaseUrl)',
- },
- loginUrl: {
- type: String,
- value: '/login',
- },
- _userLinks: {
- type: Array,
- value() { return []; },
- },
- _topMenus: {
- type: Array,
- value() { return []; },
- },
- _registerText: {
- type: String,
- value: 'Sign up',
- },
- _registerURL: {
- type: String,
- value: null,
- },
- };
- }
-
- static get observers() {
- return [
- '_accountLoaded(_account)',
- ];
- }
-
- /** @override */
- ready() {
- super.ready();
- this._ensureAttribute('role', 'banner');
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadAccount();
- this._loadConfig();
- }
-
- /** @override */
- detached() {
- super.detached();
- }
-
- reload() {
- this._loadAccount();
- }
-
- _computeRelativeURL(path) {
- return '//' + window.location.host + getBaseUrl() + path;
- }
-
- _computeLinks(defaultLinks, userLinks, adminLinks, topMenus, docBaseUrl) {
- // Polymer 2: check for undefined
- if ([
- defaultLinks,
- userLinks,
- adminLinks,
- topMenus,
- docBaseUrl,
- ].includes(undefined)) {
- return undefined;
- }
-
- const links = defaultLinks.map(menu => {
- return {
- title: menu.title,
- links: menu.links.slice(),
- };
- });
- if (userLinks && userLinks.length > 0) {
- links.push({
- title: 'Your',
- links: userLinks.slice(),
- });
- }
- const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
- if (docLinks.length) {
- links.push({
- title: 'Documentation',
- links: docLinks,
- class: 'hideOnMobile',
- });
- }
- links.push({
- title: 'Browse',
- links: adminLinks.slice(),
- });
- const topMenuLinks = [];
- links.forEach(link => { topMenuLinks[link.title] = link.links; });
- for (const m of topMenus) {
- const items = m.items.map(this._fixCustomMenuItem).filter(link =>
- // Ignore GWT project links
- !link.url.includes('${projectName}')
- );
- if (m.name in topMenuLinks) {
- items.forEach(link => { topMenuLinks[m.name].push(link); });
- } else {
- links.push({
- title: m.name,
- links: topMenuLinks[m.name] = items,
- });
- }
- }
- return links;
- }
-
- _getDocLinks(docBaseUrl, docLinks) {
- if (!docBaseUrl || !docLinks) {
- return [];
- }
- return docLinks.map(link => {
- let url = docBaseUrl;
- if (url && url[url.length - 1] === '/') {
- url = url.substring(0, url.length - 1);
- }
- return {
- url: url + link.url,
- name: link.name,
- target: '_blank',
- };
- });
- }
-
- _loadAccount() {
- this.loading = true;
- const promises = [
- this.$.restAPI.getAccount(),
- this.$.restAPI.getTopMenus(),
- pluginLoader.awaitPluginsLoaded(),
- ];
-
- return Promise.all(promises).then(result => {
- const account = result[0];
- this._account = account;
- this.loggedIn = !!account;
- this.loading = false;
- this._topMenus = result[1];
-
- return getAdminLinks(account,
- this.$.restAPI.getAccountCapabilities.bind(this.$.restAPI),
- this.$.jsAPI.getAdminMenuLinks.bind(this.$.jsAPI))
- .then(res => {
- this._adminLinks = res.links;
- });
- });
- }
-
- _loadConfig() {
- this.$.restAPI.getConfig()
- .then(config => {
- this._retrieveRegisterURL(config);
- return getDocsBaseUrl(config, this.$.restAPI);
- })
- .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
- }
-
- _accountLoaded(account) {
- if (!account) { return; }
-
- this.$.restAPI.getPreferences().then(prefs => {
- this._userLinks = prefs && prefs.my ?
- prefs.my.map(this._fixCustomMenuItem) : [];
- });
- }
-
- _retrieveRegisterURL(config) {
- if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
- this._registerURL = config.auth.register_url;
- if (config.auth.register_text) {
- this._registerText = config.auth.register_text;
- }
- }
- }
-
- _computeIsInvisible(registerURL) {
- return registerURL ? '' : 'invisible';
- }
-
- _fixCustomMenuItem(linkObj) {
- // Normalize all urls to PolyGerrit style.
- if (linkObj.url.startsWith('#')) {
- linkObj.url = linkObj.url.slice(1);
- }
-
- // Delete target property due to complications of
- // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
- //
- // The server tries to guess whether URL is a view within the UI.
- // If not, it sets target='_blank' on the menu item. The server
- // makes assumptions that work for the GWT UI, but not PolyGerrit,
- // so we'll just disable it altogether for now.
- delete linkObj.target;
-
- return linkObj;
- }
-
- _generateSettingsLink() {
- return getBaseUrl() + '/settings/';
- }
-
- _onMobileSearchTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('mobile-search', {
- composed: true, bubbles: false,
- }));
- }
-
- _computeLinkGroupClass(linkGroup) {
- if (linkGroup && linkGroup.class) {
- return linkGroup.class;
- }
-
- return '';
- }
-}
-
-customElements.define(GrMainHeader.is, GrMainHeader);
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
new file mode 100644
index 0000000..caa0521
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.ts
@@ -0,0 +1,393 @@
+/**
+ * @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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-account-dropdown/gr-account-dropdown';
+import '../gr-smart-search/gr-smart-search';
+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-main-header_html';
+import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ AccountDetailInfo,
+ RequireProperties,
+ ServerInfo,
+ TopMenuEntryInfo,
+ TopMenuItemInfo,
+} from '../../../types/common';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {AuthType} from '../../../constants/constants';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+
+type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
+
+interface MainHeaderLinkGroup {
+ title: string;
+ links: MainHeaderLink[];
+ class?: string;
+}
+
+const DEFAULT_LINKS: MainHeaderLinkGroup[] = [
+ {
+ title: 'Changes',
+ links: [
+ {
+ url: '/q/status:open+-is:wip',
+ name: 'Open',
+ },
+ {
+ url: '/q/status:merged',
+ name: 'Merged',
+ },
+ {
+ url: '/q/status:abandoned',
+ name: 'Abandoned',
+ },
+ ],
+ },
+];
+
+const DOCUMENTATION_LINKS: MainHeaderLink[] = [
+ {
+ url: '/index.html',
+ name: 'Table of Contents',
+ },
+ {
+ url: '/user-search.html',
+ name: 'Searching',
+ },
+ {
+ url: '/user-upload.html',
+ name: 'Uploading',
+ },
+ {
+ url: '/access-control.html',
+ name: 'Access Control',
+ },
+ {
+ url: '/rest-api.html',
+ name: 'REST API',
+ },
+ {
+ url: '/intro-project-owner.html',
+ name: 'Project Owner Guide',
+ },
+];
+
+// Set of authentication methods that can provide custom registration page.
+const AUTH_TYPES_WITH_REGISTER_URL: Set<AuthType> = new Set([
+ AuthType.LDAP,
+ AuthType.LDAP_BIND,
+ AuthType.CUSTOM_EXTENSION,
+]);
+
+export interface GrMainHeader {
+ $: {
+ restAPI: RestApiService & Element;
+ jsAPI: JsApiService & Element;
+ };
+}
+
+@customElement('gr-main-header')
+export class GrMainHeader extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, notify: true})
+ searchQuery?: string;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ loggedIn?: boolean;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ loading?: boolean;
+
+ @property({type: Object})
+ _account?: AccountDetailInfo;
+
+ @property({type: Array})
+ _adminLinks: NavLink[] = [];
+
+ @property({type: String})
+ _docBaseUrl: string | null = null;
+
+ @property({
+ type: Array,
+ computed: '_computeLinks(_userLinks, _adminLinks, _topMenus, _docBaseUrl)',
+ })
+ _links?: MainHeaderLinkGroup[];
+
+ @property({type: String})
+ loginUrl = '/login';
+
+ @property({type: Array})
+ _userLinks: MainHeaderLink[] = [];
+
+ @property({type: Array})
+ _topMenus?: TopMenuEntryInfo[] = [];
+
+ @property({type: String})
+ _registerText = 'Sign up';
+
+ // Empty string means that the register <div> will be hidden.
+ @property({type: String})
+ _registerURL = '';
+
+ @property({type: Boolean})
+ mobileSearchHidden = false;
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._ensureAttribute('role', 'banner');
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadAccount();
+ this._loadConfig();
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ }
+
+ reload() {
+ this._loadAccount();
+ }
+
+ _computeRelativeURL(path: string) {
+ return '//' + window.location.host + getBaseUrl() + path;
+ }
+
+ _computeLinks(
+ userLinks?: TopMenuItemInfo[],
+ adminLinks?: NavLink[],
+ topMenus?: TopMenuEntryInfo[],
+ docBaseUrl?: string | null,
+ // defaultLinks parameter is used in tests only
+ defaultLinks = DEFAULT_LINKS
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ userLinks === undefined ||
+ adminLinks === undefined ||
+ topMenus === undefined ||
+ docBaseUrl === undefined
+ ) {
+ return undefined;
+ }
+
+ const links: MainHeaderLinkGroup[] = defaultLinks.map(menu => {
+ return {
+ title: menu.title,
+ links: menu.links.slice(),
+ };
+ });
+ if (userLinks && userLinks.length > 0) {
+ links.push({
+ title: 'Your',
+ links: userLinks.slice(),
+ });
+ }
+ const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+ if (docLinks.length) {
+ links.push({
+ title: 'Documentation',
+ links: docLinks,
+ class: 'hideOnMobile',
+ });
+ }
+ links.push({
+ title: 'Browse',
+ links: adminLinks.slice(),
+ });
+ const topMenuLinks: {[name: string]: MainHeaderLink[]} = {};
+ links.forEach(link => {
+ topMenuLinks[link.title] = link.links;
+ });
+ for (const m of topMenus) {
+ const items = m.items.map(this._createHeaderLink).filter(
+ link =>
+ // Ignore GWT project links
+ !link.url.includes('${projectName}')
+ );
+ if (m.name in topMenuLinks) {
+ items.forEach(link => {
+ topMenuLinks[m.name].push(link);
+ });
+ } else {
+ links.push({
+ title: m.name,
+ links: topMenuLinks[m.name] = items,
+ });
+ }
+ }
+ return links;
+ }
+
+ _getDocLinks(docBaseUrl: string | null, docLinks: MainHeaderLink[]) {
+ if (!docBaseUrl) {
+ return [];
+ }
+ return docLinks.map(link => {
+ let url = docBaseUrl;
+ if (url && url[url.length - 1] === '/') {
+ url = url.substring(0, url.length - 1);
+ }
+ return {
+ url: url + link.url,
+ name: link.name,
+ target: '_blank',
+ };
+ });
+ }
+
+ _loadAccount() {
+ this.loading = true;
+
+ return Promise.all([
+ this.$.restAPI.getAccount(),
+ this.$.restAPI.getTopMenus(),
+ getPluginLoader().awaitPluginsLoaded(),
+ ]).then(result => {
+ const account = result[0];
+ this._account = account;
+ this.loggedIn = !!account;
+ this.loading = false;
+ this._topMenus = result[1];
+
+ return getAdminLinks(
+ account,
+ () =>
+ this.$.restAPI.getAccountCapabilities().then(capabilities => {
+ if (!capabilities) {
+ throw new Error('getAccountCapabilities returns undefined');
+ }
+ return capabilities;
+ }),
+ () => this.$.jsAPI.getAdminMenuLinks()
+ ).then(res => {
+ this._adminLinks = res.links;
+ });
+ });
+ }
+
+ _loadConfig() {
+ this.$.restAPI
+ .getConfig()
+ .then(config => {
+ if (!config) {
+ throw new Error('getConfig returned undefined');
+ }
+ this._retrieveRegisterURL(config);
+ return getDocsBaseUrl(config, this.$.restAPI);
+ })
+ .then(docBaseUrl => {
+ this._docBaseUrl = docBaseUrl;
+ });
+ }
+
+ @observe('_account')
+ _accountLoaded(account?: AccountDetailInfo) {
+ if (!account) {
+ return;
+ }
+
+ this.$.restAPI.getPreferences().then(prefs => {
+ this._userLinks =
+ prefs && prefs.my ? prefs.my.map(this._createHeaderLink) : [];
+ });
+ }
+
+ _retrieveRegisterURL(config: ServerInfo) {
+ if (AUTH_TYPES_WITH_REGISTER_URL.has(config.auth.auth_type)) {
+ this._registerURL = config.auth.register_url ?? '';
+ if (config.auth.register_text) {
+ this._registerText = config.auth.register_text;
+ }
+ }
+ }
+
+ _computeRegisterHidden(registerURL: string) {
+ return !registerURL;
+ }
+
+ _createHeaderLink(linkObj: TopMenuItemInfo): MainHeaderLink {
+ // Delete target property due to complications of
+ // https://bugs.chromium.org/p/gerrit/issues/detail?id=5888
+ //
+ // The server tries to guess whether URL is a view within the UI.
+ // If not, it sets target='_blank' on the menu item. The server
+ // makes assumptions that work for the GWT UI, but not PolyGerrit,
+ // so we'll just disable it altogether for now.
+ const {target, ...headerLink} = {...linkObj};
+
+ // Normalize all urls to PolyGerrit style.
+ if (headerLink.url.startsWith('#')) {
+ headerLink.url = linkObj.url.slice(1);
+ }
+
+ return headerLink;
+ }
+
+ _generateSettingsLink() {
+ return getBaseUrl() + '/settings/';
+ }
+
+ _onMobileSearchTap(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('mobile-search', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _computeLinkGroupClass(linkGroup: MainHeaderLinkGroup) {
+ return linkGroup.class ?? '';
+ }
+
+ _computeShowHideAriaLabel(mobileSearchHidden: boolean) {
+ if (mobileSearchHidden) {
+ return 'Show Searchbar';
+ } else {
+ return 'Hide Searchbar';
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-main-header': GrMainHeader;
+ }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
index 8ef54d6..5778fb8 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_html.ts
@@ -211,8 +211,13 @@
id="mobileSearch"
icon="gr-icons:search"
on-tap="_onMobileSearchTap"
+ role="button"
+ aria-label="[[_computeShowHideAriaLabel(mobileSearchHidden)]]"
></iron-icon>
- <div class$="[[_computeIsInvisible(_registerURL)]]">
+ <div
+ class="registerDiv"
+ hidden="[[_computeRegisterHidden(_registerURL)]]"
+ >
<a class="registerButton" href$="[[_registerURL]]">
[[_registerText]]
</a>
@@ -227,7 +232,9 @@
>
<iron-icon icon="gr-icons:settings"></iron-icon>
</a>
- <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
+ <template is="dom-if" if="[[_account]]">
+ <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
+ </template>
</div>
</div>
</nav>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
deleted file mode 100644
index b3ac40f..0000000
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.js
+++ /dev/null
@@ -1,390 +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 '../../../test/common-test-setup-karma.js';
-import './gr-main-header.js';
-
-const basicFixture = fixtureFromElement('gr-main-header');
-
-suite('gr-main-header tests', () => {
- let element;
-
- setup(() => {
- stub('gr-rest-api-interface', {
- getConfig() { return Promise.resolve({}); },
- probePath(path) { return Promise.resolve(false); },
- });
- stub('gr-main-header', {
- _loadAccount() {},
- });
- element = basicFixture.instantiate();
- });
-
- test('link visibility', () => {
- element.loading = true;
- assert.equal(getComputedStyle(element.shadowRoot
- .querySelector('.accountContainer')).display,
- 'none');
- element.loading = false;
- element.loggedIn = false;
- assert.notEqual(getComputedStyle(element.shadowRoot
- .querySelector('.accountContainer')).display,
- 'none');
- assert.notEqual(getComputedStyle(element.shadowRoot
- .querySelector('.loginButton')).display,
- 'none');
- assert.notEqual(getComputedStyle(element.shadowRoot
- .querySelector('.registerButton')).display,
- 'none');
- assert.equal(getComputedStyle(element.shadowRoot
- .querySelector('gr-account-dropdown')).display,
- 'none');
- assert.equal(getComputedStyle(element.shadowRoot
- .querySelector('.settingsButton')).display,
- 'none');
- element.loggedIn = true;
- assert.equal(getComputedStyle(element.shadowRoot
- .querySelector('.loginButton')).display,
- 'none');
- assert.equal(getComputedStyle(element.shadowRoot
- .querySelector('.registerButton')).display,
- 'none');
- assert.notEqual(getComputedStyle(element.shadowRoot
- .querySelector('gr-account-dropdown'))
- .display,
- 'none');
- assert.notEqual(getComputedStyle(element.shadowRoot
- .querySelector('.settingsButton')).display,
- 'none');
- });
-
- test('fix my menu item', () => {
- assert.deepEqual([
- {url: 'https://awesometown.com/#hashyhash'},
- {url: 'url', target: '_blank'},
- ].map(element._fixCustomMenuItem), [
- {url: 'https://awesometown.com/#hashyhash'},
- {url: 'url'},
- ]);
- });
-
- test('user links', () => {
- const defaultLinks = [{
- title: 'Faves',
- links: [{
- name: 'Pinterest',
- url: 'https://pinterest.com',
- }],
- }];
- const userLinks = [{
- name: 'Facebook',
- url: 'https://facebook.com',
- }];
- const adminLinks = [{
- name: 'Repos',
- url: '/repos',
- }];
-
- // When no admin links are passed, it should use the default.
- assert.deepEqual(element._computeLinks(
- defaultLinks,
- /* userLinks= */[],
- adminLinks,
- /* topMenus= */[],
- /* docBaseUrl= */ ''
- ),
- defaultLinks.concat({
- title: 'Browse',
- links: adminLinks,
- }));
- assert.deepEqual(element._computeLinks(
- defaultLinks,
- userLinks,
- adminLinks,
- /* topMenus= */[],
- /* docBaseUrl= */ ''
- ),
- defaultLinks.concat([
- {
- title: 'Your',
- links: userLinks,
- },
- {
- title: 'Browse',
- links: adminLinks,
- }])
- );
- });
-
- test('documentation links', () => {
- const docLinks = [
- {
- name: 'Table of Contents',
- url: '/index.html',
- },
- ];
-
- assert.deepEqual(element._getDocLinks(null, docLinks), []);
- assert.deepEqual(element._getDocLinks('', docLinks), []);
- assert.deepEqual(element._getDocLinks('base', null), []);
- assert.deepEqual(element._getDocLinks('base', []), []);
-
- assert.deepEqual(element._getDocLinks('base', docLinks), [{
- name: 'Table of Contents',
- target: '_blank',
- url: 'base/index.html',
- }]);
-
- assert.deepEqual(element._getDocLinks('base/', docLinks), [{
- name: 'Table of Contents',
- target: '_blank',
- url: 'base/index.html',
- }]);
- });
-
- test('top menus', () => {
- const adminLinks = [{
- name: 'Repos',
- url: '/repos',
- }];
- const topMenus = [{
- name: 'Plugins',
- items: [{
- name: 'Manage',
- target: '_blank',
- url: 'https://gerrit/plugins/plugin-manager/static/index.html',
- }],
- }];
- assert.deepEqual(element._computeLinks(
- /* defaultLinks= */ [],
- /* userLinks= */ [],
- adminLinks,
- topMenus,
- /* baseDocUrl= */ ''
- ), [{
- title: 'Browse',
- links: adminLinks,
- },
- {
- title: 'Plugins',
- links: [{
- name: 'Manage',
- url: 'https://gerrit/plugins/plugin-manager/static/index.html',
- }],
- }]);
- });
-
- test('ignore top project menus', () => {
- const adminLinks = [{
- name: 'Repos',
- url: '/repos',
- }];
- const topMenus = [{
- name: 'Projects',
- items: [{
- name: 'Project Settings',
- target: '_blank',
- url: '/plugins/myplugin/${projectName}',
- }, {
- name: 'Project List',
- target: '_blank',
- url: '/plugins/myplugin/index.html',
- }],
- }];
- assert.deepEqual(element._computeLinks(
- /* defaultLinks= */ [],
- /* userLinks= */ [],
- adminLinks,
- topMenus,
- /* baseDocUrl= */ ''
- ), [{
- title: 'Browse',
- links: adminLinks,
- },
- {
- title: 'Projects',
- links: [{
- name: 'Project List',
- url: '/plugins/myplugin/index.html',
- }],
- }]);
- });
-
- test('merge top menus', () => {
- const adminLinks = [{
- name: 'Repos',
- url: '/repos',
- }];
- const topMenus = [{
- name: 'Plugins',
- items: [{
- name: 'Manage',
- target: '_blank',
- url: 'https://gerrit/plugins/plugin-manager/static/index.html',
- }],
- }, {
- name: 'Plugins',
- items: [{
- name: 'Create',
- target: '_blank',
- url: 'https://gerrit/plugins/plugin-manager/static/create.html',
- }],
- }];
- assert.deepEqual(element._computeLinks(
- /* defaultLinks= */ [],
- /* userLinks= */ [],
- adminLinks,
- topMenus,
- /* baseDocUrl= */ ''
- ), [{
- title: 'Browse',
- links: adminLinks,
- }, {
- title: 'Plugins',
- links: [{
- name: 'Manage',
- url: 'https://gerrit/plugins/plugin-manager/static/index.html',
- }, {
- name: 'Create',
- url: 'https://gerrit/plugins/plugin-manager/static/create.html',
- }],
- }]);
- });
-
- test('merge top menus in default links', () => {
- const defaultLinks = [{
- title: 'Faves',
- links: [{
- name: 'Pinterest',
- url: 'https://pinterest.com',
- }],
- }];
- const topMenus = [{
- name: 'Faves',
- items: [{
- name: 'Manage',
- target: '_blank',
- url: 'https://gerrit/plugins/plugin-manager/static/index.html',
- }],
- }];
- assert.deepEqual(element._computeLinks(
- defaultLinks,
- /* userLinks= */ [],
- /* adminLinks= */ [],
- topMenus,
- /* baseDocUrl= */ ''
- ), [{
- title: 'Faves',
- links: defaultLinks[0].links.concat([{
- name: 'Manage',
- url: 'https://gerrit/plugins/plugin-manager/static/index.html',
- }]),
- }, {
- title: 'Browse',
- links: [],
- }]);
- });
-
- test('merge top menus in user links', () => {
- const userLinks = [{
- name: 'Facebook',
- url: 'https://facebook.com',
- }];
- const topMenus = [{
- name: 'Your',
- items: [{
- name: 'Manage',
- target: '_blank',
- url: 'https://gerrit/plugins/plugin-manager/static/index.html',
- }],
- }];
- assert.deepEqual(element._computeLinks(
- /* defaultLinks= */ [],
- userLinks,
- /* adminLinks= */ [],
- topMenus,
- /* baseDocUrl= */ ''
- ), [{
- title: 'Your',
- links: userLinks.concat([{
- name: 'Manage',
- url: 'https://gerrit/plugins/plugin-manager/static/index.html',
- }]),
- }, {
- title: 'Browse',
- links: [],
- }]);
- });
-
- test('merge top menus in admin links', () => {
- const adminLinks = [{
- name: 'Repos',
- url: '/repos',
- }];
- const topMenus = [{
- name: 'Browse',
- items: [{
- name: 'Manage',
- target: '_blank',
- url: 'https://gerrit/plugins/plugin-manager/static/index.html',
- }],
- }];
- assert.deepEqual(element._computeLinks(
- /* defaultLinks= */ [],
- /* userLinks= */ [],
- adminLinks,
- topMenus,
- /* baseDocUrl= */ ''
- ), [{
- title: 'Browse',
- links: adminLinks.concat([{
- name: 'Manage',
- url: 'https://gerrit/plugins/plugin-manager/static/index.html',
- }]),
- }]);
- });
-
- test('register URL', () => {
- const config = {
- auth: {
- auth_type: 'LDAP',
- register_url: 'https//gerrit.example.com/register',
- },
- };
- element._retrieveRegisterURL(config);
- assert.equal(element._registerURL, config.auth.register_url);
- assert.equal(element._registerText, 'Sign up');
-
- config.auth.register_text = 'Create account';
- element._retrieveRegisterURL(config);
- assert.equal(element._registerURL, config.auth.register_url);
- assert.equal(element._registerText, config.auth.register_text);
- });
-
- test('register URL ignored for wrong auth type', () => {
- const config = {
- auth: {
- auth_type: 'OPENID',
- register_url: 'https//gerrit.example.com/register',
- },
- };
- element._retrieveRegisterURL(config);
- assert.equal(element._registerURL, null);
- assert.equal(element._registerText, 'Sign up');
- });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
new file mode 100644
index 0000000..3ab40e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.ts
@@ -0,0 +1,521 @@
+/**
+ * @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 '../../../test/common-test-setup-karma';
+import {isHidden, query} from '../../../test/test-utils';
+import './gr-main-header';
+import {GrMainHeader} from './gr-main-header';
+import {
+ createAccountDetailWithId,
+ createServerInfo,
+} from '../../../test/test-data-generators';
+import {NavLink} from '../../../utils/admin-nav-util';
+import {ServerInfo, TopMenuItemInfo} from '../../../types/common';
+import {AuthType} from '../../../constants/constants';
+
+const basicFixture = fixtureFromElement('gr-main-header');
+
+suite('gr-main-header tests', () => {
+ let element: GrMainHeader;
+
+ setup(() => {
+ stub('gr-rest-api-interface', {
+ getConfig() {
+ return Promise.resolve(createServerInfo());
+ },
+ probePath(_) {
+ return Promise.resolve(false);
+ },
+ });
+ stub('gr-main-header', {
+ _loadAccount() {
+ return Promise.resolve();
+ },
+ });
+ element = basicFixture.instantiate();
+ });
+
+ test('link visibility', () => {
+ element.loading = true;
+ assert.isTrue(isHidden(query(element, '.accountContainer')));
+
+ element.loading = false;
+ element.loggedIn = false;
+ assert.isFalse(isHidden(query(element, '.accountContainer')));
+ assert.isFalse(isHidden(query(element, '.loginButton')));
+ assert.isFalse(isHidden(query(element, '.registerButton')));
+ assert.isTrue(isHidden(query(element, '.registerDiv')));
+
+ element._account = createAccountDetailWithId(1);
+ flush();
+ assert.isTrue(isHidden(query(element, 'gr-account-dropdown')));
+ assert.isTrue(isHidden(query(element, '.settingsButton')));
+
+ element.loggedIn = true;
+ assert.isTrue(isHidden(query(element, '.loginButton')));
+ assert.isTrue(isHidden(query(element, '.registerButton')));
+ assert.isFalse(isHidden(query(element, 'gr-account-dropdown')));
+ assert.isFalse(isHidden(query(element, '.settingsButton')));
+ });
+
+ test('fix my menu item', () => {
+ assert.deepEqual(
+ [
+ {url: 'https://awesometown.com/#hashyhash', name: '', target: ''},
+ {url: 'url', name: '', target: '_blank'},
+ ].map(element._createHeaderLink),
+ [
+ {url: 'https://awesometown.com/#hashyhash', name: ''},
+ {url: 'url', name: ''},
+ ]
+ );
+ });
+
+ test('user links', () => {
+ const defaultLinks = [
+ {
+ title: 'Faves',
+ links: [
+ {
+ name: 'Pinterest',
+ url: 'https://pinterest.com',
+ },
+ ],
+ },
+ ];
+ const userLinks: TopMenuItemInfo[] = [
+ {
+ name: 'Facebook',
+ url: 'https://facebook.com',
+ target: '',
+ },
+ ];
+ const adminLinks: NavLink[] = [
+ {
+ name: 'Repos',
+ url: '/repos',
+ noBaseUrl: true,
+ view: null,
+ },
+ ];
+
+ // When no admin links are passed, it should use the default.
+ assert.deepEqual(
+ element._computeLinks(
+ /* userLinks= */ [],
+ adminLinks,
+ /* topMenus= */ [],
+ /* docBaseUrl= */ '',
+ defaultLinks
+ ),
+ defaultLinks.concat({
+ title: 'Browse',
+ links: adminLinks,
+ })
+ );
+ assert.deepEqual(
+ element._computeLinks(
+ userLinks,
+ adminLinks,
+ /* topMenus= */ [],
+ /* docBaseUrl= */ '',
+ defaultLinks
+ ),
+ defaultLinks.concat([
+ {
+ title: 'Your',
+ links: userLinks,
+ },
+ {
+ title: 'Browse',
+ links: adminLinks,
+ },
+ ])
+ );
+ });
+
+ test('documentation links', () => {
+ const docLinks = [
+ {
+ name: 'Table of Contents',
+ url: '/index.html',
+ },
+ ];
+
+ assert.deepEqual(element._getDocLinks(null, docLinks), []);
+ assert.deepEqual(element._getDocLinks('', docLinks), []);
+ assert.deepEqual(element._getDocLinks('base', []), []);
+
+ assert.deepEqual(element._getDocLinks('base', docLinks), [
+ {
+ name: 'Table of Contents',
+ target: '_blank',
+ url: 'base/index.html',
+ },
+ ]);
+
+ assert.deepEqual(element._getDocLinks('base/', docLinks), [
+ {
+ name: 'Table of Contents',
+ target: '_blank',
+ url: 'base/index.html',
+ },
+ ]);
+ });
+
+ test('top menus', () => {
+ const adminLinks: NavLink[] = [
+ {
+ name: 'Repos',
+ url: '/repos',
+ noBaseUrl: true,
+ view: null,
+ },
+ ];
+ const topMenus = [
+ {
+ name: 'Plugins',
+ items: [
+ {
+ name: 'Manage',
+ target: '_blank',
+ url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+ },
+ ],
+ },
+ ];
+ assert.deepEqual(
+ element._computeLinks(
+ /* userLinks= */ [],
+ adminLinks,
+ topMenus,
+ /* baseDocUrl= */ '',
+ /* defaultLinks= */ []
+ ),
+ [
+ {
+ title: 'Browse',
+ links: adminLinks,
+ },
+ {
+ title: 'Plugins',
+ links: [
+ {
+ name: 'Manage',
+ url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+ },
+ ],
+ },
+ ]
+ );
+ });
+
+ test('ignore top project menus', () => {
+ const adminLinks: NavLink[] = [
+ {
+ name: 'Repos',
+ url: '/repos',
+ noBaseUrl: true,
+ view: null,
+ },
+ ];
+ const topMenus = [
+ {
+ name: 'Projects',
+ items: [
+ {
+ name: 'Project Settings',
+ target: '_blank',
+ url: '/plugins/myplugin/${projectName}',
+ },
+ {
+ name: 'Project List',
+ target: '_blank',
+ url: '/plugins/myplugin/index.html',
+ },
+ ],
+ },
+ ];
+ assert.deepEqual(
+ element._computeLinks(
+ /* userLinks= */ [],
+ adminLinks,
+ topMenus,
+ /* baseDocUrl= */ '',
+ /* defaultLinks= */ []
+ ),
+ [
+ {
+ title: 'Browse',
+ links: adminLinks,
+ },
+ {
+ title: 'Projects',
+ links: [
+ {
+ name: 'Project List',
+ url: '/plugins/myplugin/index.html',
+ },
+ ],
+ },
+ ]
+ );
+ });
+
+ test('merge top menus', () => {
+ const adminLinks: NavLink[] = [
+ {
+ name: 'Repos',
+ url: '/repos',
+ noBaseUrl: true,
+ view: null,
+ },
+ ];
+ const topMenus = [
+ {
+ name: 'Plugins',
+ items: [
+ {
+ name: 'Manage',
+ target: '_blank',
+ url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+ },
+ ],
+ },
+ {
+ name: 'Plugins',
+ items: [
+ {
+ name: 'Create',
+ target: '_blank',
+ url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+ },
+ ],
+ },
+ ];
+ assert.deepEqual(
+ element._computeLinks(
+ /* userLinks= */ [],
+ adminLinks,
+ topMenus,
+ /* baseDocUrl= */ '',
+ /* defaultLinks= */ []
+ ),
+ [
+ {
+ title: 'Browse',
+ links: adminLinks,
+ },
+ {
+ title: 'Plugins',
+ links: [
+ {
+ name: 'Manage',
+ url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+ },
+ {
+ name: 'Create',
+ url: 'https://gerrit/plugins/plugin-manager/static/create.html',
+ },
+ ],
+ },
+ ]
+ );
+ });
+
+ test('merge top menus in default links', () => {
+ const defaultLinks = [
+ {
+ title: 'Faves',
+ links: [
+ {
+ name: 'Pinterest',
+ url: 'https://pinterest.com',
+ },
+ ],
+ },
+ ];
+ const topMenus = [
+ {
+ name: 'Faves',
+ items: [
+ {
+ name: 'Manage',
+ target: '_blank',
+ url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+ },
+ ],
+ },
+ ];
+ assert.deepEqual(
+ element._computeLinks(
+ /* userLinks= */ [],
+ /* adminLinks= */ [],
+ topMenus,
+ /* baseDocUrl= */ '',
+ defaultLinks
+ ),
+ [
+ {
+ title: 'Faves',
+ links: defaultLinks[0].links.concat([
+ {
+ name: 'Manage',
+ url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+ },
+ ]),
+ },
+ {
+ title: 'Browse',
+ links: [],
+ },
+ ]
+ );
+ });
+
+ test('merge top menus in user links', () => {
+ const userLinks = [
+ {
+ name: 'Facebook',
+ url: 'https://facebook.com',
+ target: '',
+ },
+ ];
+ const topMenus = [
+ {
+ name: 'Your',
+ items: [
+ {
+ name: 'Manage',
+ target: '_blank',
+ url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+ },
+ ],
+ },
+ ];
+ assert.deepEqual(
+ element._computeLinks(
+ userLinks,
+ /* adminLinks= */ [],
+ topMenus,
+ /* baseDocUrl= */ '',
+ /* defaultLinks= */ []
+ ),
+ [
+ {
+ title: 'Your',
+ links: [
+ {
+ name: 'Facebook',
+ url: 'https://facebook.com',
+ target: '',
+ },
+ {
+ name: 'Manage',
+ url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+ },
+ ],
+ },
+ {
+ title: 'Browse',
+ links: [],
+ },
+ ]
+ );
+ });
+
+ test('merge top menus in admin links', () => {
+ const adminLinks: NavLink[] = [
+ {
+ name: 'Repos',
+ url: '/repos',
+ noBaseUrl: true,
+ view: null,
+ },
+ ];
+ const topMenus = [
+ {
+ name: 'Browse',
+ items: [
+ {
+ name: 'Manage',
+ target: '_blank',
+ url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+ },
+ ],
+ },
+ ];
+ assert.deepEqual(
+ element._computeLinks(
+ /* userLinks= */ [],
+ adminLinks,
+ topMenus,
+ /* baseDocUrl= */ '',
+ /* defaultLinks= */ []
+ ),
+ [
+ {
+ title: 'Browse',
+ links: [
+ adminLinks[0],
+ {
+ name: 'Manage',
+ url: 'https://gerrit/plugins/plugin-manager/static/index.html',
+ },
+ ],
+ },
+ ]
+ );
+ });
+
+ test('register URL', () => {
+ assert.isTrue(isHidden(query(element, '.registerDiv')));
+ const config: ServerInfo = {
+ ...createServerInfo(),
+ auth: {
+ auth_type: AuthType.LDAP,
+ register_url: 'https//gerrit.example.com/register',
+ editable_account_fields: [],
+ },
+ };
+ element._retrieveRegisterURL(config);
+ assert.equal(element._registerURL, config.auth.register_url);
+ assert.equal(element._registerText, 'Sign up');
+ assert.isFalse(isHidden(query(element, '.registerDiv')));
+
+ config.auth.register_text = 'Create account';
+ element._retrieveRegisterURL(config);
+ assert.equal(element._registerURL, config.auth.register_url);
+ assert.equal(element._registerText, config.auth.register_text);
+ assert.isFalse(isHidden(query(element, '.registerDiv')));
+ });
+
+ test('register URL ignored for wrong auth type', () => {
+ const config: ServerInfo = {
+ ...createServerInfo(),
+ auth: {
+ auth_type: AuthType.OPENID,
+ register_url: 'https//gerrit.example.com/register',
+ editable_account_fields: [],
+ },
+ };
+ element._retrieveRegisterURL(config);
+ assert.equal(element._registerURL, '');
+ assert.equal(element._registerText, 'Sign up');
+ assert.isTrue(isHidden(query(element, '.registerDiv')));
+ });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
deleted file mode 100644
index 1142d8e..0000000
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.js
+++ /dev/null
@@ -1,767 +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.
- */
-
-// Navigation parameters object format:
-//
-// Each object has a `view` property with a value from GerritNav.View. The
-// remaining properties depend on the value used for view.
-//
-// - GerritNav.View.CHANGE:
-// - `changeNum`, required, String: the numeric ID of the change.
-// - `project`, optional, String: the project name.
-// - `patchNum`, optional, Number: the patch for the right-hand-side of
-// the diff.
-// - `basePatchNum`, optional, Number: the patch for the left-hand-side
-// of the diff. If `basePatchNum` is provided, then `patchNum` must
-// also be provided.
-// - `edit`, optional, Boolean: whether or not to load the file list with
-// edit controls.
-// - `messageHash`, optional, String: the hash of the change message to
-// scroll to.
-//
-// - GerritNav.View.SEARCH:
-// - `query`, optional, String: the literal search query. If provided,
-// the string will be used as the query, and all other params will be
-// ignored.
-// - `owner`, optional, String: the owner name.
-// - `project`, optional, String: the project name.
-// - `branch`, optional, String: the branch name.
-// - `topic`, optional, String: the topic name.
-// - `hashtag`, optional, String: the hashtag name.
-// - `statuses`, optional, Array<String>: the list of change statuses to
-// search for. If more than one is provided, the search will OR them
-// together.
-// - `offset`, optional, Number: the offset for the query.
-//
-// - GerritNav.View.DIFF:
-// - `changeNum`, required, String: the numeric ID of the change.
-// - `path`, required, String: the filepath of the diff.
-// - `patchNum`, required, Number: the patch for the right-hand-side of
-// the diff.
-// - `basePatchNum`, optional, Number: the patch for the left-hand-side
-// of the diff. If `basePatchNum` is provided, then `patchNum` must
-// also be provided.
-// - `lineNum`, optional, Number: the line number to be selected on load.
-// - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
-// of true selects the line from base of the patch range. False by
-// default.
-//
-// - GerritNav.View.GROUP:
-// - `groupId`, required, String: the ID of the group.
-// - `detail`, optional, String: the name of the group detail view.
-// Takes any value from GerritNav.GroupDetailView.
-//
-// - GerritNav.View.REPO:
-// - `repoName`, required, String: the name of the repo
-// - `detail`, optional, String: the name of the repo detail view.
-// Takes any value from GerritNav.RepoDetailView.
-//
-// - GerritNav.View.DASHBOARD
-// - `repo`, optional, String.
-// - `sections`, optional, Array of objects with `title` and `query`
-// strings.
-// - `user`, optional, String.
-//
-// - GerritNav.View.ROOT:
-// - no possible parameters.
-
-const uninitialized = () => {
- console.warn('Use of uninitialized routing');
-};
-
-const EDIT_PATCHNUM = 'edit';
-const PARENT_PATCHNUM = 'PARENT';
-
-const USER_PLACEHOLDER_PATTERN = /\${user}/g;
-
-// NOTE: These queries are tested in Java. Any changes made to definitions
-// here require corresponding changes to:
-// javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
-const DEFAULT_SECTIONS = [
- {
- // Changes with unpublished draft comments. This section is omitted when
- // viewing other users, so we don't need to filter anything out.
- name: 'Has draft comments',
- query: 'has:draft',
- selfOnly: true,
- hideIfEmpty: true,
- suffixForDashboard: 'limit:10',
- },
- {
- // Changes where the user is in the attention set.
- name: 'Your Turn',
- query: 'attention:${user}',
- hideIfEmpty: false,
- suffixForDashboard: 'limit:25',
- attentionSetOnly: true,
- },
- {
- // Changes that are assigned to the viewed user.
- name: 'Assigned reviews',
- query: 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
- 'is:open -is:ignored',
- hideIfEmpty: true,
- suffixForDashboard: 'limit:25',
- assigneeOnly: true,
- },
- {
- // WIP open changes owned by viewing user. This section is omitted when
- // viewing other users, so we don't need to filter anything out.
- name: 'Work in progress',
- query: 'is:open owner:${user} is:wip',
- selfOnly: true,
- hideIfEmpty: true,
- suffixForDashboard: 'limit:25',
- },
- {
- // Non-WIP open changes owned by viewed user. Filter out changes ignored
- // by the viewing user.
- name: 'Outgoing reviews',
- query: 'is:open owner:${user} -is:wip -is:ignored',
- isOutgoing: true,
- suffixForDashboard: 'limit:25',
- },
- {
- // Non-WIP open changes not owned by the viewed user, that the viewed user
- // is associated with (as either a reviewer or the assignee). Changes
- // ignored by the viewing user are filtered out.
- name: 'Incoming reviews',
- query: 'is:open -owner:${user} -is:wip -is:ignored ' +
- '(reviewer:${user} OR assignee:${user})',
- suffixForDashboard: 'limit:25',
- },
- {
- // Open changes the viewed user is CCed on. Changes ignored by the viewing
- // user are filtered out.
- name: 'CCed on',
- query: 'is:open -is:ignored cc:${user}',
- suffixForDashboard: 'limit:10',
- },
- {
- name: 'Recently closed',
- // Closed changes where viewed user is owner, reviewer, or assignee.
- // Changes ignored by the viewing user are filtered out, and so are WIP
- // changes not owned by the viewing user (the one instance of
- // 'owner:self' is intentional and implements this logic).
- query: 'is:closed -is:ignored (-is:wip OR owner:self) ' +
- '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
- 'OR cc:${user})',
- suffixForDashboard: '-age:4w limit:10',
- },
-];
-
-// TODO(dmfilippov) Convert to class, extract consts, give better name and
-// expose as a service from appContext
-export const GerritNav = {
-
- View: {
- ADMIN: 'admin',
- AGREEMENTS: 'agreements',
- CHANGE: 'change',
- DASHBOARD: 'dashboard',
- DIFF: 'diff',
- DOCUMENTATION_SEARCH: 'documentation-search',
- EDIT: 'edit',
- GROUP: 'group',
- PLUGIN_SCREEN: 'plugin-screen',
- REPO: 'repo',
- ROOT: 'root',
- SEARCH: 'search',
- SETTINGS: 'settings',
- },
-
- GroupDetailView: {
- MEMBERS: 'members',
- LOG: 'log',
- },
-
- RepoDetailView: {
- ACCESS: 'access',
- BRANCHES: 'branches',
- COMMANDS: 'commands',
- DASHBOARDS: 'dashboards',
- TAGS: 'tags',
- },
-
- WeblinkType: {
- CHANGE: 'change',
- FILE: 'file',
- PATCHSET: 'patchset',
- },
-
- /** @type {Function} */
- _navigate: uninitialized,
-
- /** @type {Function} */
- _generateUrl: uninitialized,
-
- /** @type {Function} */
- _generateWeblinks: uninitialized,
-
- /** @type {Function} */
- mapCommentlinks: uninitialized,
-
- /**
- * @param {number=} patchNum
- * @param {number|string=} basePatchNum
- */
- _checkPatchRange(patchNum, basePatchNum) {
- if (basePatchNum && !patchNum) {
- throw new Error('Cannot use base patch number without patch number.');
- }
- },
-
- /**
- * Setup router implementation.
- *
- * @param {function(!string, boolean=)} navigate the router-abstracted equivalent of
- * `window.location.href = ...` or window.location.replace(...). The
- * string is a new location and boolean defines is it redirect or not
- * (true means redirect, i.e. equivalent of window.location.replace).
- * @param {function(!Object): string} generateUrl generates a URL given
- * navigation parameters, detailed in the file header.
- * @param {function(!Object): string} generateWeblinks weblinks generator
- * function takes single payload parameter with type property that
- * determines which
- * part of the UI is the consumer of the weblinks. type property can
- * be one of file, change, or patchset.
- * - For file type, payload will also contain string properties: repo,
- * commit, file.
- * - For patchset type, payload will also contain string properties:
- * repo, commit.
- * - For change type, payload will also contain string properties:
- * repo, commit. If server provides weblinks, those will be passed
- * as options.weblinks property on the main payload object.
- * @param {function(!Object): Object} mapCommentlinks provides an escape
- * hatch to modify the commentlinks object, e.g. if it contains any
- * relative URLs.
- */
- setup(navigate, generateUrl, generateWeblinks, mapCommentlinks) {
- this._navigate = navigate;
- this._generateUrl = generateUrl;
- this._generateWeblinks = generateWeblinks;
- this.mapCommentlinks = mapCommentlinks;
- },
-
- destroy() {
- this._navigate = uninitialized;
- this._generateUrl = uninitialized;
- this._generateWeblinks = uninitialized;
- this.mapCommentlinks = uninitialized;
- },
-
- /**
- * Generate a URL for the given route parameters.
- *
- * @param {Object} params
- * @return {string}
- */
- _getUrlFor(params) {
- return this._generateUrl(params);
- },
-
- getUrlForSearchQuery(query, opt_offset) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- query,
- offset: opt_offset,
- });
- },
-
- /**
- * @param {!string} project The name of the project.
- * @param {boolean=} opt_openOnly When true, only search open changes in
- * the project.
- * @param {string=} opt_host The host in which to search.
- * @return {string}
- */
- getUrlForProjectChanges(project, opt_openOnly, opt_host) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- project,
- statuses: opt_openOnly ? ['open'] : [],
- host: opt_host,
- });
- },
-
- /**
- * @param {string} branch The name of the branch.
- * @param {string} project The name of the project.
- * @param {string=} opt_status The status to search.
- * @param {string=} opt_host The host in which to search.
- * @return {string}
- */
- getUrlForBranch(branch, project, opt_status, opt_host) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- branch,
- project,
- statuses: opt_status ? [opt_status] : undefined,
- host: opt_host,
- });
- },
-
- /**
- * @param {string} topic The name of the topic.
- * @param {string=} opt_host The host in which to search.
- * @return {string}
- */
- getUrlForTopic(topic, opt_host) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- topic,
- statuses: ['open', 'merged'],
- host: opt_host,
- });
- },
-
- /**
- * @param {string} hashtag The name of the hashtag.
- * @return {string}
- */
- getUrlForHashtag(hashtag) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- hashtag,
- statuses: ['open', 'merged'],
- });
- },
-
- /**
- * Navigate to a search for changes with the given status.
- *
- * @param {string} status
- */
- navigateToStatusSearch(status) {
- this._navigate(this._getUrlFor({
- view: GerritNav.View.SEARCH,
- statuses: [status],
- }));
- },
-
- /**
- * Navigate to a search query
- *
- * @param {string} query
- * @param {number=} opt_offset
- */
- navigateToSearchQuery(query, opt_offset) {
- return this._navigate(this.getUrlForSearchQuery(query, opt_offset));
- },
-
- /**
- * Navigate to the user's dashboard
- */
- navigateToUserDashboard() {
- return this._navigate(this.getUrlForUserDashboard('self'));
- },
-
- /**
- * @param {!Object} change The change object.
- * @param {number=} opt_patchNum
- * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
- * used for none.
- * @param {boolean=} opt_isEdit
- * @param {string=} opt_messageHash
- * @return {string}
- */
- getUrlForChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
- opt_messageHash) {
- if (opt_basePatchNum === PARENT_PATCHNUM) {
- opt_basePatchNum = undefined;
- }
-
- this._checkPatchRange(opt_patchNum, opt_basePatchNum);
- return this._getUrlFor({
- view: GerritNav.View.CHANGE,
- changeNum: change._number,
- project: change.project,
- patchNum: opt_patchNum,
- basePatchNum: opt_basePatchNum,
- edit: opt_isEdit,
- host: change.internalHost || undefined,
- messageHash: opt_messageHash,
- });
- },
-
- /**
- * @param {number} changeNum
- * @param {string} project The name of the project.
- * @param {number=} opt_patchNum
- * @return {string}
- */
- getUrlForChangeById(changeNum, project, opt_patchNum) {
- return this._getUrlFor({
- view: GerritNav.View.CHANGE,
- changeNum,
- project,
- patchNum: opt_patchNum,
- });
- },
-
- /**
- * @param {!Object} change The change object.
- * @param {number=} opt_patchNum
- * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
- * used for none.
- * @param {boolean=} opt_isEdit
- * @param {boolean=} opt_redirect redirect to a change - if true, the current
- * location (i.e. page which makes redirect) is not added to a history.
- * I.e. back/forward buttons skip current location
- *
- */
- navigateToChange(change, opt_patchNum, opt_basePatchNum, opt_isEdit,
- opt_redirect) {
- this._navigate(this.getUrlForChange(change, opt_patchNum,
- opt_basePatchNum, opt_isEdit), opt_redirect);
- },
-
- /**
- * @param {{ _number: number, project: string }} change The change object.
- * @param {string} path The file path.
- * @param {number=} opt_patchNum
- * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
- * used for none.
- * @param {number|string=} opt_lineNum
- * @return {string}
- */
- getUrlForDiff(change, path, opt_patchNum, opt_basePatchNum, opt_lineNum) {
- return this.getUrlForDiffById(change._number, change.project, path,
- opt_patchNum, opt_basePatchNum, opt_lineNum);
- },
-
- /**
- * @param {number} changeNum
- * @param {string} project The name of the project.
- * @param {string} path The file path.
- * @param {number=} opt_patchNum
- * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
- * used for none.
- * @param {number=} opt_lineNum
- * @param {boolean=} opt_leftSide
- * @return {string}
- */
- getUrlForDiffById(changeNum, project, path, opt_patchNum,
- opt_basePatchNum, opt_lineNum, opt_leftSide) {
- if (opt_basePatchNum === PARENT_PATCHNUM) {
- opt_basePatchNum = undefined;
- }
-
- this._checkPatchRange(opt_patchNum, opt_basePatchNum);
- return this._getUrlFor({
- view: GerritNav.View.DIFF,
- changeNum,
- project,
- path,
- patchNum: opt_patchNum,
- basePatchNum: opt_basePatchNum,
- lineNum: opt_lineNum,
- leftSide: opt_leftSide,
- });
- },
-
- /**
- * @param {{ _number: number, project: string }} change The change object.
- * @param {string} path The file path.
- * @param {number=} opt_patchNum
- * @param {number=} opt_lineNum
- * @return {string}
- */
- getEditUrlForDiff(change, path, opt_patchNum, opt_lineNum) {
- return this.getEditUrlForDiffById(change._number, change.project, path,
- opt_patchNum, opt_lineNum);
- },
-
- /**
- * @param {number} changeNum
- * @param {string} project The name of the project.
- * @param {string} path The file path.
- * @param {number|string=} opt_patchNum The patchNum the file content
- * should be based on, or ${EDIT_PATCHNUM} if left undefined.
- * @param {number=} opt_lineNum The line number to pass to the inline editor.
- * @return {string}
- */
- getEditUrlForDiffById(changeNum, project, path, opt_patchNum, opt_lineNum) {
- return this._getUrlFor({
- view: GerritNav.View.EDIT,
- changeNum,
- project,
- path,
- patchNum: opt_patchNum || EDIT_PATCHNUM,
- lineNum: opt_lineNum,
- });
- },
-
- /**
- * @param {!Object} change The change object.
- * @param {string} path The file path.
- * @param {number=} opt_patchNum
- * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
- * used for none.
- */
- navigateToDiff(change, path, opt_patchNum, opt_basePatchNum) {
- this._navigate(this.getUrlForDiff(change, path, opt_patchNum,
- opt_basePatchNum));
- },
-
- /**
- * @param {string} owner The name of the owner.
- * @return {string}
- */
- getUrlForOwner(owner) {
- return this._getUrlFor({
- view: GerritNav.View.SEARCH,
- owner,
- });
- },
-
- /**
- * @param {string} user The name of the user.
- * @return {string}
- */
- getUrlForUserDashboard(user) {
- return this._getUrlFor({
- view: GerritNav.View.DASHBOARD,
- user,
- });
- },
-
- /**
- * @return {string}
- */
- getUrlForRoot() {
- return this._getUrlFor({
- view: GerritNav.View.ROOT,
- });
- },
-
- /**
- * @param {string} repo The name of the repo.
- * @param {string} dashboard The ID of the dashboard, in the form of
- * '<ref>:<path>'.
- * @return {string}
- */
- getUrlForRepoDashboard(repo, dashboard) {
- return this._getUrlFor({
- view: GerritNav.View.DASHBOARD,
- repo,
- dashboard,
- });
- },
-
- /**
- * Navigate to an arbitrary relative URL.
- *
- * @param {string} relativeUrl
- */
- navigateToRelativeUrl(relativeUrl) {
- if (!relativeUrl.startsWith('/')) {
- throw new Error('navigateToRelativeUrl with non-relative URL');
- }
- this._navigate(relativeUrl);
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepo(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- });
- },
-
- /**
- * Navigate to a repo settings page.
- *
- * @param {string} repoName
- */
- navigateToRepo(repoName) {
- this._navigate(this.getUrlForRepo(repoName));
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepoTags(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- detail: GerritNav.RepoDetailView.TAGS,
- });
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepoBranches(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- detail: GerritNav.RepoDetailView.BRANCHES,
- });
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepoAccess(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- detail: GerritNav.RepoDetailView.ACCESS,
- });
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepoCommands(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- detail: GerritNav.RepoDetailView.COMMANDS,
- });
- },
-
- /**
- * @param {string} repoName
- * @return {string}
- */
- getUrlForRepoDashboards(repoName) {
- return this._getUrlFor({
- view: GerritNav.View.REPO,
- repoName,
- detail: GerritNav.RepoDetailView.DASHBOARDS,
- });
- },
-
- /**
- * @param {string} groupId
- * @return {string}
- */
- getUrlForGroup(groupId) {
- return this._getUrlFor({
- view: GerritNav.View.GROUP,
- groupId,
- });
- },
-
- /**
- * @param {string} groupId
- * @return {string}
- */
- getUrlForGroupLog(groupId) {
- return this._getUrlFor({
- view: GerritNav.View.GROUP,
- groupId,
- detail: GerritNav.GroupDetailView.LOG,
- });
- },
-
- /**
- * @param {string} groupId
- * @return {string}
- */
- getUrlForGroupMembers(groupId) {
- return this._getUrlFor({
- view: GerritNav.View.GROUP,
- groupId,
- detail: GerritNav.GroupDetailView.MEMBERS,
- });
- },
-
- getUrlForSettings() {
- return this._getUrlFor({view: GerritNav.View.SETTINGS});
- },
-
- /**
- * @param {string} repo
- * @param {string} commit
- * @param {string} file
- * @param {Object=} opt_options
- * @return {
- * Array<{label: string, url: string}>|
- * {label: string, url: string}
- * }
- */
- getFileWebLinks(repo, commit, file, opt_options) {
- const params = {type: GerritNav.WeblinkType.FILE, repo, commit, file};
- if (opt_options) {
- params.options = opt_options;
- }
- return [].concat(this._generateWeblinks(params));
- },
-
- /**
- * @param {string} repo
- * @param {string} commit
- * @param {Object=} opt_options
- * @return {{label: string, url: string}}
- */
- getPatchSetWeblink(repo, commit, opt_options) {
- const params = {type: GerritNav.WeblinkType.PATCHSET, repo, commit};
- if (opt_options) {
- params.options = opt_options;
- }
- const result = this._generateWeblinks(params);
- if (Array.isArray(result)) {
- return result.pop();
- } else {
- return result;
- }
- },
-
- /**
- * @param {string} repo
- * @param {string} commit
- * @param {Object=} opt_options
- * @return {
- * Array<{label: string, url: string}>|
- * {label: string, url: string}
- * }
- */
- getChangeWeblinks(repo, commit, opt_options) {
- const params = {type: GerritNav.WeblinkType.CHANGE, repo, commit};
- if (opt_options) {
- params.options = opt_options;
- }
- return [].concat(this._generateWeblinks(params));
- },
-
- getUserDashboard(user = 'self', sections = DEFAULT_SECTIONS,
- title = '', config = {}) {
- const attentionEnabled =
- config.change && !!config.change.enable_attention_set;
- const assigneeEnabled =
- config.change && !!config.change.enable_assignee;
- sections = sections
- .filter(section => (attentionEnabled || !section.attentionSetOnly))
- .filter(section => (assigneeEnabled || !section.assigneeOnly))
- .filter(section => (user === 'self' || !section.selfOnly))
- .map(section => Object.assign({}, section, {
- name: section.name,
- query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
- }));
- return {title, sections};
- },
-};
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
new file mode 100644
index 0000000..8470611
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -0,0 +1,987 @@
+/**
+ * @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 {
+ BranchName,
+ ChangeInfo,
+ PatchSetNum,
+ RepoName,
+ TopicName,
+ GroupId,
+ DashboardId,
+ NumericChangeId,
+ EditPatchSetNum,
+ ChangeConfigInfo,
+ CommitId,
+ Hashtag,
+ UrlEncodedCommentId,
+ CommentLinks,
+ ParentPatchSetNum,
+ ServerInfo,
+} from '../../../types/common';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+// Navigation parameters object format:
+//
+// Each object has a `view` property with a value from GerritNav.View. The
+// remaining properties depend on the value used for view.
+//
+// - GerritNav.View.CHANGE:
+// - `changeNum`, required, String: the numeric ID of the change.
+// - `project`, optional, String: the project name.
+// - `patchNum`, optional, Number: the patch for the right-hand-side of
+// the diff.
+// - `basePatchNum`, optional, Number: the patch for the left-hand-side
+// of the diff. If `basePatchNum` is provided, then `patchNum` must
+// also be provided.
+// - `edit`, optional, Boolean: whether or not to load the file list with
+// edit controls.
+// - `messageHash`, optional, String: the hash of the change message to
+// scroll to.
+//
+// - GerritNav.View.SEARCH:
+// - `query`, optional, String: the literal search query. If provided,
+// the string will be used as the query, and all other params will be
+// ignored.
+// - `owner`, optional, String: the owner name.
+// - `project`, optional, String: the project name.
+// - `branch`, optional, String: the branch name.
+// - `topic`, optional, String: the topic name.
+// - `hashtag`, optional, String: the hashtag name.
+// - `statuses`, optional, Array<String>: the list of change statuses to
+// search for. If more than one is provided, the search will OR them
+// together.
+// - `offset`, optional, Number: the offset for the query.
+//
+// - GerritNav.View.DIFF:
+// - `changeNum`, required, String: the numeric ID of the change.
+// - `path`, required, String: the filepath of the diff.
+// - `patchNum`, required, Number: the patch for the right-hand-side of
+// the diff.
+// - `basePatchNum`, optional, Number: the patch for the left-hand-side
+// of the diff. If `basePatchNum` is provided, then `patchNum` must
+// also be provided.
+// - `lineNum`, optional, Number: the line number to be selected on load.
+// - `leftSide`, optional, Boolean: if a `lineNum` is provided, a value
+// of true selects the line from base of the patch range. False by
+// default.
+//
+// - GerritNav.View.GROUP:
+// - `groupId`, required, String: the ID of the group.
+// - `detail`, optional, String: the name of the group detail view.
+// Takes any value from GerritNav.GroupDetailView.
+//
+// - GerritNav.View.REPO:
+// - `repoName`, required, String: the name of the repo
+// - `detail`, optional, String: the name of the repo detail view.
+// Takes any value from GerritNav.RepoDetailView.
+//
+// - GerritNav.View.DASHBOARD
+// - `repo`, optional, String.
+// - `sections`, optional, Array of objects with `title` and `query`
+// strings.
+// - `user`, optional, String.
+//
+// - GerritNav.View.ROOT:
+// - no possible parameters.
+
+const uninitialized = () => {
+ console.warn('Use of uninitialized routing');
+};
+
+const uninitializedNavigate: NavigateCallback = () => {
+ uninitialized();
+ return '';
+};
+
+const uninitializedGenerateUrl: GenerateUrlCallback = () => {
+ uninitialized();
+ return '';
+};
+
+const uninitializedGenerateWebLinks: GenerateWebLinksCallback = () => {
+ uninitialized();
+ return [];
+};
+
+const uninitializedMapCommentLinks: MapCommentLinksCallback = () => {
+ uninitialized();
+ return {};
+};
+
+const USER_PLACEHOLDER_PATTERN = /\${user}/g;
+
+export interface DashboardSection {
+ name: string;
+ query: string;
+ suffixForDashboard?: string;
+ attentionSetOnly?: boolean;
+ selfOnly?: boolean;
+ hideIfEmpty?: boolean;
+ assigneeOnly?: boolean;
+ isOutgoing?: boolean;
+ results?: ChangeInfo[];
+}
+
+export interface UserDashboardConfig {
+ change?: ChangeConfigInfo;
+}
+
+export interface UserDashboard {
+ title?: string;
+ sections: DashboardSection[];
+}
+
+// NOTE: These queries are tested in Java. Any changes made to definitions
+// here require corresponding changes to:
+// java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+const HAS_DRAFTS: DashboardSection = {
+ // Changes with unpublished draft comments. This section is omitted when
+ // viewing other users, so we don't need to filter anything out.
+ name: 'Has draft comments',
+ query: 'has:draft',
+ selfOnly: true,
+ hideIfEmpty: true,
+ suffixForDashboard: 'limit:10',
+};
+export const YOUR_TURN: DashboardSection = {
+ // Changes where the user is in the attention set.
+ name: 'Your Turn',
+ query: 'attention:${user}',
+ hideIfEmpty: false,
+ suffixForDashboard: 'limit:25',
+ attentionSetOnly: true,
+};
+const ASSIGNED: DashboardSection = {
+ // Changes that are assigned to the viewed user.
+ name: 'Assigned reviews',
+ query:
+ 'assignee:${user} (-is:wip OR owner:self OR assignee:self) ' +
+ 'is:open -is:ignored',
+ hideIfEmpty: true,
+ suffixForDashboard: 'limit:25',
+ assigneeOnly: true,
+};
+const WIP: DashboardSection = {
+ // WIP open changes owned by viewing user. This section is omitted when
+ // viewing other users, so we don't need to filter anything out.
+ name: 'Work in progress',
+ query: 'is:open owner:${user} is:wip',
+ selfOnly: true,
+ hideIfEmpty: true,
+ suffixForDashboard: 'limit:25',
+};
+const OUTGOING: DashboardSection = {
+ // Non-WIP open changes owned by viewed user. Filter out changes ignored
+ // by the viewing user.
+ name: 'Outgoing reviews',
+ query: 'is:open owner:${user} -is:wip -is:ignored',
+ isOutgoing: true,
+ suffixForDashboard: 'limit:25',
+};
+const INCOMING: DashboardSection = {
+ // Non-WIP open changes not owned by the viewed user, that the viewed user
+ // is associated with (as either a reviewer or the assignee). Changes
+ // ignored by the viewing user are filtered out.
+ name: 'Incoming reviews',
+ query:
+ 'is:open -owner:${user} -is:wip -is:ignored ' +
+ '(reviewer:${user} OR assignee:${user})',
+ suffixForDashboard: 'limit:25',
+};
+const CCED: DashboardSection = {
+ // Open changes the viewed user is CCed on. Changes ignored by the viewing
+ // user are filtered out.
+ name: 'CCed on',
+ query: 'is:open -is:ignored cc:${user}',
+ suffixForDashboard: 'limit:10',
+};
+export const CLOSED: DashboardSection = {
+ name: 'Recently closed',
+ // Closed changes where viewed user is owner, reviewer, or assignee.
+ // Changes ignored by the viewing user are filtered out, and so are WIP
+ // changes not owned by the viewing user (the one instance of
+ // 'owner:self' is intentional and implements this logic).
+ query:
+ 'is:closed -is:ignored (-is:wip OR owner:self) ' +
+ '(owner:${user} OR reviewer:${user} OR assignee:${user} ' +
+ 'OR cc:${user})',
+ suffixForDashboard: '-age:4w limit:10',
+};
+const DEFAULT_SECTIONS: DashboardSection[] = [
+ HAS_DRAFTS,
+ YOUR_TURN,
+ ASSIGNED,
+ WIP,
+ OUTGOING,
+ INCOMING,
+ CCED,
+ CLOSED,
+];
+
+export interface GenerateUrlSearchViewParameters {
+ view: GerritView.SEARCH;
+ query?: string;
+ offset?: number;
+ project?: RepoName;
+ branch?: BranchName;
+ topic?: TopicName;
+ // TODO(TS): Define more precise type (enum?)
+ statuses?: string[];
+ hashtag?: string;
+ host?: string;
+ owner?: string;
+}
+
+export interface GenerateUrlChangeViewParameters {
+ view: GerritView.CHANGE;
+ // TODO(TS): NumericChangeId - not sure about it, may be it can be removeds
+ changeNum: NumericChangeId;
+ project: RepoName;
+ patchNum?: PatchSetNum;
+ basePatchNum?: PatchSetNum;
+ edit?: boolean;
+ host?: string;
+ messageHash?: string;
+ queryMap?: Map<string, string> | URLSearchParams;
+
+ // TODO(TS): querystring isn't set anywhere, try to remove
+ querystring?: string;
+}
+
+export interface GenerateUrlRepoViewParameters {
+ view: GerritView.REPO;
+ repoName: RepoName;
+ detail?: RepoDetailView;
+}
+
+export interface GenerateUrlDashboardViewParameters {
+ view: GerritView.DASHBOARD;
+ user?: string;
+ repo?: RepoName;
+ dashboard?: DashboardId;
+
+ // TODO(TS): properties bellow aren't set anywhere, try to remove
+ project?: RepoName;
+ sections?: DashboardSection[];
+ title?: string;
+}
+
+export interface GenerateUrlGroupViewParameters {
+ view: GerritView.GROUP;
+ groupId: GroupId;
+ detail?: GroupDetailView;
+}
+
+export interface GenerateUrlEditViewParameters {
+ view: GerritView.EDIT;
+ changeNum: NumericChangeId;
+ project: RepoName;
+ path: string;
+ patchNum: PatchSetNum;
+ lineNum?: number | string;
+}
+
+export interface GenerateUrlRootViewParameters {
+ view: GerritView.ROOT;
+}
+
+export interface GenerateUrlSettingsViewParameters {
+ view: GerritView.SETTINGS;
+}
+
+export interface GenerateUrlDiffViewParameters {
+ view: GerritView.DIFF;
+ changeNum: NumericChangeId;
+ project: RepoName;
+ path?: string;
+ patchNum?: PatchSetNum | null;
+ basePatchNum?: PatchSetNum | null;
+ lineNum?: number | string;
+ leftSide?: boolean;
+ commentId?: UrlEncodedCommentId;
+ // TODO(TS): remove - property is set but never used
+ commentLink?: boolean;
+}
+
+export type GenerateUrlParameters =
+ | GenerateUrlSearchViewParameters
+ | GenerateUrlChangeViewParameters
+ | GenerateUrlRepoViewParameters
+ | GenerateUrlDashboardViewParameters
+ | GenerateUrlGroupViewParameters
+ | GenerateUrlEditViewParameters
+ | GenerateUrlRootViewParameters
+ | GenerateUrlSettingsViewParameters
+ | GenerateUrlDiffViewParameters;
+
+export function isGenerateUrlChangeViewParameters(
+ x: GenerateUrlParameters
+): x is GenerateUrlChangeViewParameters {
+ return x.view === GerritView.CHANGE;
+}
+
+export function isGenerateUrlEditViewParameters(
+ x: GenerateUrlParameters
+): x is GenerateUrlEditViewParameters {
+ return x.view === GerritView.EDIT;
+}
+
+export function isGenerateUrlDiffViewParameters(
+ x: GenerateUrlParameters
+): x is GenerateUrlDiffViewParameters {
+ return x.view === GerritView.DIFF;
+}
+
+export interface GenerateWebLinksOptions {
+ weblinks?: GeneratedWebLink[];
+ config?: ServerInfo;
+}
+
+export interface GenerateWebLinksPatchsetParameters {
+ type: WeblinkType.PATCHSET;
+ repo: RepoName;
+ commit?: CommitId;
+ options?: GenerateWebLinksOptions;
+}
+export interface GenerateWebLinksFileParameters {
+ type: WeblinkType.FILE;
+ repo: RepoName;
+ commit: CommitId;
+ file: string;
+ options?: GenerateWebLinksOptions;
+}
+export interface GenerateWebLinksChangeParameters {
+ type: WeblinkType.CHANGE;
+ repo: RepoName;
+ commit: CommitId;
+ options?: GenerateWebLinksOptions;
+}
+
+export type GenerateWebLinksParameters =
+ | GenerateWebLinksPatchsetParameters
+ | GenerateWebLinksFileParameters
+ | GenerateWebLinksChangeParameters;
+
+export type NavigateCallback = (target: string, redirect?: boolean) => void;
+export type GenerateUrlCallback = (params: GenerateUrlParameters) => string;
+export type GenerateWebLinksCallback = (
+ params: GenerateWebLinksParameters
+) => GeneratedWebLink[] | GeneratedWebLink;
+
+export type MapCommentLinksCallback = (patterns: CommentLinks) => CommentLinks;
+
+export interface WebLink {
+ name?: string;
+ label: string;
+ url: string;
+}
+
+export interface GeneratedWebLink {
+ name?: string;
+ label?: string;
+ url?: string;
+}
+
+export enum GerritView {
+ ADMIN = 'admin',
+ AGREEMENTS = 'agreements',
+ CHANGE = 'change',
+ DASHBOARD = 'dashboard',
+ DIFF = 'diff',
+ DOCUMENTATION_SEARCH = 'documentation-search',
+ EDIT = 'edit',
+ GROUP = 'group',
+ PLUGIN_SCREEN = 'plugin-screen',
+ REPO = 'repo',
+ ROOT = 'root',
+ SEARCH = 'search',
+ SETTINGS = 'settings',
+}
+
+export enum GroupDetailView {
+ MEMBERS = 'members',
+ LOG = 'log',
+}
+
+export enum RepoDetailView {
+ ACCESS = 'access',
+ BRANCHES = 'branches',
+ COMMANDS = 'commands',
+ DASHBOARDS = 'dashboards',
+ TAGS = 'tags',
+}
+
+export enum WeblinkType {
+ CHANGE = 'change',
+ FILE = 'file',
+ PATCHSET = 'patchset',
+}
+
+// TODO(dmfilippov) Convert to class, extract consts, give better name and
+// expose as a service from appContext
+export const GerritNav = {
+ View: GerritView,
+
+ GroupDetailView,
+
+ RepoDetailView,
+
+ WeblinkType,
+
+ _navigate: uninitializedNavigate,
+
+ _generateUrl: uninitializedGenerateUrl,
+
+ _generateWeblinks: uninitializedGenerateWebLinks,
+
+ mapCommentlinks: uninitializedMapCommentLinks,
+
+ _checkPatchRange(patchNum?: PatchSetNum, basePatchNum?: PatchSetNum) {
+ if (basePatchNum && !patchNum) {
+ throw new Error('Cannot use base patch number without patch number.');
+ }
+ },
+
+ /**
+ * Setup router implementation.
+ *
+ * @param navigate the router-abstracted equivalent of
+ * `window.location.href = ...` or window.location.replace(...). The
+ * string is a new location and boolean defines is it redirect or not
+ * (true means redirect, i.e. equivalent of window.location.replace).
+ * @param generateUrl generates a URL given
+ * navigation parameters, detailed in the file header.
+ * @param generateWeblinks weblinks generator
+ * function takes single payload parameter with type property that
+ * determines which
+ * part of the UI is the consumer of the weblinks. type property can
+ * be one of file, change, or patchset.
+ * - For file type, payload will also contain string properties: repo,
+ * commit, file.
+ * - For patchset type, payload will also contain string properties:
+ * repo, commit.
+ * - For change type, payload will also contain string properties:
+ * repo, commit. If server provides weblinks, those will be passed
+ * as options.weblinks property on the main payload object.
+ * @param mapCommentlinks provides an escape
+ * hatch to modify the commentlinks object, e.g. if it contains any
+ * relative URLs.
+ */
+ setup(
+ navigate: NavigateCallback,
+ generateUrl: GenerateUrlCallback,
+ generateWeblinks: GenerateWebLinksCallback,
+ mapCommentlinks: MapCommentLinksCallback
+ ) {
+ this._navigate = navigate;
+ this._generateUrl = generateUrl;
+ this._generateWeblinks = generateWeblinks;
+ this.mapCommentlinks = mapCommentlinks;
+ },
+
+ destroy() {
+ this._navigate = uninitializedNavigate;
+ this._generateUrl = uninitializedGenerateUrl;
+ this._generateWeblinks = uninitializedGenerateWebLinks;
+ this.mapCommentlinks = uninitializedMapCommentLinks;
+ },
+
+ /**
+ * Generate a URL for the given route parameters.
+ */
+ _getUrlFor(params: GenerateUrlParameters) {
+ return this._generateUrl(params);
+ },
+
+ getUrlForSearchQuery(query: string, offset?: number) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ query,
+ offset,
+ });
+ },
+
+ /**
+ * @param openOnly When true, only search open changes in the project.
+ * @param host The host in which to search.
+ */
+ getUrlForProjectChanges(
+ project: RepoName,
+ openOnly?: boolean,
+ host?: string
+ ) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ project,
+ statuses: openOnly ? ['open'] : [],
+ host,
+ });
+ },
+
+ /**
+ * @param status The status to search.
+ * @param host The host in which to search.
+ */
+ getUrlForBranch(
+ branch: BranchName,
+ project: RepoName,
+ status?: string,
+ host?: string
+ ) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ branch,
+ project,
+ statuses: status ? [status] : undefined,
+ host,
+ });
+ },
+
+ /**
+ * @param topic The name of the topic.
+ * @param host The host in which to search.
+ */
+ getUrlForTopic(topic: TopicName, host?: string) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ topic,
+ statuses: ['open', 'merged'],
+ host,
+ });
+ },
+
+ /**
+ * @param hashtag The name of the hashtag.
+ */
+ getUrlForHashtag(hashtag: Hashtag) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ hashtag,
+ statuses: ['open', 'merged'],
+ });
+ },
+
+ /**
+ * Navigate to a search for changes with the given status.
+ */
+ navigateToStatusSearch(status: string) {
+ this._navigate(
+ this._getUrlFor({
+ view: GerritView.SEARCH,
+ statuses: [status],
+ })
+ );
+ },
+
+ /**
+ * Navigate to a search query
+ */
+ navigateToSearchQuery(query: string, offset?: number) {
+ return this._navigate(this.getUrlForSearchQuery(query, offset));
+ },
+
+ /**
+ * Navigate to the user's dashboard
+ */
+ navigateToUserDashboard() {
+ return this._navigate(this.getUrlForUserDashboard('self'));
+ },
+
+ /**
+ * @param basePatchNum The string 'PARENT' can be used for none.
+ */
+ getUrlForChange(
+ change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum,
+ isEdit?: boolean,
+ messageHash?: string
+ ) {
+ if (basePatchNum === ParentPatchSetNum) {
+ basePatchNum = undefined;
+ }
+
+ this._checkPatchRange(patchNum, basePatchNum);
+ return this._getUrlFor({
+ view: GerritView.CHANGE,
+ changeNum: change._number,
+ project: change.project,
+ patchNum,
+ basePatchNum,
+ edit: isEdit,
+ host: change.internalHost || undefined,
+ messageHash,
+ });
+ },
+
+ getUrlForChangeById(
+ changeNum: NumericChangeId,
+ project: RepoName,
+ patchNum?: PatchSetNum
+ ) {
+ return this._getUrlFor({
+ view: GerritView.CHANGE,
+ changeNum,
+ project,
+ patchNum,
+ });
+ },
+
+ /**
+ * @param basePatchNum The string 'PARENT' can be used for none.
+ * @param redirect redirect to a change - if true, the current
+ * location (i.e. page which makes redirect) is not added to a history.
+ * I.e. back/forward buttons skip current location
+ *
+ */
+ navigateToChange(
+ change: Pick<ChangeInfo, '_number' | 'project' | 'internalHost'>,
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum,
+ isEdit?: boolean,
+ redirect?: boolean
+ ) {
+ this._navigate(
+ this.getUrlForChange(change, patchNum, basePatchNum, isEdit),
+ redirect
+ );
+ },
+
+ /**
+ * @param basePatchNum The string 'PARENT' can be used for none.
+ */
+ getUrlForDiff(
+ change: ChangeInfo | ParsedChangeInfo,
+ filePath: string,
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum,
+ lineNum?: number
+ ) {
+ return this.getUrlForDiffById(
+ change._number,
+ change.project,
+ filePath,
+ patchNum,
+ basePatchNum,
+ lineNum
+ );
+ },
+
+ getUrlForComment(
+ changeNum: NumericChangeId,
+ project: RepoName,
+ commentId: UrlEncodedCommentId
+ ) {
+ return this._getUrlFor({
+ view: GerritView.DIFF,
+ changeNum,
+ project,
+ commentId,
+ });
+ },
+
+ /**
+ * @param basePatchNum The string 'PARENT' can be used for none.
+ */
+ getUrlForDiffById(
+ changeNum: NumericChangeId,
+ project: RepoName,
+ filePath: string,
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum,
+ lineNum?: number,
+ leftSide?: boolean
+ ) {
+ if (basePatchNum === ParentPatchSetNum) {
+ basePatchNum = undefined;
+ }
+
+ this._checkPatchRange(patchNum, basePatchNum);
+ return this._getUrlFor({
+ view: GerritView.DIFF,
+ changeNum,
+ project,
+ path: filePath,
+ patchNum,
+ basePatchNum,
+ lineNum,
+ leftSide,
+ });
+ },
+
+ getEditUrlForDiff(
+ change: ChangeInfo | ParsedChangeInfo,
+ filePath: string,
+ patchNum?: PatchSetNum,
+ lineNum?: number
+ ) {
+ return this.getEditUrlForDiffById(
+ change._number,
+ change.project,
+ filePath,
+ patchNum,
+ lineNum
+ );
+ },
+
+ /**
+ * @param patchNum The patchNum the file content should be based on, or
+ * ${EditPatchSetNum} if left undefined.
+ * @param lineNum The line number to pass to the inline editor.
+ */
+ getEditUrlForDiffById(
+ changeNum: NumericChangeId,
+ project: RepoName,
+ filePath: string,
+ patchNum?: PatchSetNum,
+ lineNum?: number
+ ) {
+ return this._getUrlFor({
+ view: GerritView.EDIT,
+ changeNum,
+ project,
+ path: filePath,
+ patchNum: patchNum || EditPatchSetNum,
+ lineNum,
+ });
+ },
+
+ /**
+ * @param basePatchNum The string 'PARENT' can be used for none.
+ */
+ navigateToDiff(
+ change: ChangeInfo | ParsedChangeInfo,
+ filePath: string,
+ patchNum?: PatchSetNum,
+ basePatchNum?: PatchSetNum,
+ lineNum?: number
+ ) {
+ this._navigate(
+ this.getUrlForDiff(change, filePath, patchNum, basePatchNum, lineNum)
+ );
+ },
+
+ /**
+ * @param owner The name of the owner.
+ */
+ getUrlForOwner(owner: string) {
+ return this._getUrlFor({
+ view: GerritView.SEARCH,
+ owner,
+ });
+ },
+
+ /**
+ * @param user The name of the user.
+ */
+ getUrlForUserDashboard(user: string) {
+ return this._getUrlFor({
+ view: GerritView.DASHBOARD,
+ user,
+ });
+ },
+
+ getUrlForRoot() {
+ return this._getUrlFor({
+ view: GerritView.ROOT,
+ });
+ },
+
+ /**
+ * @param repo The name of the repo.
+ * @param dashboard The ID of the dashboard, in the form of '<ref>:<path>'.
+ */
+ getUrlForRepoDashboard(repo: RepoName, dashboard: DashboardId) {
+ return this._getUrlFor({
+ view: GerritView.DASHBOARD,
+ repo,
+ dashboard,
+ });
+ },
+
+ /**
+ * Navigate to an arbitrary relative URL.
+ */
+ navigateToRelativeUrl(relativeUrl: string) {
+ if (!relativeUrl.startsWith('/')) {
+ throw new Error('navigateToRelativeUrl with non-relative URL');
+ }
+ this._navigate(relativeUrl);
+ },
+
+ getUrlForRepo(repoName: RepoName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ });
+ },
+
+ /**
+ * Navigate to a repo settings page.
+ */
+ navigateToRepo(repoName: RepoName) {
+ this._navigate(this.getUrlForRepo(repoName));
+ },
+
+ getUrlForRepoTags(repoName: RepoName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ detail: RepoDetailView.TAGS,
+ });
+ },
+
+ getUrlForRepoBranches(repoName: RepoName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ detail: GerritNav.RepoDetailView.BRANCHES,
+ });
+ },
+
+ getUrlForRepoAccess(repoName: RepoName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ detail: GerritNav.RepoDetailView.ACCESS,
+ });
+ },
+
+ getUrlForRepoCommands(repoName: RepoName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ detail: GerritNav.RepoDetailView.COMMANDS,
+ });
+ },
+
+ getUrlForRepoDashboards(repoName: RepoName) {
+ return this._getUrlFor({
+ view: GerritView.REPO,
+ repoName,
+ detail: GerritNav.RepoDetailView.DASHBOARDS,
+ });
+ },
+
+ getUrlForGroup(groupId: GroupId) {
+ return this._getUrlFor({
+ view: GerritView.GROUP,
+ groupId,
+ });
+ },
+
+ getUrlForGroupLog(groupId: GroupId) {
+ return this._getUrlFor({
+ view: GerritView.GROUP,
+ groupId,
+ detail: GerritNav.GroupDetailView.LOG,
+ });
+ },
+
+ getUrlForGroupMembers(groupId: GroupId) {
+ return this._getUrlFor({
+ view: GerritView.GROUP,
+ groupId,
+ detail: GroupDetailView.MEMBERS,
+ });
+ },
+
+ getUrlForSettings() {
+ return this._getUrlFor({view: GerritView.SETTINGS});
+ },
+
+ getFileWebLinks(
+ repo: RepoName,
+ commit: CommitId,
+ file: string,
+ options?: GenerateWebLinksOptions
+ ): GeneratedWebLink[] {
+ const params: GenerateWebLinksFileParameters = {
+ type: WeblinkType.FILE,
+ repo,
+ commit,
+ file,
+ };
+ if (options) {
+ params.options = options;
+ }
+ return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
+ },
+
+ getPatchSetWeblink(
+ repo: RepoName,
+ commit?: CommitId,
+ options?: GenerateWebLinksOptions
+ ): GeneratedWebLink {
+ const params: GenerateWebLinksPatchsetParameters = {
+ type: WeblinkType.PATCHSET,
+ repo,
+ commit,
+ };
+ if (options) {
+ params.options = options;
+ }
+ const result = this._generateWeblinks(params);
+ if (Array.isArray(result)) {
+ // TODO(TS): Unclear what to do with empty array.
+ // Either write a comment why result can't be empty or change the return
+ // type or add a check.
+ return result.pop()!;
+ } else {
+ return result;
+ }
+ },
+
+ getChangeWeblinks(
+ repo: RepoName,
+ commit: CommitId,
+ options?: GenerateWebLinksOptions
+ ): GeneratedWebLink[] {
+ const params: GenerateWebLinksChangeParameters = {
+ type: WeblinkType.CHANGE,
+ repo,
+ commit,
+ };
+ if (options) {
+ params.options = options;
+ }
+ return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
+ },
+
+ getUserDashboard(
+ user = 'self',
+ sections = DEFAULT_SECTIONS,
+ title = '',
+ config: UserDashboardConfig = {}
+ ): UserDashboard {
+ const attentionEnabled =
+ config.change && !!config.change.enable_attention_set;
+ const assigneeEnabled = config.change && !!config.change.enable_assignee;
+ sections = sections
+ .filter(section => attentionEnabled || !section.attentionSetOnly)
+ .filter(section => assigneeEnabled || !section.assigneeOnly)
+ .filter(section => user === 'self' || !section.selfOnly)
+ .map(section => {
+ return {
+ ...section,
+ name: section.name,
+ query: section.query.replace(USER_PLACEHOLDER_PATTERN, user),
+ };
+ });
+ return {title, sections};
+ },
+};
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
deleted file mode 100644
index 792a751..0000000
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ /dev/null
@@ -1,1565 +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-rest-api-interface/gr-rest-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 page from 'page/page.mjs';
-import {htmlTemplate} from './gr-router_html.js';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
-import {GerritNav} from '../gr-navigation/gr-navigation.js';
-import {appContext} from '../../../services/app-context.js';
-import {patchNumEquals} from '../../../utils/patch-set-util.js';
-
-const RoutePattern = {
- ROOT: '/',
-
- DASHBOARD: /^\/dashboard\/(.+)$/,
- CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
- PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
-
- AGREEMENTS: /^\/settings\/agreements\/?/,
- NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
- REGISTER: /^\/register(\/.*)?$/,
-
- // Pattern for login and logout URLs intended to be passed-through. May
- // include a return URL.
- LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
-
- // Pattern for a catchall route when no other pattern is matched.
- DEFAULT: /.*/,
-
- // Matches /admin/groups/[uuid-]<group>
- GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
-
- // Redirects /groups/self to /settings/#Groups for GWT compatibility
- GROUP_SELF: /^\/groups\/self/,
-
- // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
- // Redirects to /admin/groups/[uuid-]<group>
- GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
-
- // Matches /admin/groups/<group>,audit-log
- GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
-
- // Matches /admin/groups/[uuid-]<group>,members
- GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
-
- // Matches /admin/groups[,<offset>][/].
- GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
- GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
- GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
-
- // Matches /admin/create-project
- LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
-
- // Matches /admin/create-project
- LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
-
- PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
-
- // Matches /admin/repos/<repo>
- REPO: /^\/admin\/repos\/([^,]+)$/,
-
- // Matches /admin/repos/<repo>,commands.
- REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
-
- // Matches /admin/repos/<repos>,access.
- REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
-
- // Matches /admin/repos/<repos>,access.
- REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
-
- // Matches /admin/repos[,<offset>][/].
- REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
- REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
- REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
-
- // Matches /admin/repos/<repo>,branches[,<offset>].
- BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
- BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
- BRANCH_LIST_FILTER_OFFSET:
- '/admin/repos/:repo,branches/q/filter::filter,:offset',
-
- // Matches /admin/repos/<repo>,tags[,<offset>].
- TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
- TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
- TAG_LIST_FILTER_OFFSET:
- '/admin/repos/:repo,tags/q/filter::filter,:offset',
-
- PLUGINS: /^\/plugins\/(.+)$/,
-
- PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
-
- // Matches /admin/plugins[,<offset>][/].
- PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
- PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
- PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
-
- QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
-
- /**
- * Support vestigial params from GWT UI.
- *
- * @see Issue 7673.
- * @type {!RegExp}
- */
- QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
-
- // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
- CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
- CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
-
- // Matches
- // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..][<patchNum|edit>].
- // TODO(kaspern): Migrate completely to project based URLs, with backwards
- // compatibility for change-only.
- CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
-
- // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>],edit
- CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
-
- // Matches
- // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..]<patchNum|edit>/<path>.
- // TODO(kaspern): Migrate completely to project based URLs, with backwards
- // compatibility for change-only.
- // eslint-disable-next-line max-len
- DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
-
- // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>]/<path>,edit[#lineNum]
- DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(#\d+)?$/,
-
- // Matches non-project-relative
- // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
- DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
-
- // Matches diff routes using @\d+ to specify a file name (whether or not
- // the project name is included).
- // eslint-disable-next-line max-len
- DIFF_LEGACY_LINENUM: /^\/c\/((.+)\/\+\/)?(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?\/(.+))?)@[ab]?\d+$/,
-
- SETTINGS: /^\/settings\/?/,
- SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
-
- // Matches /c/<changeNum>/ /<URL tail>
- // Catches improperly encoded URLs (context: Issue 7100)
- IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/ \/(.+)$/,
-
- PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
-
- DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
- DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
- DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
-};
-
-/**
- * Pattern to recognize and parse the diff line locations as they appear in
- * the hash of diff URLs. In this format, a number on its own indicates that
- * line number in the revision of the diff. A number prefixed by either an 'a'
- * or a 'b' indicates that line number of the base of the diff.
- *
- * @type {RegExp}
- */
-const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
-
-/**
- * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
- */
-const PLUS_PATTERN = /\+/g;
-
-/**
- * Pattern to recognize leading '?' in window.location.search, for stripping.
- */
-const QUESTION_PATTERN = /^\?*/;
-
-/**
- * GWT UI would use @\d+ at the end of a path to indicate linenum.
- */
-const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
-
-const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
-
-const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
-
-// Polymer makes `app` intrinsically defined on the window by virtue of the
-// custom element having the id "app", but it is made explicit here.
-// If you move this code to other place, please update comment about
-// gr-router and gr-app in the PolyGerritIndexHtml.soy file if needed
-const app = document.querySelector('#app');
-if (!app) {
- console.log('No gr-app found (running tests)');
-}
-
-// Setup listeners outside of the router component initialization.
-(function() {
- window.addEventListener('WebComponentsReady', () => {
- appContext.reportingService.timeEnd('WebComponentsReady');
- });
-})();
-
-/**
- * @extends PolymerElement
- */
-class GrRouter extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-router'; }
-
- static get properties() {
- return {
- _app: {
- type: Object,
- value: app,
- },
- _isRedirecting: Boolean,
- // This variable is to differentiate between internal navigation (false)
- // and for first navigation in app after loaded from server (true).
- _isInitialLoad: {
- type: Boolean,
- value: true,
- },
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- start() {
- if (!this._app) { return; }
- this._startRouter();
- }
-
- _setParams(params) {
- this._appElement().params = params;
- }
-
- _appElement() {
- // In Polymer2 you have to reach through the shadow root of the app
- // element. This obviously breaks encapsulation.
- // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
- // explicitly in app, or by delegating to it.
- return document.getElementById('app-element') ||
- document.getElementById('app').shadowRoot.getElementById(
- 'app-element');
- }
-
- _redirect(url) {
- this._isRedirecting = true;
- page.redirect(url);
- }
-
- /**
- * @param {!Object} params
- * @return {string}
- */
- _generateUrl(params) {
- const base = getBaseUrl();
- let url = '';
- const Views = GerritNav.View;
-
- if (params.view === Views.SEARCH) {
- url = this._generateSearchUrl(params);
- } else if (params.view === Views.CHANGE) {
- url = this._generateChangeUrl(params);
- } else if (params.view === Views.DASHBOARD) {
- url = this._generateDashboardUrl(params);
- } else if (params.view === Views.DIFF || params.view === Views.EDIT) {
- url = this._generateDiffOrEditUrl(params);
- } else if (params.view === Views.GROUP) {
- url = this._generateGroupUrl(params);
- } else if (params.view === Views.REPO) {
- url = this._generateRepoUrl(params);
- } else if (params.view === Views.ROOT) {
- url = '/';
- } else if (params.view === Views.SETTINGS) {
- url = this._generateSettingsUrl(params);
- } else {
- throw new Error('Can\'t generate');
- }
-
- return base + url;
- }
-
- _generateWeblinks(params) {
- const type = params.type;
- switch (type) {
- case GerritNav.WeblinkType.FILE:
- return this._getFileWebLinks(params);
- case GerritNav.WeblinkType.CHANGE:
- return this._getChangeWeblinks(params);
- case GerritNav.WeblinkType.PATCHSET:
- return this._getPatchSetWeblink(params);
- default:
- console.warn(`Unsupported weblink ${type}!`);
- }
- }
-
- _getPatchSetWeblink(params) {
- const {commit, options} = params;
- const {weblinks, config} = options || {};
- const name = commit && commit.slice(0, 7);
- const weblink = this._getBrowseCommitWeblink(weblinks, config);
- if (!weblink || !weblink.url) {
- return {name};
- } else {
- return {name, url: weblink.url};
- }
- }
-
- _firstCodeBrowserWeblink(weblinks) {
- // This is an ordered allowed list of web link types that provide direct
- // links to the commit in the url property.
- const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
- for (let i = 0; i < codeBrowserLinks.length; i++) {
- const weblink =
- weblinks.find(weblink => weblink.name === codeBrowserLinks[i]);
- if (weblink) { return weblink; }
- }
- return null;
- }
-
- _getBrowseCommitWeblink(weblinks, config) {
- if (!weblinks) { return null; }
- let weblink;
- // Use primary weblink if configured and exists.
- if (config && config.gerrit && config.gerrit.primary_weblink_name) {
- weblink = weblinks.find(
- weblink => weblink.name === config.gerrit.primary_weblink_name
- );
- }
- if (!weblink) {
- weblink = this._firstCodeBrowserWeblink(weblinks);
- }
- if (!weblink) { return null; }
- return weblink;
- }
-
- _getChangeWeblinks({repo, commit, options: {weblinks, config}}) {
- if (!weblinks || !weblinks.length) return [];
- const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
- return weblinks.filter(weblink =>
- !commitWeblink ||
- !commitWeblink.name ||
- weblink.name !== commitWeblink.name);
- }
-
- _getFileWebLinks({repo, commit, file, options: {weblinks}}) {
- return weblinks;
- }
-
- /**
- * @param {!Object} params
- * @return {string}
- */
- _generateSearchUrl(params) {
- let offsetExpr = '';
- if (params.offset && params.offset > 0) {
- offsetExpr = ',' + params.offset;
- }
-
- if (params.query) {
- return '/q/' + encodeURL(params.query, true) + offsetExpr;
- }
-
- const operators = [];
- if (params.owner) {
- operators.push('owner:' + encodeURL(params.owner, false));
- }
- if (params.project) {
- operators.push('project:' + encodeURL(params.project, false));
- }
- if (params.branch) {
- operators.push('branch:' + encodeURL(params.branch, false));
- }
- if (params.topic) {
- operators.push('topic:"' + encodeURL(params.topic, false) + '"');
- }
- if (params.hashtag) {
- operators.push('hashtag:"' +
- encodeURL(params.hashtag.toLowerCase(), false) + '"');
- }
- if (params.statuses) {
- if (params.statuses.length === 1) {
- operators.push(
- 'status:' + encodeURL(params.statuses[0], false));
- } else if (params.statuses.length > 1) {
- operators.push(
- '(' +
- params.statuses.map(s => `status:${encodeURL(s, false)}`)
- .join(' OR ') +
- ')');
- }
- }
-
- return '/q/' + operators.join('+') + offsetExpr;
- }
-
- /**
- * @param {!Object} params
- * @return {string}
- */
- _generateChangeUrl(params) {
- let range = this._getPatchRangeExpression(params);
- if (range.length) { range = '/' + range; }
- let suffix = `${range}`;
- if (params.querystring) {
- suffix += '?' + params.querystring;
- } else if (params.edit) {
- suffix += ',edit';
- }
- if (params.messageHash) {
- suffix += params.messageHash;
- }
- if (params.project) {
- const encodedProject = encodeURL(params.project, true);
- return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
- } else {
- return `/c/${params.changeNum}${suffix}`;
- }
- }
-
- /**
- * @param {!Object} params
- * @return {string}
- */
- _generateDashboardUrl(params) {
- const repoName = params.repo || params.project || null;
- if (params.sections) {
- // Custom dashboard.
- const queryParams = this._sectionsToEncodedParams(params.sections,
- repoName);
- if (params.title) {
- queryParams.push('title=' + encodeURIComponent(params.title));
- }
- const user = params.user ? params.user : '';
- return `/dashboard/${user}?${queryParams.join('&')}`;
- } else if (repoName) {
- // Project dashboard.
- const encodedRepo = encodeURL(repoName, true);
- return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
- } else {
- // User dashboard.
- return `/dashboard/${params.user || 'self'}`;
- }
- }
-
- /**
- * @param {!Array<!{name: string, query: string}>} sections
- * @param {string=} opt_repoName
- * @return {!Array<string>}
- */
- _sectionsToEncodedParams(sections, opt_repoName) {
- return sections.map(section => {
- // If there is a repo name provided, make sure to substitute it into the
- // ${repo} (or legacy ${project}) query tokens.
- const query = opt_repoName ?
- section.query.replace(REPO_TOKEN_PATTERN, opt_repoName) :
- section.query;
- return encodeURIComponent(section.name) + '=' +
- encodeURIComponent(query);
- });
- }
-
- /**
- * @param {!Object} params
- * @return {string}
- */
- _generateDiffOrEditUrl(params) {
- let range = this._getPatchRangeExpression(params);
- if (range.length) { range = '/' + range; }
-
- let suffix = `${range}/${encodeURL(params.path, true)}`;
-
- if (params.view === GerritNav.View.EDIT) { suffix += ',edit'; }
-
- if (params.lineNum) {
- suffix += '#';
- if (params.leftSide) { suffix += 'b'; }
- suffix += params.lineNum;
- }
-
- if (params.project) {
- const encodedProject = encodeURL(params.project, true);
- return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
- } else {
- return `/c/${params.changeNum}${suffix}`;
- }
- }
-
- /**
- * @param {!Object} params
- * @return {string}
- */
- _generateGroupUrl(params) {
- let url = `/admin/groups/${encodeURL(params.groupId + '', true)}`;
- if (params.detail === GerritNav.GroupDetailView.MEMBERS) {
- url += ',members';
- } else if (params.detail === GerritNav.GroupDetailView.LOG) {
- url += ',audit-log';
- }
- return url;
- }
-
- /**
- * @param {!Object} params
- * @return {string}
- */
- _generateRepoUrl(params) {
- let url = `/admin/repos/${encodeURL(params.repoName + '', true)}`;
- if (params.detail === GerritNav.RepoDetailView.ACCESS) {
- url += ',access';
- } else if (params.detail === GerritNav.RepoDetailView.BRANCHES) {
- url += ',branches';
- } else if (params.detail === GerritNav.RepoDetailView.TAGS) {
- url += ',tags';
- } else if (params.detail === GerritNav.RepoDetailView.COMMANDS) {
- url += ',commands';
- } else if (params.detail === GerritNav.RepoDetailView.DASHBOARDS) {
- url += ',dashboards';
- }
- return url;
- }
-
- /**
- * @param {!Object} params
- * @return {string}
- */
- _generateSettingsUrl(params) {
- return '/settings';
- }
-
- /**
- * Given an object of parameters, potentially including a `patchNum` or a
- * `basePatchNum` or both, return a string representation of that range. If
- * no range is indicated in the params, the empty string is returned.
- *
- * @param {!Object} params
- * @return {string}
- */
- _getPatchRangeExpression(params) {
- let range = '';
- if (params.patchNum) { range = '' + params.patchNum; }
- if (params.basePatchNum) { range = params.basePatchNum + '..' + range; }
- return range;
- }
-
- /**
- * Given a set of params without a project, gets the project from the rest
- * API project lookup and then sets the app params.
- *
- * @param {?Object} params
- */
- _normalizeLegacyRouteParams(params) {
- if (!params.changeNum) { return Promise.resolve(); }
-
- return this.$.restAPI.getFromProjectLookup(params.changeNum)
- .then(project => {
- // Show a 404 and terminate if the lookup request failed. Attempting
- // to redirect after failing to get the project loops infinitely.
- if (!project) {
- this._show404();
- return;
- }
-
- params.project = project;
- this._normalizePatchRangeParams(params);
- this._redirect(this._generateUrl(params));
- });
- }
-
- /**
- * Normalizes the params object, and determines if the URL needs to be
- * modified to fit the proper schema.
- *
- * @param {*} params
- * @return {boolean} whether or not the URL needs to be upgraded.
- */
- _normalizePatchRangeParams(params) {
- const hasBasePatchNum = params.basePatchNum !== null &&
- params.basePatchNum !== undefined;
- const hasPatchNum = params.patchNum !== null &&
- params.patchNum !== undefined;
- let needsRedirect = false;
-
- // Diffing a patch against itself is invalid, so if the base and revision
- // patches are equal clear the base.
- if (hasBasePatchNum &&
- patchNumEquals(params.basePatchNum, params.patchNum)) {
- needsRedirect = true;
- params.basePatchNum = null;
- } else if (hasBasePatchNum && !hasPatchNum) {
- // Regexes set basePatchNum instead of patchNum when only one is
- // specified. Redirect is not needed in this case.
- params.patchNum = params.basePatchNum;
- params.basePatchNum = null;
- }
- return needsRedirect;
- }
-
- /**
- * Redirect the user to login using the given return-URL for redirection
- * after authentication success.
- *
- * @param {string} returnUrl
- */
- _redirectToLogin(returnUrl) {
- const basePath = getBaseUrl() || '';
- page(
- '/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
- }
-
- /**
- * Hashes parsed by page.js exclude "inner" hashes, so a URL like "/a#b#c"
- * is parsed to have a hash of "b" rather than "b#c". Instead, this method
- * parses hashes correctly. Will return an empty string if there is no hash.
- *
- * @param {!string} canonicalPath
- * @return {!string} Everything after the first '#' ("a#b#c" -> "b#c").
- */
- _getHashFromCanonicalPath(canonicalPath) {
- return canonicalPath.split('#').slice(1)
- .join('#');
- }
-
- _parseLineAddress(hash) {
- const match = hash.match(LINE_ADDRESS_PATTERN);
- if (!match) { return null; }
- return {
- leftSide: !!match[1],
- lineNum: parseInt(match[2], 10),
- };
- }
-
- /**
- * Check to see if the user is logged in and return a promise that only
- * resolves if the user is logged in. If the user us not logged in, the
- * promise is rejected and the page is redirected to the login flow.
- *
- * @param {!Object} data The parsed route data.
- * @return {!Promise<!Object>} A promise yielding the original route data
- * (if it resolves).
- */
- _redirectIfNotLoggedIn(data) {
- return this.$.restAPI.getLoggedIn().then(loggedIn => {
- if (loggedIn) {
- return Promise.resolve();
- } else {
- this._redirectToLogin(data.canonicalPath);
- return Promise.reject(new Error());
- }
- });
- }
-
- /** Page.js middleware that warms the REST API's logged-in cache line. */
- _loadUserMiddleware(ctx, next) {
- this.$.restAPI.getLoggedIn().then(() => { next(); });
- }
-
- /** Page.js middleware that try parse the querystring into queryMap. */
- _queryStringMiddleware(ctx, next) {
- let queryMap = new Map();
- if (ctx.querystring) {
- // https://caniuse.com/#search=URLSearchParams
- if (window.URLSearchParams) {
- queryMap = new URLSearchParams(ctx.querystring);
- } else {
- queryMap = new Map(this._parseQueryString(ctx.querystring));
- }
- }
- ctx.queryMap = queryMap;
- next();
- }
-
- /**
- * Map a route to a method on the router.
- *
- * @param {!string|!RegExp} pattern The page.js pattern for the route.
- * @param {!string} handlerName The method name for the handler. If the
- * route is matched, the handler will be executed with `this` referring
- * to the component. Its return value will be discarded so that it does
- * not interfere with page.js.
- * @param {?boolean=} opt_authRedirect If true, then auth is checked before
- * executing the handler. If the user is not logged in, it will redirect
- * to the login flow and the handler will not be executed. The login
- * redirect specifies the matched URL to be used after successfull auth.
- */
- _mapRoute(pattern, handlerName, opt_authRedirect) {
- if (!this[handlerName]) {
- console.error('Attempted to map route to unknown method: ',
- handlerName);
- return;
- }
- page(pattern,
- (ctx, next) => this._loadUserMiddleware(ctx, next),
- (ctx, next) => this._queryStringMiddleware(ctx, next),
- data => {
- this.reporting.locationChanged(handlerName);
- const promise = opt_authRedirect ?
- this._redirectIfNotLoggedIn(data) : Promise.resolve();
- promise.then(() => { this[handlerName](data); });
- });
- }
-
- _startRouter() {
- const base = getBaseUrl();
- if (base) {
- page.base(base);
- }
-
- GerritNav.setup(
- (url, opt_redirect) => {
- if (opt_redirect) {
- page.redirect(url);
- } else {
- page.show(url);
- }
- },
- this._generateUrl.bind(this),
- params => this._generateWeblinks(params),
- x => x
- );
-
- page.exit('*', (ctx, next) => {
- if (!this._isRedirecting) {
- this.reporting.beforeLocationChanged();
- }
- this._isRedirecting = false;
- this._isInitialLoad = false;
- next();
- });
-
- // Middleware
- page((ctx, next) => {
- document.body.scrollTop = 0;
-
- if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
- // Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
- // This is needed to allow plugins to add basic #/x/ screen links to
- // any location.
- this._redirect(ctx.hash);
- return;
- }
-
- // Fire asynchronously so that the URL is changed by the time the event
- // is processed.
- this.async(() => {
- this.dispatchEvent(new CustomEvent('location-change', {
- detail: {
- hash: window.location.hash,
- pathname: window.location.pathname,
- },
- composed: true, bubbles: true,
- }));
- }, 1);
- next();
- });
-
- this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
-
- this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
-
- this._mapRoute(RoutePattern.CUSTOM_DASHBOARD,
- '_handleCustomDashboardRoute');
-
- this._mapRoute(RoutePattern.PROJECT_DASHBOARD,
- '_handleProjectDashboardRoute');
-
- this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
-
- this._mapRoute(RoutePattern.GROUP_AUDIT_LOG, '_handleGroupAuditLogRoute',
- true);
-
- this._mapRoute(RoutePattern.GROUP_MEMBERS, '_handleGroupMembersRoute',
- true);
-
- this._mapRoute(RoutePattern.GROUP_LIST_OFFSET,
- '_handleGroupListOffsetRoute', true);
-
- this._mapRoute(RoutePattern.GROUP_LIST_FILTER_OFFSET,
- '_handleGroupListFilterOffsetRoute', true);
-
- this._mapRoute(RoutePattern.GROUP_LIST_FILTER,
- '_handleGroupListFilterRoute', true);
-
- this._mapRoute(RoutePattern.GROUP_SELF, '_handleGroupSelfRedirectRoute',
- true);
-
- this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
-
- this._mapRoute(RoutePattern.PROJECT_OLD,
- '_handleProjectsOldRoute');
-
- this._mapRoute(RoutePattern.REPO_COMMANDS,
- '_handleRepoCommandsRoute', true);
-
- this._mapRoute(RoutePattern.REPO_ACCESS,
- '_handleRepoAccessRoute');
-
- this._mapRoute(RoutePattern.REPO_DASHBOARDS,
- '_handleRepoDashboardsRoute');
-
- this._mapRoute(RoutePattern.BRANCH_LIST_OFFSET,
- '_handleBranchListOffsetRoute');
-
- this._mapRoute(RoutePattern.BRANCH_LIST_FILTER_OFFSET,
- '_handleBranchListFilterOffsetRoute');
-
- this._mapRoute(RoutePattern.BRANCH_LIST_FILTER,
- '_handleBranchListFilterRoute');
-
- this._mapRoute(RoutePattern.TAG_LIST_OFFSET,
- '_handleTagListOffsetRoute');
-
- this._mapRoute(RoutePattern.TAG_LIST_FILTER_OFFSET,
- '_handleTagListFilterOffsetRoute');
-
- this._mapRoute(RoutePattern.TAG_LIST_FILTER,
- '_handleTagListFilterRoute');
-
- this._mapRoute(RoutePattern.LEGACY_CREATE_GROUP,
- '_handleCreateGroupRoute', true);
-
- this._mapRoute(RoutePattern.LEGACY_CREATE_PROJECT,
- '_handleCreateProjectRoute', true);
-
- this._mapRoute(RoutePattern.REPO_LIST_OFFSET,
- '_handleRepoListOffsetRoute');
-
- this._mapRoute(RoutePattern.REPO_LIST_FILTER_OFFSET,
- '_handleRepoListFilterOffsetRoute');
-
- this._mapRoute(RoutePattern.REPO_LIST_FILTER,
- '_handleRepoListFilterRoute');
-
- this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
-
- this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
-
- this._mapRoute(RoutePattern.PLUGIN_LIST_OFFSET,
- '_handlePluginListOffsetRoute', true);
-
- this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
- '_handlePluginListFilterOffsetRoute', true);
-
- this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER,
- '_handlePluginListFilterRoute', true);
-
- this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
-
- this._mapRoute(RoutePattern.QUERY_LEGACY_SUFFIX,
- '_handleQueryLegacySuffixRoute');
-
- this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
-
- this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
-
- this._mapRoute(RoutePattern.CHANGE_NUMBER_LEGACY,
- '_handleChangeNumberLegacyRoute');
-
- this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
-
- this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
-
- this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
-
- this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
-
- this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
-
- this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
-
- this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
-
- this._mapRoute(RoutePattern.NEW_AGREEMENTS, '_handleNewAgreementsRoute',
- true);
-
- this._mapRoute(RoutePattern.SETTINGS_LEGACY,
- '_handleSettingsLegacyRoute', true);
-
- this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
-
- this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
-
- this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
-
- this._mapRoute(RoutePattern.IMPROPERLY_ENCODED_PLUS,
- '_handleImproperlyEncodedPlusRoute');
-
- this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
-
- this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH_FILTER,
- '_handleDocumentationSearchRoute');
-
- // redirects /Documentation/q/* to /Documentation/q/filter:*
- this._mapRoute(RoutePattern.DOCUMENTATION_SEARCH,
- '_handleDocumentationSearchRedirectRoute');
-
- // Makes sure /Documentation/* links work (doin't return 404)
- this._mapRoute(RoutePattern.DOCUMENTATION,
- '_handleDocumentationRedirectRoute');
-
- // Note: this route should appear last so it only catches URLs unmatched
- // by other patterns.
- this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
-
- page.start();
- }
-
- /**
- * @param {!Object} data
- * @return {Promise|null} if handling the route involves asynchrony, then a
- * promise is returned. Otherwise, synchronous handling returns null.
- */
- _handleRootRoute(data) {
- if (data.querystring.match(/^closeAfterLogin/)) {
- // Close child window on redirect after login.
- window.close();
- return null;
- }
- let hash = this._getHashFromCanonicalPath(data.canonicalPath);
- // For backward compatibility with GWT links.
- if (hash) {
- // In certain login flows the server may redirect to a hash without
- // a leading slash, which page.js doesn't handle correctly.
- if (hash[0] !== '/') {
- hash = '/' + hash;
- }
- if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
- // Path decodes all '+' to ' ' -- this breaks project-based URLs.
- // See Issue 6888.
- hash = hash.replace('/ /', '/+/');
- }
- const base = getBaseUrl();
- let newUrl = base + hash;
- if (hash.startsWith('/VE/')) {
- newUrl = base + '/settings' + hash;
- }
- this._redirect(newUrl);
- return null;
- }
- return this.$.restAPI.getLoggedIn().then(loggedIn => {
- if (loggedIn) {
- this._redirect('/dashboard/self');
- } else {
- this._redirect('/q/status:open+-is:wip');
- }
- });
- }
-
- /**
- * Decode an application/x-www-form-urlencoded string.
- *
- * @param {string} qs The application/x-www-form-urlencoded string.
- * @return {string} The decoded string.
- */
- _decodeQueryString(qs) {
- return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
- }
-
- /**
- * Parse a query string (e.g. window.location.search) into an array of
- * name/value pairs.
- *
- * @param {string} qs The application/x-www-form-urlencoded query string.
- * @return {!Array<!Array<string>>} An array of name/value pairs, where each
- * element is a 2-element array.
- */
- _parseQueryString(qs) {
- qs = qs.replace(QUESTION_PATTERN, '');
- if (!qs) {
- return [];
- }
- const params = [];
- qs.split('&').forEach(param => {
- const idx = param.indexOf('=');
- let name;
- let value;
- if (idx < 0) {
- name = this._decodeQueryString(param);
- value = '';
- } else {
- name = this._decodeQueryString(param.substring(0, idx));
- value = this._decodeQueryString(param.substring(idx + 1));
- }
- if (name) {
- params.push([name, value]);
- }
- });
- return params;
- }
-
- /**
- * Handle dashboard routes. These may be user, or project dashboards.
- *
- * @param {!Object} data The parsed route data.
- */
- _handleDashboardRoute(data) {
- // User dashboard. We require viewing user to be logged in, else we
- // redirect to login for self dashboard or simple owner search for
- // other user dashboard.
- return this.$.restAPI.getLoggedIn().then(loggedIn => {
- if (!loggedIn) {
- if (data.params[0].toLowerCase() === 'self') {
- this._redirectToLogin(data.canonicalPath);
- } else {
- this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
- }
- } else {
- this._setParams({
- view: GerritNav.View.DASHBOARD,
- user: data.params[0],
- });
- }
- });
- }
-
- /**
- * Handle custom dashboard routes.
- *
- * @param {!Object} data The parsed route data.
- * @param {string=} opt_qs Optional query string associated with the route.
- * If not given, window.location.search is used. (Used by tests).
- */
- _handleCustomDashboardRoute(data, opt_qs) {
- // opt_qs may be provided by a test, and it may have a falsy value
- const qs = opt_qs !== undefined ? opt_qs : window.location.search;
- const queryParams = this._parseQueryString(qs);
- let title = 'Custom Dashboard';
- const titleParam = queryParams.find(
- elem => elem[0].toLowerCase() === 'title');
- if (titleParam) {
- title = titleParam[1];
- }
- // Dashboards support a foreach param which adds a base query to any
- // additional query.
- const forEachParam = queryParams.find(
- elem => elem[0].toLowerCase() === 'foreach');
- let forEachQuery = null;
- if (forEachParam) {
- forEachQuery = forEachParam[1];
- }
- const sectionParams = queryParams.filter(
- elem => elem[0] && elem[1] && elem[0].toLowerCase() !== 'title' &&
- elem[0].toLowerCase() !== 'foreach');
- const sections = sectionParams.map(elem => {
- const query = forEachQuery ? `${forEachQuery} ${elem[1]}` : elem[1];
- return {
- name: elem[0],
- query,
- };
- });
-
- if (sections.length > 0) {
- // Custom dashboard view.
- this._setParams({
- view: GerritNav.View.DASHBOARD,
- user: 'self',
- sections,
- title,
- });
- return Promise.resolve();
- }
-
- // Redirect /dashboard/ -> /dashboard/self.
- this._redirect('/dashboard/self');
- return Promise.resolve();
- }
-
- _handleProjectDashboardRoute(data) {
- const project = data.params[0];
- this._setParams({
- view: GerritNav.View.DASHBOARD,
- project,
- dashboard: decodeURIComponent(data.params[1]),
- });
- this.reporting.setRepoName(project);
- }
-
- _handleGroupInfoRoute(data) {
- this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
- }
-
- _handleGroupSelfRedirectRoute(data) {
- this._redirect('/settings/#Groups');
- }
-
- _handleGroupRoute(data) {
- this._setParams({
- view: GerritNav.View.GROUP,
- groupId: data.params[0],
- });
- }
-
- _handleGroupAuditLogRoute(data) {
- this._setParams({
- view: GerritNav.View.GROUP,
- detail: GerritNav.GroupDetailView.LOG,
- groupId: data.params[0],
- });
- }
-
- _handleGroupMembersRoute(data) {
- this._setParams({
- view: GerritNav.View.GROUP,
- detail: GerritNav.GroupDetailView.MEMBERS,
- groupId: data.params[0],
- });
- }
-
- _handleGroupListOffsetRoute(data) {
- this._setParams({
- view: GerritNav.View.ADMIN,
- adminView: 'gr-admin-group-list',
- offset: data.params[1] || 0,
- filter: null,
- openCreateModal: data.hash === 'create',
- });
- }
-
- _handleGroupListFilterOffsetRoute(data) {
- this._setParams({
- view: GerritNav.View.ADMIN,
- adminView: 'gr-admin-group-list',
- offset: data.params.offset,
- filter: data.params.filter,
- });
- }
-
- _handleGroupListFilterRoute(data) {
- this._setParams({
- view: GerritNav.View.ADMIN,
- adminView: 'gr-admin-group-list',
- filter: data.params.filter || null,
- });
- }
-
- _handleProjectsOldRoute(data) {
- let params = '';
- if (data.params[1]) {
- params = encodeURIComponent(data.params[1]);
- if (data.params[1].includes(',')) {
- params =
- encodeURIComponent(data.params[1]).replace('%2C', ',');
- }
- }
-
- this._redirect(`/admin/repos/${params}`);
- }
-
- _handleRepoCommandsRoute(data) {
- const repo = data.params[0];
- this._setParams({
- view: GerritNav.View.REPO,
- detail: GerritNav.RepoDetailView.COMMANDS,
- repo,
- });
- this.reporting.setRepoName(repo);
- }
-
- _handleRepoAccessRoute(data) {
- const repo = data.params[0];
- this._setParams({
- view: GerritNav.View.REPO,
- detail: GerritNav.RepoDetailView.ACCESS,
- repo,
- });
- this.reporting.setRepoName(repo);
- }
-
- _handleRepoDashboardsRoute(data) {
- const repo = data.params[0];
- this._setParams({
- view: GerritNav.View.REPO,
- detail: GerritNav.RepoDetailView.DASHBOARDS,
- repo,
- });
- this.reporting.setRepoName(repo);
- }
-
- _handleBranchListOffsetRoute(data) {
- this._setParams({
- view: GerritNav.View.REPO,
- detail: GerritNav.RepoDetailView.BRANCHES,
- repo: data.params[0],
- offset: data.params[2] || 0,
- filter: null,
- });
- }
-
- _handleBranchListFilterOffsetRoute(data) {
- this._setParams({
- view: GerritNav.View.REPO,
- detail: GerritNav.RepoDetailView.BRANCHES,
- repo: data.params.repo,
- offset: data.params.offset,
- filter: data.params.filter,
- });
- }
-
- _handleBranchListFilterRoute(data) {
- this._setParams({
- view: GerritNav.View.REPO,
- detail: GerritNav.RepoDetailView.BRANCHES,
- repo: data.params.repo,
- filter: data.params.filter || null,
- });
- }
-
- _handleTagListOffsetRoute(data) {
- this._setParams({
- view: GerritNav.View.REPO,
- detail: GerritNav.RepoDetailView.TAGS,
- repo: data.params[0],
- offset: data.params[2] || 0,
- filter: null,
- });
- }
-
- _handleTagListFilterOffsetRoute(data) {
- this._setParams({
- view: GerritNav.View.REPO,
- detail: GerritNav.RepoDetailView.TAGS,
- repo: data.params.repo,
- offset: data.params.offset,
- filter: data.params.filter,
- });
- }
-
- _handleTagListFilterRoute(data) {
- this._setParams({
- view: GerritNav.View.REPO,
- detail: GerritNav.RepoDetailView.TAGS,
- repo: data.params.repo,
- filter: data.params.filter || null,
- });
- }
-
- _handleRepoListOffsetRoute(data) {
- this._setParams({
- view: GerritNav.View.ADMIN,
- adminView: 'gr-repo-list',
- offset: data.params[1] || 0,
- filter: null,
- openCreateModal: data.hash === 'create',
- });
- }
-
- _handleRepoListFilterOffsetRoute(data) {
- this._setParams({
- view: GerritNav.View.ADMIN,
- adminView: 'gr-repo-list',
- offset: data.params.offset,
- filter: data.params.filter,
- });
- }
-
- _handleRepoListFilterRoute(data) {
- this._setParams({
- view: GerritNav.View.ADMIN,
- adminView: 'gr-repo-list',
- filter: data.params.filter || null,
- });
- }
-
- _handleCreateProjectRoute(data) {
- // Redirects the legacy route to the new route, which displays the project
- // list with a hash 'create'.
- this._redirect('/admin/repos#create');
- }
-
- _handleCreateGroupRoute(data) {
- // Redirects the legacy route to the new route, which displays the group
- // list with a hash 'create'.
- this._redirect('/admin/groups#create');
- }
-
- _handleRepoRoute(data) {
- const repo = data.params[0];
- this._setParams({
- view: GerritNav.View.REPO,
- repo,
- });
- this.reporting.setRepoName(repo);
- }
-
- _handlePluginListOffsetRoute(data) {
- this._setParams({
- view: GerritNav.View.ADMIN,
- adminView: 'gr-plugin-list',
- offset: data.params[1] || 0,
- filter: null,
- });
- }
-
- _handlePluginListFilterOffsetRoute(data) {
- this._setParams({
- view: GerritNav.View.ADMIN,
- adminView: 'gr-plugin-list',
- offset: data.params.offset,
- filter: data.params.filter,
- });
- }
-
- _handlePluginListFilterRoute(data) {
- this._setParams({
- view: GerritNav.View.ADMIN,
- adminView: 'gr-plugin-list',
- filter: data.params.filter || null,
- });
- }
-
- _handlePluginListRoute(data) {
- this._setParams({
- view: GerritNav.View.ADMIN,
- adminView: 'gr-plugin-list',
- });
- }
-
- _handleQueryRoute(data) {
- this._setParams({
- view: GerritNav.View.SEARCH,
- query: data.params[0],
- offset: data.params[2],
- });
- }
-
- _handleQueryLegacySuffixRoute(ctx) {
- this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
- }
-
- _handleChangeNumberLegacyRoute(ctx) {
- this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
- }
-
- _handleChangeRoute(ctx) {
- // Parameter order is based on the regex group number matched.
- const params = {
- project: ctx.params[0],
- changeNum: ctx.params[1],
- basePatchNum: ctx.params[4],
- patchNum: ctx.params[6],
- view: GerritNav.View.CHANGE,
- queryMap: ctx.queryMap,
- };
-
- this.reporting.setRepoName(params.project);
- this._redirectOrNavigate(params);
- }
-
- _handleDiffRoute(ctx) {
- // Parameter order is based on the regex group number matched.
- const params = {
- project: ctx.params[0],
- changeNum: ctx.params[1],
- basePatchNum: ctx.params[4],
- patchNum: ctx.params[6],
- path: ctx.params[8],
- view: GerritNav.View.DIFF,
- };
-
- const address = this._parseLineAddress(ctx.hash);
- if (address) {
- params.leftSide = address.leftSide;
- params.lineNum = address.lineNum;
- }
- this.reporting.setRepoName(params.project);
- this._redirectOrNavigate(params);
- }
-
- _handleChangeLegacyRoute(ctx) {
- // Parameter order is based on the regex group number matched.
- const params = {
- changeNum: ctx.params[0],
- basePatchNum: ctx.params[3],
- patchNum: ctx.params[5],
- view: GerritNav.View.CHANGE,
- querystring: ctx.querystring,
- };
-
- this._normalizeLegacyRouteParams(params);
- }
-
- _handleLegacyLinenum(ctx) {
- this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
- }
-
- _handleDiffLegacyRoute(ctx) {
- // Parameter order is based on the regex group number matched.
- const params = {
- changeNum: ctx.params[0],
- basePatchNum: ctx.params[2],
- patchNum: ctx.params[4],
- path: ctx.params[5],
- view: GerritNav.View.DIFF,
- };
-
- const address = this._parseLineAddress(ctx.hash);
- if (address) {
- params.leftSide = address.leftSide;
- params.lineNum = address.lineNum;
- }
-
- this._normalizeLegacyRouteParams(params);
- }
-
- _handleDiffEditRoute(ctx) {
- // Parameter order is based on the regex group number matched.
- const project = ctx.params[0];
- this._redirectOrNavigate({
- project,
- changeNum: ctx.params[1],
- patchNum: ctx.params[2],
- path: ctx.params[3],
- lineNum: ctx.hash,
- view: GerritNav.View.EDIT,
- });
- this.reporting.setRepoName(project);
- }
-
- _handleChangeEditRoute(ctx) {
- // Parameter order is based on the regex group number matched.
- const project = ctx.params[0];
- this._redirectOrNavigate({
- project,
- changeNum: ctx.params[1],
- patchNum: ctx.params[3],
- view: GerritNav.View.CHANGE,
- edit: true,
- });
- this.reporting.setRepoName(project);
- }
-
- /**
- * Normalize the patch range params for a the change or diff view and
- * redirect if URL upgrade is needed.
- */
- _redirectOrNavigate(params) {
- const needsRedirect = this._normalizePatchRangeParams(params);
- if (needsRedirect) {
- this._redirect(this._generateUrl(params));
- } else {
- this._setParams(params);
- }
- }
-
- _handleAgreementsRoute() {
- this._redirect('/settings/#Agreements');
- }
-
- _handleNewAgreementsRoute(data) {
- data.params.view = GerritNav.View.AGREEMENTS;
- this._setParams(data.params);
- }
-
- _handleSettingsLegacyRoute(data) {
- // email tokens may contain '+' but no space.
- // The parameter parsing replaces all '+' with a space,
- // undo that to have valid tokens.
- const token = data.params[0].replace(/ /g, '+');
- this._setParams({
- view: GerritNav.View.SETTINGS,
- emailToken: token,
- });
- }
-
- _handleSettingsRoute(data) {
- this._setParams({view: GerritNav.View.SETTINGS});
- }
-
- _handleRegisterRoute(ctx) {
- this._setParams({justRegistered: true});
- let path = ctx.params[0] || '/';
-
- // Prevent redirect looping.
- if (path.startsWith('/register')) { path = '/'; }
-
- if (path[0] !== '/') { return; }
- this._redirect(getBaseUrl() + path);
- }
-
- /**
- * Handler for routes that should pass through the router and not be caught
- * by the catchall _handleDefaultRoute handler.
- */
- _handlePassThroughRoute() {
- location.reload();
- }
-
- /**
- * URL may sometimes have /+/ encoded to / /.
- * Context: Issue 6888, Issue 7100
- */
- _handleImproperlyEncodedPlusRoute(ctx) {
- let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
- if (hash.length) { hash = '#' + hash; }
- this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
- }
-
- _handlePluginScreen(ctx) {
- const view = GerritNav.View.PLUGIN_SCREEN;
- const plugin = ctx.params[0];
- const screen = ctx.params[1];
- this._setParams({view, plugin, screen});
- }
-
- _handleDocumentationSearchRoute(data) {
- this._setParams({
- view: GerritNav.View.DOCUMENTATION_SEARCH,
- filter: data.params.filter || null,
- });
- }
-
- _handleDocumentationSearchRedirectRoute(data) {
- this._redirect('/Documentation/q/filter:' +
- encodeURIComponent(data.params[0]));
- }
-
- _handleDocumentationRedirectRoute(data) {
- if (data.params[1]) {
- location.reload();
- } else {
- // Redirect /Documentation to /Documentation/index.html
- this._redirect('/Documentation/index.html');
- }
- }
-
- /**
- * Catchall route for when no other route is matched.
- */
- _handleDefaultRoute() {
- if (this._isInitialLoad) {
- // Server recognized this route as polygerrit, so we show 404.
- this._show404();
- } else {
- // Route can be recognized by server, so we pass it to server.
- this._handlePassThroughRoute();
- }
- }
-
- _show404() {
- // Note: the app's 404 display is tightly-coupled with catching 404
- // network responses, so we simulate a 404 response status to display it.
- // TODO: Decouple the gr-app error view from network responses.
- this._appElement().dispatchEvent(new CustomEvent('page-error',
- {detail: {response: {status: 404}}}));
- }
-}
-
-customElements.define(GrRouter.is, GrRouter);
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
new file mode 100644
index 0000000..21bf900
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -0,0 +1,1781 @@
+/**
+ * @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-rest-api-interface/gr-rest-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 {
+ page,
+ PageContext,
+ PageNextCallback,
+} from '../../../utils/page-wrapper-utils';
+import {htmlTemplate} from './gr-router_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {
+ DashboardSection,
+ GenerateUrlChangeViewParameters,
+ GenerateUrlDashboardViewParameters,
+ GenerateUrlDiffViewParameters,
+ GenerateUrlEditViewParameters,
+ GenerateUrlGroupViewParameters,
+ GenerateUrlParameters,
+ GenerateUrlRepoViewParameters,
+ GenerateUrlSearchViewParameters,
+ GenerateWebLinksChangeParameters,
+ GenerateWebLinksFileParameters,
+ GenerateWebLinksParameters,
+ GenerateWebLinksPatchsetParameters,
+ GerritView,
+ isGenerateUrlDiffViewParameters,
+ RepoDetailView,
+ WeblinkType,
+ GroupDetailView,
+ GerritNav,
+ GeneratedWebLink,
+} from '../gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {
+ patchNumEquals,
+ convertToPatchSetNum,
+} from '../../../utils/patch-set-util';
+import {customElement, property} from '@polymer/decorators';
+import {assertNever} from '../../../utils/common-util';
+import {
+ DashboardId,
+ GroupId,
+ NumericChangeId,
+ PatchSetNum,
+ RepoName,
+ ServerInfo,
+ UrlEncodedCommentId,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ AppElement,
+ AppElementParams,
+ AppElementAgreementParam,
+} from '../../gr-app-types';
+import {LocationChangeEventDetail} from '../../../types/events';
+
+const RoutePattern = {
+ ROOT: '/',
+
+ DASHBOARD: /^\/dashboard\/(.+)$/,
+ CUSTOM_DASHBOARD: /^\/dashboard\/?$/,
+ PROJECT_DASHBOARD: /^\/p\/(.+)\/\+\/dashboard\/(.+)/,
+ LEGACY_PROJECT_DASHBOARD: /^\/projects\/(.+),dashboards\/(.+)/,
+
+ AGREEMENTS: /^\/settings\/agreements\/?/,
+ NEW_AGREEMENTS: /^\/settings\/new-agreement\/?/,
+ REGISTER: /^\/register(\/.*)?$/,
+
+ // Pattern for login and logout URLs intended to be passed-through. May
+ // include a return URL.
+ LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
+
+ // Pattern for a catchall route when no other pattern is matched.
+ DEFAULT: /.*/,
+
+ // Matches /admin/groups/[uuid-]<group>
+ GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
+
+ // Redirects /groups/self to /settings/#Groups for GWT compatibility
+ GROUP_SELF: /^\/groups\/self/,
+
+ // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
+ // Redirects to /admin/groups/[uuid-]<group>
+ GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
+
+ // Matches /admin/groups/<group>,audit-log
+ GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
+
+ // Matches /admin/groups/[uuid-]<group>,members
+ GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
+
+ // Matches /admin/groups[,<offset>][/].
+ GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
+ GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
+ GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
+
+ // Matches /admin/create-project
+ LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
+
+ // Matches /admin/create-project
+ LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
+
+ PROJECT_OLD: /^\/admin\/(projects)\/?(.+)?$/,
+
+ // Matches /admin/repos/<repo>
+ REPO: /^\/admin\/repos\/([^,]+)$/,
+
+ // Matches /admin/repos/<repo>,commands.
+ REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
+
+ // Matches /admin/repos/<repos>,access.
+ REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
+
+ // Matches /admin/repos/<repos>,access.
+ REPO_DASHBOARDS: /^\/admin\/repos\/(.+),dashboards$/,
+
+ // Matches /admin/repos[,<offset>][/].
+ REPO_LIST_OFFSET: /^\/admin\/repos(,(\d+))?(\/)?$/,
+ REPO_LIST_FILTER: '/admin/repos/q/filter::filter',
+ REPO_LIST_FILTER_OFFSET: '/admin/repos/q/filter::filter,:offset',
+
+ // Matches /admin/repos/<repo>,branches[,<offset>].
+ BRANCH_LIST_OFFSET: /^\/admin\/repos\/(.+),branches(,(.+))?$/,
+ BRANCH_LIST_FILTER: '/admin/repos/:repo,branches/q/filter::filter',
+ BRANCH_LIST_FILTER_OFFSET:
+ '/admin/repos/:repo,branches/q/filter::filter,:offset',
+
+ // Matches /admin/repos/<repo>,tags[,<offset>].
+ TAG_LIST_OFFSET: /^\/admin\/repos\/(.+),tags(,(.+))?$/,
+ TAG_LIST_FILTER: '/admin/repos/:repo,tags/q/filter::filter',
+ TAG_LIST_FILTER_OFFSET: '/admin/repos/:repo,tags/q/filter::filter,:offset',
+
+ PLUGINS: /^\/plugins\/(.+)$/,
+
+ PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
+
+ // Matches /admin/plugins[,<offset>][/].
+ PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
+ PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
+ PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
+
+ QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
+
+ /**
+ * Support vestigial params from GWT UI.
+ *
+ * @see Issue 7673.
+ * @type {!RegExp}
+ */
+ QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
+
+ CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
+
+ // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
+ CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+ CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
+
+ // Matches
+ // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..][<patchNum|edit>].
+ // TODO(kaspern): Migrate completely to project based URLs, with backwards
+ // compatibility for change-only.
+ CHANGE: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+
+ // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>],edit
+ CHANGE_EDIT: /^\/c\/(.+)\/\+\/(\d+)(\/(\d+))?,edit\/?$/,
+
+ // Matches /c/<project>/+/<changeNum>/comment/<commentId>/
+ // Navigates to the diff view
+ // This route is needed to resolve to patchNum vs latestPatchNum used in the
+ // links generated in the emails.
+ COMMENT: /^\/c\/(.+)\/\+\/(\d+)\/comment\/(\w+)\/?$/,
+
+ // Matches
+ // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..]<patchNum|edit>/<path>.
+ // TODO(kaspern): Migrate completely to project based URLs, with backwards
+ // compatibility for change-only.
+ // eslint-disable-next-line max-len
+ DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))))\/?$/,
+
+ // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>]/<path>,edit[#lineNum]
+ DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(#\d+)?$/,
+
+ // Matches non-project-relative
+ // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
+ DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
+
+ // Matches diff routes using @\d+ to specify a file name (whether or not
+ // the project name is included).
+ // eslint-disable-next-line max-len
+ DIFF_LEGACY_LINENUM: /^\/c\/((.+)\/\+\/)?(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?\/(.+))?)@[ab]?\d+$/,
+
+ SETTINGS: /^\/settings\/?/,
+ SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
+
+ // Matches /c/<changeNum>/ /<URL tail>
+ // Catches improperly encoded URLs (context: Issue 7100)
+ IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/ \/(.+)$/,
+
+ PLUGIN_SCREEN: /^\/x\/([\w-]+)\/([\w-]+)\/?/,
+
+ DOCUMENTATION_SEARCH_FILTER: '/Documentation/q/filter::filter',
+ DOCUMENTATION_SEARCH: /^\/Documentation\/q\/(.*)$/,
+ DOCUMENTATION: /^\/Documentation(\/)?(.+)?/,
+};
+
+export const _testOnly_RoutePattern = RoutePattern;
+
+/**
+ * Pattern to recognize and parse the diff line locations as they appear in
+ * the hash of diff URLs. In this format, a number on its own indicates that
+ * line number in the revision of the diff. A number prefixed by either an 'a'
+ * or a 'b' indicates that line number of the base of the diff.
+ *
+ * @type {RegExp}
+ */
+const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
+
+/**
+ * Pattern to recognize '+' in url-encoded strings for replacement with ' '.
+ */
+const PLUS_PATTERN = /\+/g;
+
+/**
+ * Pattern to recognize leading '?' in window.location.search, for stripping.
+ */
+const QUESTION_PATTERN = /^\?*/;
+
+/**
+ * GWT UI would use @\d+ at the end of a path to indicate linenum.
+ */
+const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
+
+const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
+
+const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
+
+// Polymer makes `app` intrinsically defined on the window by virtue of the
+// custom element having the id "app", but it is made explicit here.
+// If you move this code to other place, please update comment about
+// gr-router and gr-app in the PolyGerritIndexHtml.soy file if needed
+const app = document.querySelector('#app');
+if (!app) {
+ console.info('No gr-app found (running tests)');
+}
+
+// Setup listeners outside of the router component initialization.
+(function () {
+ window.addEventListener('WebComponentsReady', () => {
+ appContext.reportingService.timeEnd('WebComponentsReady');
+ });
+})();
+
+export interface GrRouter {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+export interface PageContextWithQueryMap extends PageContext {
+ queryMap: Map<string, string> | URLSearchParams;
+}
+
+type QueryStringItem = [string, string]; // [key, value]
+
+type GenerateUrlLegacyChangeViewParameters = Omit<
+ GenerateUrlChangeViewParameters,
+ 'project'
+>;
+type GenerateUrlLegacyDiffViewParameters = Omit<
+ GenerateUrlDiffViewParameters,
+ 'project'
+>;
+
+interface PatchRangeParams {
+ patchNum?: PatchSetNum | null;
+ basePatchNum?: PatchSetNum | null;
+}
+
+@customElement('gr-router')
+export class GrRouter extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ readonly _app = app;
+
+ @property({type: Boolean})
+ _isRedirecting?: boolean;
+
+ // This variable is to differentiate between internal navigation (false)
+ // and for first navigation in app after loaded from server (true).
+ @property({type: Boolean})
+ _isInitialLoad = true;
+
+ private readonly reporting = appContext.reportingService;
+
+ constructor() {
+ super();
+ }
+
+ start() {
+ if (!this._app) {
+ return;
+ }
+ this._startRouter();
+ }
+
+ _setParams(params: AppElementParams | GenerateUrlParameters) {
+ this._appElement().params = params;
+ }
+
+ _appElement(): AppElement {
+ // In Polymer2 you have to reach through the shadow root of the app
+ // element. This obviously breaks encapsulation.
+ // TODO(brohlfs): Make this more elegant, e.g. by exposing app-element
+ // explicitly in app, or by delegating to it.
+
+ // It is expected that application has a GrAppElement(id=='app-element')
+ // at the document level or inside the shadow root of the GrApp (id='app')
+ // element.
+ return (document.getElementById('app-element') ||
+ document
+ .getElementById('app')!
+ .shadowRoot!.getElementById('app-element')!) as AppElement;
+ }
+
+ _redirect(url: string) {
+ this._isRedirecting = true;
+ page.redirect(url);
+ }
+
+ _generateUrl(params: GenerateUrlParameters) {
+ const base = getBaseUrl();
+ let url = '';
+
+ if (params.view === GerritView.SEARCH) {
+ url = this._generateSearchUrl(params);
+ } else if (params.view === GerritView.CHANGE) {
+ url = this._generateChangeUrl(params);
+ } else if (params.view === GerritView.DASHBOARD) {
+ url = this._generateDashboardUrl(params);
+ } else if (
+ params.view === GerritView.DIFF ||
+ params.view === GerritView.EDIT
+ ) {
+ url = this._generateDiffOrEditUrl(params);
+ } else if (params.view === GerritView.GROUP) {
+ url = this._generateGroupUrl(params);
+ } else if (params.view === GerritView.REPO) {
+ url = this._generateRepoUrl(params);
+ } else if (params.view === GerritView.ROOT) {
+ url = '/';
+ } else if (params.view === GerritView.SETTINGS) {
+ url = this._generateSettingsUrl();
+ } else {
+ assertNever(params, "Can't generate");
+ }
+
+ return base + url;
+ }
+
+ _generateWeblinks(
+ params: GenerateWebLinksParameters
+ ): GeneratedWebLink[] | GeneratedWebLink {
+ switch (params.type) {
+ case WeblinkType.FILE:
+ return this._getFileWebLinks(params);
+ case WeblinkType.CHANGE:
+ return this._getChangeWeblinks(params);
+ case WeblinkType.PATCHSET:
+ return this._getPatchSetWeblink(params);
+ default:
+ console.warn(`Unsupported weblink ${(params as any).type}!`);
+ // TODO(TS): use assertNever(params.type)
+ return [];
+ }
+ }
+
+ _getPatchSetWeblink(
+ params: GenerateWebLinksPatchsetParameters
+ ): GeneratedWebLink {
+ const {commit, options} = params;
+ const {weblinks, config} = options || {};
+ const name = commit && commit.slice(0, 7);
+ const weblink = this._getBrowseCommitWeblink(weblinks, config);
+ if (!weblink || !weblink.url) {
+ return {name};
+ } else {
+ return {name, url: weblink.url};
+ }
+ }
+
+ _firstCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
+ // This is an ordered allowed list of web link types that provide direct
+ // links to the commit in the url property.
+ const codeBrowserLinks = ['gitiles', 'browse', 'gitweb'];
+ for (let i = 0; i < codeBrowserLinks.length; i++) {
+ const weblink = weblinks.find(
+ weblink => weblink.name === codeBrowserLinks[i]
+ );
+ if (weblink) {
+ return weblink;
+ }
+ }
+ return null;
+ }
+
+ _getBrowseCommitWeblink(weblinks?: GeneratedWebLink[], config?: ServerInfo) {
+ if (!weblinks) {
+ return null;
+ }
+ let weblink;
+ // Use primary weblink if configured and exists.
+ if (config?.gerrit?.primary_weblink_name) {
+ const primaryWeblinkName = config.gerrit.primary_weblink_name;
+ weblink = weblinks.find(weblink => weblink.name === primaryWeblinkName);
+ }
+ if (!weblink) {
+ weblink = this._firstCodeBrowserWeblink(weblinks);
+ }
+ if (!weblink) {
+ return null;
+ }
+ return weblink;
+ }
+
+ _getChangeWeblinks(
+ params: GenerateWebLinksChangeParameters
+ ): GeneratedWebLink[] {
+ const weblinks = params.options?.weblinks;
+ const config = params.options?.config;
+ if (!weblinks || !weblinks.length) return [];
+ const commitWeblink = this._getBrowseCommitWeblink(weblinks, config);
+ return weblinks.filter(
+ weblink =>
+ !commitWeblink ||
+ !commitWeblink.name ||
+ weblink.name !== commitWeblink.name
+ );
+ }
+
+ _getFileWebLinks(params: GenerateWebLinksFileParameters): GeneratedWebLink[] {
+ return params.options?.weblinks || [];
+ }
+
+ _generateSearchUrl(params: GenerateUrlSearchViewParameters) {
+ let offsetExpr = '';
+ if (params.offset && params.offset > 0) {
+ offsetExpr = `,${params.offset}`;
+ }
+
+ if (params.query) {
+ return '/q/' + encodeURL(params.query, true) + offsetExpr;
+ }
+
+ const operators: string[] = [];
+ if (params.owner) {
+ operators.push('owner:' + encodeURL(params.owner, false));
+ }
+ if (params.project) {
+ operators.push('project:' + encodeURL(params.project, false));
+ }
+ if (params.branch) {
+ operators.push('branch:' + encodeURL(params.branch, false));
+ }
+ if (params.topic) {
+ operators.push('topic:"' + encodeURL(params.topic, false) + '"');
+ }
+ if (params.hashtag) {
+ operators.push(
+ 'hashtag:"' + encodeURL(params.hashtag.toLowerCase(), false) + '"'
+ );
+ }
+ if (params.statuses) {
+ if (params.statuses.length === 1) {
+ operators.push('status:' + encodeURL(params.statuses[0], false));
+ } else if (params.statuses.length > 1) {
+ operators.push(
+ '(' +
+ params.statuses
+ .map(s => `status:${encodeURL(s, false)}`)
+ .join(' OR ') +
+ ')'
+ );
+ }
+ }
+
+ return '/q/' + operators.join('+') + offsetExpr;
+ }
+
+ _generateChangeUrl(params: GenerateUrlChangeViewParameters) {
+ let range = this._getPatchRangeExpression(params);
+ if (range.length) {
+ range = '/' + range;
+ }
+ let suffix = `${range}`;
+ if (params.querystring) {
+ suffix += '?' + params.querystring;
+ } else if (params.edit) {
+ suffix += ',edit';
+ }
+ if (params.messageHash) {
+ suffix += params.messageHash;
+ }
+ if (params.project) {
+ const encodedProject = encodeURL(params.project, true);
+ return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
+ } else {
+ return `/c/${params.changeNum}${suffix}`;
+ }
+ }
+
+ _generateDashboardUrl(params: GenerateUrlDashboardViewParameters) {
+ const repoName = params.repo || params.project || undefined;
+ if (params.sections) {
+ // Custom dashboard.
+ const queryParams = this._sectionsToEncodedParams(
+ params.sections,
+ repoName
+ );
+ if (params.title) {
+ queryParams.push('title=' + encodeURIComponent(params.title));
+ }
+ const user = params.user ? params.user : '';
+ return `/dashboard/${user}?${queryParams.join('&')}`;
+ } else if (repoName) {
+ // Project dashboard.
+ const encodedRepo = encodeURL(repoName, true);
+ return `/p/${encodedRepo}/+/dashboard/${params.dashboard}`;
+ } else {
+ // User dashboard.
+ return `/dashboard/${params.user || 'self'}`;
+ }
+ }
+
+ _sectionsToEncodedParams(sections: DashboardSection[], repoName?: RepoName) {
+ return sections.map(section => {
+ // If there is a repo name provided, make sure to substitute it into the
+ // ${repo} (or legacy ${project}) query tokens.
+ const query = repoName
+ ? section.query.replace(REPO_TOKEN_PATTERN, repoName)
+ : section.query;
+ return encodeURIComponent(section.name) + '=' + encodeURIComponent(query);
+ });
+ }
+
+ _generateDiffOrEditUrl(
+ params: GenerateUrlDiffViewParameters | GenerateUrlEditViewParameters
+ ) {
+ let range = this._getPatchRangeExpression(params);
+ if (range.length) {
+ range = '/' + range;
+ }
+
+ let suffix = `${range}/${encodeURL(params.path || '', true)}`;
+
+ if (params.view === GerritView.EDIT) {
+ suffix += ',edit';
+ }
+
+ if (params.lineNum) {
+ suffix += '#';
+ if (isGenerateUrlDiffViewParameters(params) && params.leftSide) {
+ suffix += 'b';
+ }
+ suffix += params.lineNum;
+ }
+
+ if (isGenerateUrlDiffViewParameters(params) && params.commentId) {
+ suffix = `/comment/${params.commentId}` + suffix;
+ }
+
+ if (params.project) {
+ const encodedProject = encodeURL(params.project, true);
+ return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
+ } else {
+ return `/c/${params.changeNum}${suffix}`;
+ }
+ }
+
+ _generateGroupUrl(params: GenerateUrlGroupViewParameters) {
+ let url = `/admin/groups/${encodeURL(`${params.groupId}`, true)}`;
+ if (params.detail === GroupDetailView.MEMBERS) {
+ url += ',members';
+ } else if (params.detail === GroupDetailView.LOG) {
+ url += ',audit-log';
+ }
+ return url;
+ }
+
+ _generateRepoUrl(params: GenerateUrlRepoViewParameters) {
+ let url = `/admin/repos/${encodeURL(`${params.repoName}`, true)}`;
+ if (params.detail === RepoDetailView.ACCESS) {
+ url += ',access';
+ } else if (params.detail === RepoDetailView.BRANCHES) {
+ url += ',branches';
+ } else if (params.detail === RepoDetailView.TAGS) {
+ url += ',tags';
+ } else if (params.detail === RepoDetailView.COMMANDS) {
+ url += ',commands';
+ } else if (params.detail === RepoDetailView.DASHBOARDS) {
+ url += ',dashboards';
+ }
+ return url;
+ }
+
+ _generateSettingsUrl() {
+ return '/settings';
+ }
+
+ /**
+ * Given an object of parameters, potentially including a `patchNum` or a
+ * `basePatchNum` or both, return a string representation of that range. If
+ * no range is indicated in the params, the empty string is returned.
+ */
+ _getPatchRangeExpression(params: PatchRangeParams) {
+ let range = '';
+ if (params.patchNum) {
+ range = `${params.patchNum}`;
+ }
+ if (params.basePatchNum) {
+ range = `${params.basePatchNum}..${range}`;
+ }
+ return range;
+ }
+
+ /**
+ * Given a set of params without a project, gets the project from the rest
+ * API project lookup and then sets the app params.
+ */
+ _normalizeLegacyRouteParams(
+ params: Readonly<
+ | GenerateUrlLegacyChangeViewParameters
+ | GenerateUrlLegacyDiffViewParameters
+ >
+ ) {
+ if (!params.changeNum) {
+ return Promise.resolve();
+ }
+
+ return this.$.restAPI
+ .getFromProjectLookup(params.changeNum)
+ .then(project => {
+ // Show a 404 and terminate if the lookup request failed. Attempting
+ // to redirect after failing to get the project loops infinitely.
+ if (!project) {
+ this._show404();
+ return;
+ }
+ const updatedParams:
+ | GenerateUrlChangeViewParameters
+ | GenerateUrlDiffViewParameters = {...params, project};
+ this._normalizePatchRangeParams(updatedParams);
+ this._redirect(this._generateUrl(updatedParams));
+ });
+ }
+
+ /**
+ * Normalizes the params object, and determines if the URL needs to be
+ * modified to fit the proper schema.
+ *
+ */
+ _normalizePatchRangeParams(params: PatchRangeParams) {
+ if (params.basePatchNum === null || params.basePatchNum === undefined) {
+ return false;
+ }
+ const hasPatchNum =
+ params.patchNum !== null && params.patchNum !== undefined;
+ let needsRedirect = false;
+
+ // Diffing a patch against itself is invalid, so if the base and revision
+ // patches are equal clear the base.
+ if (
+ params.patchNum &&
+ patchNumEquals(params.basePatchNum, params.patchNum)
+ ) {
+ needsRedirect = true;
+ params.basePatchNum = null;
+ } else if (!hasPatchNum) {
+ // Regexes set basePatchNum instead of patchNum when only one is
+ // specified. Redirect is not needed in this case.
+ params.patchNum = params.basePatchNum;
+ params.basePatchNum = null;
+ }
+ return needsRedirect;
+ }
+
+ /**
+ * Redirect the user to login using the given return-URL for redirection
+ * after authentication success.
+ */
+ _redirectToLogin(returnUrl: string) {
+ const basePath = getBaseUrl() || '';
+ page('/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
+ }
+
+ /**
+ * Hashes parsed by page.js exclude "inner" hashes, so a URL like "/a#b#c"
+ * is parsed to have a hash of "b" rather than "b#c". Instead, this method
+ * parses hashes correctly. Will return an empty string if there is no hash.
+ *
+ * @return Everything after the first '#' ("a#b#c" -> "b#c").
+ */
+ _getHashFromCanonicalPath(canonicalPath: string) {
+ return canonicalPath.split('#').slice(1).join('#');
+ }
+
+ _parseLineAddress(hash: string) {
+ const match = hash.match(LINE_ADDRESS_PATTERN);
+ if (!match) {
+ return null;
+ }
+ return {
+ leftSide: !!match[1],
+ lineNum: Number(match[2]),
+ };
+ }
+
+ /**
+ * Check to see if the user is logged in and return a promise that only
+ * resolves if the user is logged in. If the user us not logged in, the
+ * promise is rejected and the page is redirected to the login flow.
+ *
+ * @return A promise yielding the original route data
+ * (if it resolves).
+ */
+ _redirectIfNotLoggedIn(data: PageContext) {
+ return this.$.restAPI.getLoggedIn().then(loggedIn => {
+ if (loggedIn) {
+ return Promise.resolve();
+ } else {
+ this._redirectToLogin(data.canonicalPath);
+ return Promise.reject(new Error());
+ }
+ });
+ }
+
+ /** Page.js middleware that warms the REST API's logged-in cache line. */
+ _loadUserMiddleware(_: PageContext, next: PageNextCallback) {
+ this.$.restAPI.getLoggedIn().then(() => {
+ next();
+ });
+ }
+
+ /** Page.js middleware that try parse the querystring into queryMap. */
+ _queryStringMiddleware(ctx: PageContext, next: PageNextCallback) {
+ let queryMap: Map<string, string> | URLSearchParams = new Map();
+ if (ctx.querystring) {
+ // https://caniuse.com/#search=URLSearchParams
+ if (window.URLSearchParams) {
+ queryMap = new URLSearchParams(ctx.querystring);
+ } else {
+ queryMap = new Map(this._parseQueryString(ctx.querystring));
+ }
+ }
+ (ctx as PageContextWithQueryMap).queryMap = queryMap;
+ next();
+ }
+
+ /**
+ * Map a route to a method on the router.
+ *
+ * @param pattern The page.js pattern for the route.
+ * @param handlerName The method name for the handler. If the
+ * route is matched, the handler will be executed with `this` referring
+ * to the component. Its return value will be discarded so that it does
+ * not interfere with page.js.
+ * @param authRedirect If true, then auth is checked before
+ * executing the handler. If the user is not logged in, it will redirect
+ * to the login flow and the handler will not be executed. The login
+ * redirect specifies the matched URL to be used after successfull auth.
+ */
+ _mapRoute(
+ pattern: string | RegExp,
+ handlerName: keyof GrRouter,
+ authRedirect?: boolean
+ ) {
+ if (!this[handlerName]) {
+ console.error('Attempted to map route to unknown method: ', handlerName);
+ return;
+ }
+ page(
+ pattern,
+ (ctx, next) => this._loadUserMiddleware(ctx, next),
+ (ctx, next) => this._queryStringMiddleware(ctx, next),
+ data => {
+ this.reporting.locationChanged(handlerName);
+ const promise = authRedirect
+ ? this._redirectIfNotLoggedIn(data)
+ : Promise.resolve();
+ promise.then(() => {
+ this[handlerName](data as PageContextWithQueryMap);
+ });
+ }
+ );
+ }
+
+ _startRouter() {
+ const base = getBaseUrl();
+ if (base) {
+ page.base(base);
+ }
+
+ GerritNav.setup(
+ (url, redirect?) => {
+ if (redirect) {
+ page.redirect(url);
+ } else {
+ page.show(url);
+ }
+ },
+ params => this._generateUrl(params),
+ params => this._generateWeblinks(params),
+ x => x
+ );
+
+ page.exit('*', (_, next) => {
+ if (!this._isRedirecting) {
+ this.reporting.beforeLocationChanged();
+ }
+ this._isRedirecting = false;
+ this._isInitialLoad = false;
+ next();
+ });
+
+ // Middleware
+ page((ctx, next) => {
+ document.body.scrollTop = 0;
+
+ if (ctx.hash.match(RoutePattern.PLUGIN_SCREEN)) {
+ // Redirect all urls using hash #/x/plugin/screen to /x/plugin/screen
+ // This is needed to allow plugins to add basic #/x/ screen links to
+ // any location.
+ this._redirect(ctx.hash);
+ return;
+ }
+
+ // Fire asynchronously so that the URL is changed by the time the event
+ // is processed.
+ this.async(() => {
+ const detail: LocationChangeEventDetail = {
+ hash: window.location.hash,
+ pathname: window.location.pathname,
+ };
+ this.dispatchEvent(
+ new CustomEvent('location-change', {
+ detail,
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }, 1);
+ next();
+ });
+
+ this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
+
+ this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
+
+ this._mapRoute(
+ RoutePattern.CUSTOM_DASHBOARD,
+ '_handleCustomDashboardRoute'
+ );
+
+ this._mapRoute(
+ RoutePattern.PROJECT_DASHBOARD,
+ '_handleProjectDashboardRoute'
+ );
+
+ this._mapRoute(
+ RoutePattern.LEGACY_PROJECT_DASHBOARD,
+ '_handleLegacyProjectDashboardRoute'
+ );
+
+ this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
+
+ this._mapRoute(
+ RoutePattern.GROUP_AUDIT_LOG,
+ '_handleGroupAuditLogRoute',
+ true
+ );
+
+ this._mapRoute(
+ RoutePattern.GROUP_MEMBERS,
+ '_handleGroupMembersRoute',
+ true
+ );
+
+ this._mapRoute(
+ RoutePattern.GROUP_LIST_OFFSET,
+ '_handleGroupListOffsetRoute',
+ true
+ );
+
+ this._mapRoute(
+ RoutePattern.GROUP_LIST_FILTER_OFFSET,
+ '_handleGroupListFilterOffsetRoute',
+ true
+ );
+
+ this._mapRoute(
+ RoutePattern.GROUP_LIST_FILTER,
+ '_handleGroupListFilterRoute',
+ true
+ );
+
+ this._mapRoute(
+ RoutePattern.GROUP_SELF,
+ '_handleGroupSelfRedirectRoute',
+ true
+ );
+
+ this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
+
+ this._mapRoute(RoutePattern.PROJECT_OLD, '_handleProjectsOldRoute');
+
+ this._mapRoute(
+ RoutePattern.REPO_COMMANDS,
+ '_handleRepoCommandsRoute',
+ true
+ );
+
+ this._mapRoute(RoutePattern.REPO_ACCESS, '_handleRepoAccessRoute');
+
+ this._mapRoute(RoutePattern.REPO_DASHBOARDS, '_handleRepoDashboardsRoute');
+
+ this._mapRoute(
+ RoutePattern.BRANCH_LIST_OFFSET,
+ '_handleBranchListOffsetRoute'
+ );
+
+ this._mapRoute(
+ RoutePattern.BRANCH_LIST_FILTER_OFFSET,
+ '_handleBranchListFilterOffsetRoute'
+ );
+
+ this._mapRoute(
+ RoutePattern.BRANCH_LIST_FILTER,
+ '_handleBranchListFilterRoute'
+ );
+
+ this._mapRoute(RoutePattern.TAG_LIST_OFFSET, '_handleTagListOffsetRoute');
+
+ this._mapRoute(
+ RoutePattern.TAG_LIST_FILTER_OFFSET,
+ '_handleTagListFilterOffsetRoute'
+ );
+
+ this._mapRoute(RoutePattern.TAG_LIST_FILTER, '_handleTagListFilterRoute');
+
+ this._mapRoute(
+ RoutePattern.LEGACY_CREATE_GROUP,
+ '_handleCreateGroupRoute',
+ true
+ );
+
+ this._mapRoute(
+ RoutePattern.LEGACY_CREATE_PROJECT,
+ '_handleCreateProjectRoute',
+ true
+ );
+
+ this._mapRoute(RoutePattern.REPO_LIST_OFFSET, '_handleRepoListOffsetRoute');
+
+ this._mapRoute(
+ RoutePattern.REPO_LIST_FILTER_OFFSET,
+ '_handleRepoListFilterOffsetRoute'
+ );
+
+ this._mapRoute(RoutePattern.REPO_LIST_FILTER, '_handleRepoListFilterRoute');
+
+ this._mapRoute(RoutePattern.REPO, '_handleRepoRoute');
+
+ this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
+
+ this._mapRoute(
+ RoutePattern.PLUGIN_LIST_OFFSET,
+ '_handlePluginListOffsetRoute',
+ true
+ );
+
+ this._mapRoute(
+ RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
+ '_handlePluginListFilterOffsetRoute',
+ true
+ );
+
+ this._mapRoute(
+ RoutePattern.PLUGIN_LIST_FILTER,
+ '_handlePluginListFilterRoute',
+ true
+ );
+
+ this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
+
+ this._mapRoute(
+ RoutePattern.QUERY_LEGACY_SUFFIX,
+ '_handleQueryLegacySuffixRoute'
+ );
+
+ this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
+
+ this._mapRoute(RoutePattern.CHANGE_ID_QUERY, '_handleChangeIdQueryRoute');
+
+ this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
+
+ this._mapRoute(
+ RoutePattern.CHANGE_NUMBER_LEGACY,
+ '_handleChangeNumberLegacyRoute'
+ );
+
+ this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
+
+ this._mapRoute(RoutePattern.CHANGE_EDIT, '_handleChangeEditRoute', true);
+
+ this._mapRoute(RoutePattern.COMMENT, '_handleCommentRoute');
+
+ this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
+
+ this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
+
+ this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
+
+ this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
+
+ this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
+
+ this._mapRoute(
+ RoutePattern.NEW_AGREEMENTS,
+ '_handleNewAgreementsRoute',
+ true
+ );
+
+ this._mapRoute(
+ RoutePattern.SETTINGS_LEGACY,
+ '_handleSettingsLegacyRoute',
+ true
+ );
+
+ this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
+
+ this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
+
+ this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
+
+ this._mapRoute(
+ RoutePattern.IMPROPERLY_ENCODED_PLUS,
+ '_handleImproperlyEncodedPlusRoute'
+ );
+
+ this._mapRoute(RoutePattern.PLUGIN_SCREEN, '_handlePluginScreen');
+
+ this._mapRoute(
+ RoutePattern.DOCUMENTATION_SEARCH_FILTER,
+ '_handleDocumentationSearchRoute'
+ );
+
+ // redirects /Documentation/q/* to /Documentation/q/filter:*
+ this._mapRoute(
+ RoutePattern.DOCUMENTATION_SEARCH,
+ '_handleDocumentationSearchRedirectRoute'
+ );
+
+ // Makes sure /Documentation/* links work (doin't return 404)
+ this._mapRoute(
+ RoutePattern.DOCUMENTATION,
+ '_handleDocumentationRedirectRoute'
+ );
+
+ // Note: this route should appear last so it only catches URLs unmatched
+ // by other patterns.
+ this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
+
+ page.start();
+ }
+
+ /**
+ * @return if handling the route involves asynchrony, then a
+ * promise is returned. Otherwise, synchronous handling returns null.
+ */
+ _handleRootRoute(data: PageContextWithQueryMap) {
+ if (data.querystring.match(/^closeAfterLogin/)) {
+ // Close child window on redirect after login.
+ window.close();
+ return null;
+ }
+ let hash = this._getHashFromCanonicalPath(data.canonicalPath);
+ // For backward compatibility with GWT links.
+ if (hash) {
+ // In certain login flows the server may redirect to a hash without
+ // a leading slash, which page.js doesn't handle correctly.
+ if (hash[0] !== '/') {
+ hash = '/' + hash;
+ }
+ if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
+ // Path decodes all '+' to ' ' -- this breaks project-based URLs.
+ // See Issue 6888.
+ hash = hash.replace('/ /', '/+/');
+ }
+ const base = getBaseUrl();
+ let newUrl = base + hash;
+ if (hash.startsWith('/VE/')) {
+ newUrl = base + '/settings' + hash;
+ }
+ this._redirect(newUrl);
+ return null;
+ }
+ return this.$.restAPI.getLoggedIn().then(loggedIn => {
+ if (loggedIn) {
+ this._redirect('/dashboard/self');
+ } else {
+ this._redirect('/q/status:open+-is:wip');
+ }
+ });
+ }
+
+ /**
+ * Decode an application/x-www-form-urlencoded string.
+ *
+ * @param qs The application/x-www-form-urlencoded string.
+ * @return The decoded string.
+ */
+ _decodeQueryString(qs: string) {
+ return decodeURIComponent(qs.replace(PLUS_PATTERN, ' '));
+ }
+
+ /**
+ * Parse a query string (e.g. window.location.search) into an array of
+ * name/value pairs.
+ *
+ * @param qs The application/x-www-form-urlencoded query string.
+ * @return An array of name/value pairs, where each
+ * element is a 2-element array.
+ */
+ _parseQueryString(qs: string): Array<QueryStringItem> {
+ qs = qs.replace(QUESTION_PATTERN, '');
+ if (!qs) {
+ return [];
+ }
+ const params: Array<[string, string]> = [];
+ qs.split('&').forEach(param => {
+ const idx = param.indexOf('=');
+ let name;
+ let value;
+ if (idx < 0) {
+ name = this._decodeQueryString(param);
+ value = '';
+ } else {
+ name = this._decodeQueryString(param.substring(0, idx));
+ value = this._decodeQueryString(param.substring(idx + 1));
+ }
+ if (name) {
+ params.push([name, value]);
+ }
+ });
+ return params;
+ }
+
+ /**
+ * Handle dashboard routes. These may be user, or project dashboards.
+ */
+ _handleDashboardRoute(data: PageContextWithQueryMap) {
+ // User dashboard. We require viewing user to be logged in, else we
+ // redirect to login for self dashboard or simple owner search for
+ // other user dashboard.
+ return this.$.restAPI.getLoggedIn().then(loggedIn => {
+ if (!loggedIn) {
+ if (data.params[0].toLowerCase() === 'self') {
+ this._redirectToLogin(data.canonicalPath);
+ } else {
+ this._redirect('/q/owner:' + encodeURIComponent(data.params[0]));
+ }
+ } else {
+ this._setParams({
+ view: GerritView.DASHBOARD,
+ user: data.params[0],
+ });
+ }
+ });
+ }
+
+ /**
+ * Handle custom dashboard routes.
+ *
+ * @param qs Optional query string associated with the route.
+ * If not given, window.location.search is used. (Used by tests).
+ */
+ _handleCustomDashboardRoute(
+ _: PageContextWithQueryMap,
+ qs: string = window.location.search
+ ) {
+ const queryParams = this._parseQueryString(qs);
+ let title = 'Custom Dashboard';
+ const titleParam = queryParams.find(
+ elem => elem[0].toLowerCase() === 'title'
+ );
+ if (titleParam) {
+ title = titleParam[1];
+ }
+ // Dashboards support a foreach param which adds a base query to any
+ // additional query.
+ const forEachParam = queryParams.find(
+ elem => elem[0].toLowerCase() === 'foreach'
+ );
+ let forEachQuery: string | null = null;
+ if (forEachParam) {
+ forEachQuery = forEachParam[1];
+ }
+ const sectionParams = queryParams.filter(
+ elem =>
+ elem[0] &&
+ elem[1] &&
+ elem[0].toLowerCase() !== 'title' &&
+ elem[0].toLowerCase() !== 'foreach'
+ );
+ const sections = sectionParams.map(elem => {
+ const query = forEachQuery ? `${forEachQuery} ${elem[1]}` : elem[1];
+ return {
+ name: elem[0],
+ query,
+ };
+ });
+
+ if (sections.length > 0) {
+ // Custom dashboard view.
+ this._setParams({
+ view: GerritView.DASHBOARD,
+ user: 'self',
+ sections,
+ title,
+ });
+ return Promise.resolve();
+ }
+
+ // Redirect /dashboard/ -> /dashboard/self.
+ this._redirect('/dashboard/self');
+ return Promise.resolve();
+ }
+
+ _handleProjectDashboardRoute(data: PageContextWithQueryMap) {
+ const project = data.params[0] as RepoName;
+ this._setParams({
+ view: GerritView.DASHBOARD,
+ project,
+ dashboard: decodeURIComponent(data.params[1]) as DashboardId,
+ });
+ 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]));
+ }
+
+ _handleGroupSelfRedirectRoute(_: PageContextWithQueryMap) {
+ this._redirect('/settings/#Groups');
+ }
+
+ _handleGroupRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.GROUP,
+ groupId: data.params[0] as GroupId,
+ });
+ }
+
+ _handleGroupAuditLogRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.GROUP,
+ detail: GroupDetailView.LOG,
+ groupId: data.params[0] as GroupId,
+ });
+ }
+
+ _handleGroupMembersRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.GROUP,
+ detail: GroupDetailView.MEMBERS,
+ groupId: data.params[0] as GroupId,
+ });
+ }
+
+ _handleGroupListOffsetRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.ADMIN,
+ adminView: 'gr-admin-group-list',
+ offset: data.params[1] || 0,
+ filter: null,
+ openCreateModal: data.hash === 'create',
+ });
+ }
+
+ _handleGroupListFilterOffsetRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.ADMIN,
+ adminView: 'gr-admin-group-list',
+ offset: data.params['offset'],
+ filter: data.params['filter'],
+ });
+ }
+
+ _handleGroupListFilterRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.ADMIN,
+ adminView: 'gr-admin-group-list',
+ filter: data.params['filter'] || null,
+ });
+ }
+
+ _handleProjectsOldRoute(data: PageContextWithQueryMap) {
+ let params = '';
+ if (data.params[1]) {
+ params = encodeURIComponent(data.params[1]);
+ if (data.params[1].includes(',')) {
+ params = encodeURIComponent(data.params[1]).replace('%2C', ',');
+ }
+ }
+
+ this._redirect(`/admin/repos/${params}`);
+ }
+
+ _handleRepoCommandsRoute(data: PageContextWithQueryMap) {
+ const repo = data.params[0] as RepoName;
+ this._setParams({
+ view: GerritView.REPO,
+ detail: RepoDetailView.COMMANDS,
+ repo,
+ });
+ this.reporting.setRepoName(repo);
+ }
+
+ _handleRepoAccessRoute(data: PageContextWithQueryMap) {
+ const repo = data.params[0] as RepoName;
+ this._setParams({
+ view: GerritView.REPO,
+ detail: RepoDetailView.ACCESS,
+ repo,
+ });
+ this.reporting.setRepoName(repo);
+ }
+
+ _handleRepoDashboardsRoute(data: PageContextWithQueryMap) {
+ const repo = data.params[0] as RepoName;
+ this._setParams({
+ view: GerritView.REPO,
+ detail: RepoDetailView.DASHBOARDS,
+ repo,
+ });
+ this.reporting.setRepoName(repo);
+ }
+
+ _handleBranchListOffsetRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.REPO,
+ detail: RepoDetailView.BRANCHES,
+ repo: data.params[0] as RepoName,
+ offset: data.params[2] || 0,
+ filter: null,
+ });
+ }
+
+ _handleBranchListFilterOffsetRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.REPO,
+ detail: RepoDetailView.BRANCHES,
+ repo: data.params['repo'] as RepoName,
+ offset: data.params['offset'],
+ filter: data.params['filter'],
+ });
+ }
+
+ _handleBranchListFilterRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.REPO,
+ detail: RepoDetailView.BRANCHES,
+ repo: data.params['repo'] as RepoName,
+ filter: data.params['filter'] || null,
+ });
+ }
+
+ _handleTagListOffsetRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.REPO,
+ detail: RepoDetailView.TAGS,
+ repo: data.params[0] as RepoName,
+ offset: data.params[2] || 0,
+ filter: null,
+ });
+ }
+
+ _handleTagListFilterOffsetRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.REPO,
+ detail: RepoDetailView.TAGS,
+ repo: data.params['repo'] as RepoName,
+ offset: data.params['offset'],
+ filter: data.params['filter'],
+ });
+ }
+
+ _handleTagListFilterRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.REPO,
+ detail: RepoDetailView.TAGS,
+ repo: data.params['repo'] as RepoName,
+ filter: data.params['filter'] || null,
+ });
+ }
+
+ _handleRepoListOffsetRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.ADMIN,
+ adminView: 'gr-repo-list',
+ offset: data.params[1] || 0,
+ filter: null,
+ openCreateModal: data.hash === 'create',
+ });
+ }
+
+ _handleRepoListFilterOffsetRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.ADMIN,
+ adminView: 'gr-repo-list',
+ offset: data.params['offset'],
+ filter: data.params['filter'],
+ });
+ }
+
+ _handleRepoListFilterRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.ADMIN,
+ adminView: 'gr-repo-list',
+ filter: data.params['filter'] || null,
+ });
+ }
+
+ _handleCreateProjectRoute(_: PageContextWithQueryMap) {
+ // Redirects the legacy route to the new route, which displays the project
+ // list with a hash 'create'.
+ this._redirect('/admin/repos#create');
+ }
+
+ _handleCreateGroupRoute(_: PageContextWithQueryMap) {
+ // Redirects the legacy route to the new route, which displays the group
+ // list with a hash 'create'.
+ this._redirect('/admin/groups#create');
+ }
+
+ _handleRepoRoute(data: PageContextWithQueryMap) {
+ const repo = data.params[0] as RepoName;
+ this._setParams({
+ view: GerritView.REPO,
+ repo,
+ });
+ this.reporting.setRepoName(repo);
+ }
+
+ _handlePluginListOffsetRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.ADMIN,
+ adminView: 'gr-plugin-list',
+ offset: data.params[1] || 0,
+ filter: null,
+ });
+ }
+
+ _handlePluginListFilterOffsetRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.ADMIN,
+ adminView: 'gr-plugin-list',
+ offset: data.params['offset'],
+ filter: data.params['filter'],
+ });
+ }
+
+ _handlePluginListFilterRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.ADMIN,
+ adminView: 'gr-plugin-list',
+ filter: data.params['filter'] || null,
+ });
+ }
+
+ _handlePluginListRoute(_: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.ADMIN,
+ adminView: 'gr-plugin-list',
+ });
+ }
+
+ _handleQueryRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.SEARCH,
+ query: data.params[0],
+ offset: data.params[2],
+ });
+ }
+
+ _handleChangeIdQueryRoute(data: PageContextWithQueryMap) {
+ // TODO(pcc): This will need to indicate that this was a change ID query if
+ // standard queries gain the ability to search places like commit messages
+ // for change IDs.
+ this._setParams({
+ view: GerritNav.View.SEARCH,
+ query: data.params[0],
+ });
+ }
+
+ _handleQueryLegacySuffixRoute(ctx: PageContextWithQueryMap) {
+ this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
+ }
+
+ _handleChangeNumberLegacyRoute(ctx: PageContextWithQueryMap) {
+ this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
+ }
+
+ _handleChangeRoute(ctx: PageContextWithQueryMap) {
+ // Parameter order is based on the regex group number matched.
+ const params: GenerateUrlChangeViewParameters = {
+ project: ctx.params[0] as RepoName,
+ // TODO(TS): remove as unknown
+ changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+ basePatchNum: convertToPatchSetNum(ctx.params[4]),
+ patchNum: convertToPatchSetNum(ctx.params[6]),
+ view: GerritView.CHANGE,
+ queryMap: ctx.queryMap,
+ };
+
+ this.reporting.setRepoName(params.project);
+ this._redirectOrNavigate(params);
+ }
+
+ _handleCommentRoute(ctx: PageContextWithQueryMap) {
+ const params: GenerateUrlDiffViewParameters = {
+ project: ctx.params[0] as RepoName,
+ changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+ commentId: ctx.params[2] as UrlEncodedCommentId,
+ view: GerritView.DIFF,
+ commentLink: true,
+ };
+ this.reporting.setRepoName(params.project);
+ this._redirectOrNavigate(params);
+ }
+
+ _handleDiffRoute(ctx: PageContextWithQueryMap) {
+ // Parameter order is based on the regex group number matched.
+ const params: GenerateUrlDiffViewParameters = {
+ project: ctx.params[0] as RepoName,
+ changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+ basePatchNum: convertToPatchSetNum(ctx.params[4]),
+ patchNum: convertToPatchSetNum(ctx.params[6]),
+ path: ctx.params[8],
+ view: GerritView.DIFF,
+ };
+ const address = this._parseLineAddress(ctx.hash);
+ if (address) {
+ params.leftSide = address.leftSide;
+ params.lineNum = address.lineNum;
+ }
+ this.reporting.setRepoName(params.project);
+ this._redirectOrNavigate(params);
+ }
+
+ _handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
+ // Parameter order is based on the regex group number matched.
+ const params: GenerateUrlLegacyChangeViewParameters = {
+ changeNum: (ctx.params[0] as unknown) as NumericChangeId,
+ basePatchNum: convertToPatchSetNum(ctx.params[3]),
+ patchNum: convertToPatchSetNum(ctx.params[5]),
+ view: GerritView.CHANGE,
+ querystring: ctx.querystring,
+ };
+
+ this._normalizeLegacyRouteParams(params);
+ }
+
+ _handleLegacyLinenum(ctx: PageContextWithQueryMap) {
+ this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
+ }
+
+ _handleDiffLegacyRoute(ctx: PageContextWithQueryMap) {
+ // Parameter order is based on the regex group number matched.
+ const params: GenerateUrlLegacyDiffViewParameters = {
+ // TODO(TS): remove "as unknown"
+ changeNum: (ctx.params[0] as unknown) as NumericChangeId,
+ basePatchNum: convertToPatchSetNum(ctx.params[2]),
+ patchNum: convertToPatchSetNum(ctx.params[4]),
+ path: ctx.params[5],
+ view: GerritView.DIFF,
+ };
+
+ const address = this._parseLineAddress(ctx.hash);
+ if (address) {
+ params.leftSide = address.leftSide;
+ params.lineNum = address.lineNum;
+ }
+
+ this._normalizeLegacyRouteParams(params);
+ }
+
+ _handleDiffEditRoute(ctx: PageContextWithQueryMap) {
+ // Parameter order is based on the regex group number matched.
+ const project = ctx.params[0] as RepoName;
+ this._redirectOrNavigate({
+ project,
+ changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+ // for edit view params, patchNum cannot be undefined
+ patchNum: convertToPatchSetNum(ctx.params[2])!,
+ path: ctx.params[3],
+ lineNum: ctx.hash,
+ view: GerritView.EDIT,
+ });
+ this.reporting.setRepoName(project);
+ }
+
+ _handleChangeEditRoute(ctx: PageContextWithQueryMap) {
+ // Parameter order is based on the regex group number matched.
+ const project = ctx.params[0] as RepoName;
+ this._redirectOrNavigate({
+ project,
+ // TODO(TS): remove "as unknown"
+ changeNum: (ctx.params[1] as unknown) as NumericChangeId,
+ patchNum: convertToPatchSetNum(ctx.params[3]),
+ view: GerritView.CHANGE,
+ edit: true,
+ });
+ this.reporting.setRepoName(project);
+ }
+
+ /**
+ * Normalize the patch range params for a the change or diff view and
+ * redirect if URL upgrade is needed.
+ */
+ _redirectOrNavigate(params: GenerateUrlParameters & PatchRangeParams) {
+ const needsRedirect = this._normalizePatchRangeParams(params);
+ if (needsRedirect) {
+ this._redirect(this._generateUrl(params));
+ } else {
+ this._setParams(params);
+ }
+ }
+
+ _handleAgreementsRoute() {
+ this._redirect('/settings/#Agreements');
+ }
+
+ _handleNewAgreementsRoute(data: PageContextWithQueryMap) {
+ data.params['view'] = GerritView.AGREEMENTS;
+ // TODO(TS): create valid object
+ this._setParams((data.params as unknown) as AppElementAgreementParam);
+ }
+
+ _handleSettingsLegacyRoute(data: PageContextWithQueryMap) {
+ // email tokens may contain '+' but no space.
+ // The parameter parsing replaces all '+' with a space,
+ // undo that to have valid tokens.
+ const token = data.params[0].replace(/ /g, '+');
+ this._setParams({
+ view: GerritView.SETTINGS,
+ emailToken: token,
+ });
+ }
+
+ _handleSettingsRoute(_: PageContextWithQueryMap) {
+ this._setParams({view: GerritView.SETTINGS});
+ }
+
+ _handleRegisterRoute(ctx: PageContextWithQueryMap) {
+ this._setParams({justRegistered: true});
+ let path = ctx.params[0] || '/';
+
+ // Prevent redirect looping.
+ if (path.startsWith('/register')) {
+ path = '/';
+ }
+
+ if (path[0] !== '/') {
+ return;
+ }
+ this._redirect(getBaseUrl() + path);
+ }
+
+ /**
+ * Handler for routes that should pass through the router and not be caught
+ * by the catchall _handleDefaultRoute handler.
+ */
+ _handlePassThroughRoute() {
+ location.reload();
+ }
+
+ /**
+ * URL may sometimes have /+/ encoded to / /.
+ * Context: Issue 6888, Issue 7100
+ */
+ _handleImproperlyEncodedPlusRoute(ctx: PageContextWithQueryMap) {
+ let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
+ if (hash.length) {
+ hash = '#' + hash;
+ }
+ this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
+ }
+
+ _handlePluginScreen(ctx: PageContextWithQueryMap) {
+ const view = GerritView.PLUGIN_SCREEN;
+ const plugin = ctx.params[0];
+ const screen = ctx.params[1];
+ this._setParams({view, plugin, screen});
+ }
+
+ _handleDocumentationSearchRoute(data: PageContextWithQueryMap) {
+ this._setParams({
+ view: GerritView.DOCUMENTATION_SEARCH,
+ filter: data.params['filter'] || null,
+ });
+ }
+
+ _handleDocumentationSearchRedirectRoute(data: PageContextWithQueryMap) {
+ this._redirect(
+ '/Documentation/q/filter:' + encodeURIComponent(data.params[0])
+ );
+ }
+
+ _handleDocumentationRedirectRoute(data: PageContextWithQueryMap) {
+ if (data.params[1]) {
+ location.reload();
+ } else {
+ // Redirect /Documentation to /Documentation/index.html
+ this._redirect('/Documentation/index.html');
+ }
+ }
+
+ /**
+ * Catchall route for when no other route is matched.
+ */
+ _handleDefaultRoute() {
+ if (this._isInitialLoad) {
+ // Server recognized this route as polygerrit, so we show 404.
+ this._show404();
+ } else {
+ // Route can be recognized by server, so we pass it to server.
+ this._handlePassThroughRoute();
+ }
+ }
+
+ _show404() {
+ // Note: the app's 404 display is tightly-coupled with catching 404
+ // network responses, so we simulate a 404 response status to display it.
+ // TODO: Decouple the gr-app error view from network responses.
+ this._appElement().dispatchEvent(
+ new CustomEvent('page-error', {detail: {response: {status: 404}}})
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-router': GrRouter;
+ }
+}
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 7e36823..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
@@ -17,9 +17,10 @@
import '../../../test/common-test-setup-karma.js';
import './gr-router.js';
-import page from 'page/page.mjs';
+import {page} from '../../../utils/page-wrapper-utils.js';
import {GerritNav} from '../gr-navigation/gr-navigation.js';
import {stubBaseUrl} from '../../../test/test-utils.js';
+import {_testOnly_RoutePattern} from './gr-router.js';
const basicFixture = fixtureFromElement('gr-router');
@@ -181,8 +182,10 @@
'_handleBranchListFilterOffsetRoute',
'_handleBranchListFilterRoute',
'_handleBranchListOffsetRoute',
+ '_handleChangeIdQueryRoute',
'_handleChangeNumberLegacyRoute',
'_handleChangeRoute',
+ '_handleCommentRoute',
'_handleDiffRoute',
'_handleDefaultRoute',
'_handleChangeLegacyRoute',
@@ -194,6 +197,7 @@
'_handleImproperlyEncodedPlusRoute',
'_handlePassThroughRoute',
'_handleProjectDashboardRoute',
+ '_handleLegacyProjectDashboardRoute',
'_handleProjectsOldRoute',
'_handleRepoAccessRoute',
'_handleRepoDashboardsRoute',
@@ -516,11 +520,12 @@
suite('param normalization', () => {
let projectLookupStub;
+ let generateUrlStub;
setup(() => {
projectLookupStub = sinon
.stub(element.$.restAPI, 'getFromProjectLookup');
- sinon.stub(element, '_generateUrl');
+ generateUrlStub = sinon.stub(element, '_generateUrl');
});
suite('_normalizeLegacyRouteParams', () => {
@@ -539,9 +544,9 @@
projectLookupStub.returns(Promise.resolve('foo/bar'));
const params = {};
return element._normalizeLegacyRouteParams(params).then(() => {
+ assert.isFalse(generateUrlStub.calledOnce);
assert.isFalse(projectLookupStub.called);
assert.isFalse(rangeStub.called);
- assert.isNotOk(params.project);
assert.isFalse(redirectStub.called);
assert.isFalse(show404Stub.called);
});
@@ -550,10 +555,13 @@
test('w/ changeNum', () => {
projectLookupStub.returns(Promise.resolve('foo/bar'));
const params = {changeNum: 1234};
+
return element._normalizeLegacyRouteParams(params).then(() => {
+ assert.isTrue(generateUrlStub.calledOnce);
+ const updatedParams = generateUrlStub.lastCall.args[0];
assert.isTrue(projectLookupStub.called);
assert.isTrue(rangeStub.called);
- assert.equal(params.project, 'foo/bar');
+ assert.equal(updatedParams.project, 'foo/bar');
assert.isTrue(redirectStub.calledOnce);
assert.isFalse(show404Stub.called);
});
@@ -563,9 +571,9 @@
projectLookupStub.returns(Promise.resolve(undefined));
const params = {changeNum: 1234};
return element._normalizeLegacyRouteParams(params).then(() => {
+ assert.isFalse(generateUrlStub.calledOnce);
assert.isTrue(projectLookupStub.called);
assert.isFalse(rangeStub.called);
- assert.isUndefined(params.project);
assert.isFalse(redirectStub.called);
assert.isTrue(show404Stub.calledOnce);
});
@@ -610,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);
@@ -733,6 +749,14 @@
});
});
+ test('_handleChangeIdQueryRoute', () => {
+ const data = {params: ['I0123456789abcdef0123456789abcdef01234567']};
+ assertDataToParams(data, '_handleChangeIdQueryRoute', {
+ view: GerritNav.View.SEARCH,
+ query: 'I0123456789abcdef0123456789abcdef01234567',
+ });
+ });
+
suite('_handleRegisterRoute', () => {
test('happy path', () => {
const ctx = {params: ['/foo/bar']};
@@ -1505,6 +1529,23 @@
assert.isFalse(redirectStub.called);
assert.isTrue(normalizeRangeStub.called);
});
+
+ test('comment route', () => {
+ const url = '/c/gerrit/+/264833/comment/00049681_f34fd6a9/';
+ const groups = url.match(_testOnly_RoutePattern.COMMENT);
+ assert.deepEqual(groups.slice(1), [
+ 'gerrit', // project
+ '264833', // changeNum
+ '00049681_f34fd6a9', // commentId
+ ]);
+ assertDataToParams({params: groups.slice(1)}, '_handleCommentRoute', {
+ project: 'gerrit',
+ changeNum: '264833',
+ commentId: '00049681_f34fd6a9',
+ commentLink: true,
+ view: GerritNav.View.DIFF,
+ });
+ });
});
test('_handleDiffEditRoute', () => {
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
deleted file mode 100644
index 6d50fcf..0000000
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ /dev/null
@@ -1,343 +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 '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-search-bar_html.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-// Possible static search options for auto complete, without negations.
-const SEARCH_OPERATORS = [
- 'added:',
- 'age:',
- 'age:1week', // Give an example age
- 'assignee:',
- 'author:',
- 'branch:',
- 'bug:',
- 'cc:',
- 'cc:self',
- 'change:',
- 'cherrypickof:',
- 'comment:',
- 'commentby:',
- 'commit:',
- 'committer:',
- 'conflicts:',
- 'deleted:',
- 'delta:',
- 'dir:',
- 'directory:',
- 'ext:',
- 'extension:',
- 'file:',
- 'footer:',
- 'from:',
- 'has:',
- 'has:draft',
- 'has:edit',
- 'has:star',
- 'has:stars',
- 'has:unresolved',
- 'hashtag:',
- 'intopic:',
- 'is:',
- 'is:abandoned',
- 'is:assigned',
- 'is:closed',
- 'is:ignored',
- 'is:merged',
- 'is:open',
- 'is:owner',
- 'is:private',
- 'is:reviewed',
- 'is:reviewer',
- 'is:starred',
- 'is:submittable',
- 'is:watched',
- 'is:wip',
- 'label:',
- 'message:',
- 'onlyexts:',
- 'onlyextensions:',
- 'owner:',
- 'ownerin:',
- 'parentproject:',
- 'project:',
- 'projects:',
- 'query:',
- 'ref:',
- 'reviewedby:',
- 'reviewer:',
- 'reviewer:self',
- 'reviewerin:',
- 'size:',
- 'star:',
- 'status:',
- 'status:abandoned',
- 'status:closed',
- 'status:merged',
- 'status:open',
- 'status:reviewed',
- 'submissionid:',
- 'topic:',
- 'tr:',
-];
-
-// All of the ops, with corresponding negations.
-const SEARCH_OPERATORS_WITH_NEGATIONS_SET =
- new Set(SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`)));
-
-const MAX_AUTOCOMPLETE_RESULTS = 10;
-
-const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
-
-/**
- * @extends PolymerElement
- */
-class GrSearchBar extends KeyboardShortcutMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-search-bar'; }
- /**
- * Fired when a search is committed
- *
- * @event handle-search
- */
-
- static get properties() {
- return {
- value: {
- type: String,
- value: '',
- notify: true,
- observer: '_valueChanged',
- },
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- query: {
- type: Function,
- value() {
- return this._getSearchSuggestions.bind(this);
- },
- },
- projectSuggestions: {
- type: Function,
- value() {
- return () => Promise.resolve([]);
- },
- },
- groupSuggestions: {
- type: Function,
- value() {
- return () => Promise.resolve([]);
- },
- },
- accountSuggestions: {
- type: Function,
- value() {
- return () => Promise.resolve([]);
- },
- },
- _inputVal: String,
- _threshold: {
- type: Number,
- value: 1,
- },
- /**
- * Invisible label for input element. This label is exposed to
- * screen readers by nested element
- */
- label: {
- type: String,
- value: '',
- },
- };
- }
-
- attached() {
- super.attached();
- this.$.restAPI.getConfig().then(serverConfig => {
- const mergeability = serverConfig
- && serverConfig.index
- && serverConfig.index.mergeabilityComputationBehavior;
- if (mergeability === 'API_REF_UPDATED_AND_CHANGE_REINDEX'
- || mergeability === 'REF_UPDATED_AND_CHANGE_REINDEX') {
- // add 'is:mergeable' to SEARCH_OPERATORS_WITH_NEGATIONS_SET
- this._addOperator('is:mergeable');
- }
- });
- }
-
- _addOperator(name, include_neg = true) {
- SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(name);
- if (include_neg) {
- SEARCH_OPERATORS_WITH_NEGATIONS_SET.add(`-${name}`);
- }
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.SEARCH]: '_handleSearch',
- };
- }
-
- _valueChanged(value) {
- this._inputVal = value;
- }
-
- _handleInputCommit(e) {
- this._preventDefaultAndNavigateToInputVal(e);
- }
-
- /**
- * This function is called in a few different cases:
- * - e.target is the search button
- * - e.target is the gr-autocomplete widget (#searchInput)
- * - e.target is the input element wrapped within #searchInput
- *
- * @param {!Event} e
- */
- _preventDefaultAndNavigateToInputVal(e) {
- e.preventDefault();
- const target = dom(e).rootTarget;
- // If the target is the #searchInput or has a sub-input component, that
- // is what holds the focus as opposed to the target from the DOM event.
- if (target.$.input) {
- target.$.input.blur();
- } else {
- target.blur();
- }
- const trimmedInput = this._inputVal && this._inputVal.trim();
- if (trimmedInput) {
- const predefinedOpOnlyQuery = [...SEARCH_OPERATORS_WITH_NEGATIONS_SET]
- .some(op => op.endsWith(':') && op === trimmedInput);
- if (predefinedOpOnlyQuery) {
- return;
- }
- this.dispatchEvent(new CustomEvent('handle-search', {
- detail: {inputVal: this._inputVal},
- }));
- }
- }
-
- /**
- * Determine what array of possible suggestions should be provided
- * to _getSearchSuggestions.
- *
- * @param {string} input - The full search term, in lowercase.
- * @return {!Promise} This returns a promise that resolves to an array of
- * suggestion objects.
- */
- _fetchSuggestions(input) {
- // Split the input on colon to get a two part predicate/expression.
- const splitInput = input.split(':');
- const predicate = splitInput[0];
- const expression = splitInput[1] || '';
- // Switch on the predicate to determine what to autocomplete.
- switch (predicate) {
- case 'ownerin':
- case 'reviewerin':
- // Fetch groups.
- return this.groupSuggestions(predicate, expression);
-
- case 'parentproject':
- case 'project':
- // Fetch projects.
- return this.projectSuggestions(predicate, expression);
-
- case 'author':
- case 'cc':
- case 'commentby':
- case 'committer':
- case 'from':
- case 'owner':
- case 'reviewedby':
- case 'reviewer':
- // Fetch accounts.
- return this.accountSuggestions(predicate, expression);
-
- default:
- return Promise.resolve([...SEARCH_OPERATORS_WITH_NEGATIONS_SET]
- .filter(operator => operator.includes(input))
- .map(operator => { return {text: operator}; }));
- }
- }
-
- /**
- * Get the sorted, pruned list of suggestions for the current search query.
- *
- * @param {string} input - The complete search query.
- * @return {!Promise} This returns a promise that resolves to an array of
- * suggestions.
- */
- _getSearchSuggestions(input) {
- // Allow spaces within quoted terms.
- const tokens = input.match(TOKENIZE_REGEX);
- const trimmedInput = tokens[tokens.length - 1].toLowerCase();
-
- return this._fetchSuggestions(trimmedInput)
- .then(suggestions => {
- if (!suggestions || !suggestions.length) { return []; }
- return suggestions
- // Prioritize results that start with the input.
- .sort((a, b) => {
- const aContains = a.text.toLowerCase().indexOf(trimmedInput);
- const bContains = b.text.toLowerCase().indexOf(trimmedInput);
- if (aContains === bContains) {
- return a.text.localeCompare(b.text);
- }
- if (aContains === -1) {
- return 1;
- }
- if (bContains === -1) {
- return -1;
- }
- return aContains - bContains;
- })
- // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
- .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
- // Map to an object to play nice with gr-autocomplete.
- .map(({text, label}) => {
- return {
- name: text,
- value: text,
- label,
- };
- });
- });
- }
-
- _handleSearch(e) {
- const keyboardEvent = this.getKeyboardEvent(e);
- if (this.shouldSuppressKeyboardShortcut(e) ||
- (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; }
-
- e.preventDefault();
- this.$.searchInput.focus();
- this.$.searchInput.selectAll();
- }
-}
-
-customElements.define(GrSearchBar.is, GrSearchBar);
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
new file mode 100644
index 0000000..d785a2f
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
@@ -0,0 +1,403 @@
+/**
+ * @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 '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-search-bar_html';
+import {
+ KeyboardShortcutMixin,
+ Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {ServerInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ AutocompleteQuery,
+ AutocompleteSuggestion,
+ GrAutocomplete,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {getDocsBaseUrl} from '../../../utils/url-util';
+import {CustomKeyboardEvent} from '../../../types/events';
+import {MergeabilityComputationBehavior} from '../../../constants/constants';
+
+// Possible static search options for auto complete, without negations.
+const SEARCH_OPERATORS: ReadonlyArray<string> = [
+ 'added:',
+ 'age:',
+ 'age:1week', // Give an example age
+ 'assignee:',
+ 'author:',
+ 'branch:',
+ 'bug:',
+ 'cc:',
+ 'cc:self',
+ 'change:',
+ 'cherrypickof:',
+ 'comment:',
+ 'commentby:',
+ 'commit:',
+ 'committer:',
+ 'conflicts:',
+ 'deleted:',
+ 'delta:',
+ 'dir:',
+ 'directory:',
+ 'ext:',
+ 'extension:',
+ 'file:',
+ 'footer:',
+ 'from:',
+ 'has:',
+ 'has:draft',
+ 'has:edit',
+ 'has:star',
+ 'has:stars',
+ 'has:unresolved',
+ 'hashtag:',
+ 'intopic:',
+ 'is:',
+ 'is:abandoned',
+ 'is:assigned',
+ 'is:closed',
+ 'is:ignored',
+ 'is:merged',
+ 'is:open',
+ 'is:owner',
+ 'is:private',
+ 'is:reviewed',
+ 'is:reviewer',
+ 'is:starred',
+ 'is:submittable',
+ 'is:watched',
+ 'is:wip',
+ 'label:',
+ 'message:',
+ 'onlyexts:',
+ 'onlyextensions:',
+ 'owner:',
+ 'ownerin:',
+ 'parentproject:',
+ 'project:',
+ 'projects:',
+ 'query:',
+ 'ref:',
+ 'reviewedby:',
+ 'reviewer:',
+ 'reviewer:self',
+ 'reviewerin:',
+ 'size:',
+ 'star:',
+ 'status:',
+ 'status:abandoned',
+ 'status:closed',
+ 'status:merged',
+ 'status:open',
+ 'status:reviewed',
+ 'submissionid:',
+ 'topic:',
+ 'tr:',
+];
+
+// All of the ops, with corresponding negations.
+const SEARCH_OPERATORS_WITH_NEGATIONS_SET: ReadonlySet<string> = new Set(
+ SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`))
+);
+
+const MAX_AUTOCOMPLETE_RESULTS = 10;
+
+const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+
+export type SuggestionProvider = (
+ predicate: string,
+ expression: string
+) => Promise<AutocompleteSuggestion[]>;
+
+export interface SearchBarHandleSearchDetail {
+ inputVal: string;
+}
+
+export interface GrSearchBar {
+ $: {
+ restAPI: RestApiService & Element;
+ searchInput: GrAutocomplete;
+ };
+}
+
+@customElement('gr-search-bar')
+export class GrSearchBar extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ private searchOperators = new Set(SEARCH_OPERATORS_WITH_NEGATIONS_SET);
+
+ /**
+ * Fired when a search is committed
+ *
+ * @event handle-search
+ */
+
+ @property({type: String, notify: true, observer: '_valueChanged'})
+ value = '';
+
+ @property({type: Object})
+ keyEventTarget: unknown = document.body;
+
+ @property({type: Object})
+ query: AutocompleteQuery;
+
+ @property({type: Object})
+ projectSuggestions: SuggestionProvider = () => Promise.resolve([]);
+
+ @property({type: Object})
+ groupSuggestions: SuggestionProvider = () => Promise.resolve([]);
+
+ @property({type: Object})
+ accountSuggestions: SuggestionProvider = () => Promise.resolve([]);
+
+ @property({type: String})
+ _inputVal?: string;
+
+ @property({type: Number})
+ _threshold = 1;
+
+ @property({type: String})
+ label = '';
+
+ @property({type: String})
+ docBaseUrl: string | null = null;
+
+ constructor() {
+ super();
+ this.query = (input: string) => this._getSearchSuggestions(input);
+ }
+
+ attached() {
+ super.attached();
+ this.$.restAPI.getConfig().then((serverConfig?: ServerInfo) => {
+ const mergeability =
+ serverConfig &&
+ serverConfig.change &&
+ serverConfig.change.mergeability_computation_behavior;
+ if (
+ mergeability ===
+ MergeabilityComputationBehavior.API_REF_UPDATED_AND_CHANGE_REINDEX ||
+ mergeability ===
+ MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX
+ ) {
+ // add 'is:mergeable' to searchOperators
+ this._addOperator('is:mergeable');
+ }
+ if (serverConfig) {
+ getDocsBaseUrl(serverConfig, this.$.restAPI).then(baseUrl => {
+ this.docBaseUrl = baseUrl;
+ });
+ }
+ });
+ }
+
+ _computeHelpDocLink(docBaseUrl: string | null) {
+ // fallback to gerrit's official doc
+ let baseUrl =
+ docBaseUrl || 'https://gerrit-review.googlesource.com/documentation/';
+ if (baseUrl.endsWith('/')) {
+ baseUrl = baseUrl.substring(0, baseUrl.length - 1);
+ }
+ return `${baseUrl}/user-search.html`;
+ }
+
+ _addOperator(name: string, include_neg = true) {
+ this.searchOperators.add(name);
+ if (include_neg) {
+ this.searchOperators.add(`-${name}`);
+ }
+ }
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.SEARCH]: '_handleSearch',
+ };
+ }
+
+ _valueChanged(value: string) {
+ this._inputVal = value;
+ }
+
+ _handleInputCommit(e: Event) {
+ this._preventDefaultAndNavigateToInputVal(e);
+ }
+
+ /**
+ * This function is called in a few different cases:
+ * - e.target is the search button
+ * - e.target is the gr-autocomplete widget (#searchInput)
+ * - e.target is the input element wrapped within #searchInput
+ */
+ _preventDefaultAndNavigateToInputVal(e: Event) {
+ e.preventDefault();
+ const target = (dom(e) as EventApi).rootTarget as PolymerElement;
+ // If the target is the #searchInput or has a sub-input component, that
+ // is what holds the focus as opposed to the target from the DOM event.
+ if (target.$['input']) {
+ (target.$['input'] as HTMLElement).blur();
+ } else {
+ target.blur();
+ }
+ if (!this._inputVal) return;
+ const trimmedInput = this._inputVal.trim();
+ if (trimmedInput) {
+ const predefinedOpOnlyQuery = [...this.searchOperators].some(
+ op => op.endsWith(':') && op === trimmedInput
+ );
+ if (predefinedOpOnlyQuery) {
+ return;
+ }
+ const detail: SearchBarHandleSearchDetail = {
+ inputVal: this._inputVal,
+ };
+ this.dispatchEvent(
+ new CustomEvent('handle-search', {
+ detail,
+ })
+ );
+ }
+ }
+
+ /**
+ * Determine what array of possible suggestions should be provided
+ * to _getSearchSuggestions.
+ *
+ * @param input - The full search term, in lowercase.
+ * @return This returns a promise that resolves to an array of
+ * suggestion objects.
+ */
+ _fetchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+ // Split the input on colon to get a two part predicate/expression.
+ const splitInput = input.split(':');
+ const predicate = splitInput[0];
+ const expression = splitInput[1] || '';
+ // Switch on the predicate to determine what to autocomplete.
+ switch (predicate) {
+ case 'ownerin':
+ case 'reviewerin':
+ // Fetch groups.
+ return this.groupSuggestions(predicate, expression);
+
+ case 'parentproject':
+ case 'project':
+ // Fetch projects.
+ return this.projectSuggestions(predicate, expression);
+
+ case 'author':
+ case 'cc':
+ case 'commentby':
+ case 'committer':
+ case 'from':
+ case 'owner':
+ case 'reviewedby':
+ case 'reviewer':
+ // Fetch accounts.
+ return this.accountSuggestions(predicate, expression);
+
+ default:
+ return Promise.resolve(
+ [...this.searchOperators]
+ .filter(operator => operator.includes(input))
+ .map(operator => {
+ return {text: operator};
+ })
+ );
+ }
+ }
+
+ /**
+ * Get the sorted, pruned list of suggestions for the current search query.
+ *
+ * @param input - The complete search query.
+ * @return This returns a promise that resolves to an array of
+ * suggestions.
+ */
+ _getSearchSuggestions(input: string): Promise<AutocompleteSuggestion[]> {
+ // Allow spaces within quoted terms.
+ const tokens = input.match(TOKENIZE_REGEX);
+ if (tokens === null) return Promise.resolve([]);
+ const trimmedInput = tokens[tokens.length - 1].toLowerCase();
+
+ return this._fetchSuggestions(trimmedInput).then(suggestions => {
+ if (!suggestions || !suggestions.length) {
+ return [];
+ }
+ return (
+ suggestions
+ // Prioritize results that start with the input.
+ .sort((a, b) => {
+ const aContains = a.text?.toLowerCase().indexOf(trimmedInput);
+ const bContains = b.text?.toLowerCase().indexOf(trimmedInput);
+ if (aContains === undefined && bContains === undefined) return 0;
+ if (aContains === undefined && bContains !== undefined) return 1;
+ if (aContains !== undefined && bContains === undefined) return -1;
+ if (aContains === bContains) {
+ return a.text!.localeCompare(b.text!);
+ }
+ if (aContains === -1) {
+ return 1;
+ }
+ if (bContains === -1) {
+ return -1;
+ }
+ return aContains! - bContains!;
+ })
+ // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
+ .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
+ // Map to an object to play nice with gr-autocomplete.
+ .map(({text, label}) => {
+ return {
+ name: text,
+ value: text,
+ label,
+ };
+ })
+ );
+ });
+ }
+
+ _handleSearch(e: CustomKeyboardEvent) {
+ const keyboardEvent = this.getKeyboardEvent(e);
+ if (
+ this.shouldSuppressKeyboardShortcut(e) ||
+ (this.modifierPressed(e) && !keyboardEvent.shiftKey)
+ ) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.searchInput.focus();
+ this.$.searchInput.selectAll();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-search-bar': GrSearchBar;
+ }
+}
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
index a4d5e69..2fbdc7e 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_html.ts
@@ -41,7 +41,19 @@
threshold="[[_threshold]]"
tab-complete=""
vertical-offset="30"
- ></gr-autocomplete>
+ >
+ <a
+ slot="suffix"
+ href$="[[_computeHelpDocLink(docBaseUrl)]]"
+ target="_blank"
+ class="help"
+ >
+ <iron-icon
+ icon="gr-icons:help-outline"
+ title="read documentation"
+ ></iron-icon>
+ </a>
+ </gr-autocomplete>
</form>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
`;
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
index e5d04be..e470618 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
@@ -20,6 +20,7 @@
import '../../../scripts/util.js';
import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util.js';
const basicFixture = fixtureFromElement('gr-search-bar');
@@ -175,11 +176,9 @@
});
});
- test('Autocompltes without is:mergable when disabled', done => {
- element._getSearchSuggestions('is:mergeab').then(s => {
- assert.equal(s.length, 0);
- done();
- });
+ test('Autocompletes without is:mergable when disabled', async () => {
+ const s = await element._getSearchSuggestions('is:mergeab');
+ assert.isEmpty(s);
});
});
@@ -192,8 +191,8 @@
stub('gr-rest-api-interface', {
getConfig() {
return Promise.resolve({
- index: {
- mergeabilityComputationBehavior: mergeability,
+ change: {
+ mergeability_computation_behavior: mergeability,
},
});
},
@@ -215,5 +214,39 @@
});
});
});
+
+ suite('doc url', () => {
+ setup(done => {
+ stub('gr-rest-api-interface', {
+ getConfig() {
+ return Promise.resolve({
+ gerrit: {
+ doc_url: 'https://doc.com/',
+ },
+ });
+ },
+ });
+
+ _testOnly_clearDocsBaseUrlCache();
+ element = basicFixture.instantiate();
+ flush(done);
+ });
+
+ test('compute help doc url with correct path', () => {
+ assert.equal(element.docBaseUrl, 'https://doc.com/');
+ assert.equal(
+ element._computeHelpDocLink(element.docBaseUrl),
+ 'https://doc.com/user-search.html'
+ );
+ });
+
+ test('compute help doc url fallback to gerrit url', () => {
+ assert.equal(
+ element._computeHelpDocLink(),
+ 'https://gerrit-review.googlesource.com/documentation/' +
+ 'user-search.html'
+ );
+ });
+ });
});
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
deleted file mode 100644
index 772b412..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.js
+++ /dev/null
@@ -1,177 +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-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-search-bar/gr-search-bar.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-smart-search_html.js';
-import {GerritNav} from '../gr-navigation/gr-navigation.js';
-import {getUserName} from '../../../utils/display-name-util.js';
-
-const MAX_AUTOCOMPLETE_RESULTS = 10;
-const SELF_EXPRESSION = 'self';
-const ME_EXPRESSION = 'me';
-
-/**
- * @extends PolymerElement
- */
-class GrSmartSearch extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-smart-search'; }
-
- static get properties() {
- return {
- searchQuery: String,
- _config: Object,
- _projectSuggestions: {
- type: Function,
- value() {
- return this._fetchProjects.bind(this);
- },
- },
- _groupSuggestions: {
- type: Function,
- value() {
- return this._fetchGroups.bind(this);
- },
- },
- _accountSuggestions: {
- type: Function,
- value() {
- return this._fetchAccounts.bind(this);
- },
- },
- /**
- * Invisible label for input element. This label is exposed to
- * screen readers by nested element
- */
- label: {
- type: String,
- value: '',
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.$.restAPI.getConfig().then(cfg => {
- this._config = cfg;
- });
- }
-
- _handleSearch(e) {
- const input = e.detail.inputVal;
- if (input) {
- GerritNav.navigateToSearchQuery(input);
- }
- }
-
- /**
- * Fetch from the API the predicted projects.
- *
- * @param {string} predicate - The first part of the search term, e.g.
- * 'project'
- * @param {string} expression - The second part of the search term, e.g.
- * 'gerr'
- * @return {!Promise} This returns a promise that resolves to an array of
- * strings.
- */
- _fetchProjects(predicate, expression) {
- return this.$.restAPI.getSuggestedProjects(
- expression,
- MAX_AUTOCOMPLETE_RESULTS)
- .then(projects => {
- if (!projects) { return []; }
- const keys = Object.keys(projects);
- return keys.map(key => { return {text: predicate + ':' + key}; });
- });
- }
-
- /**
- * Fetch from the API the predicted groups.
- *
- * @param {string} predicate - The first part of the search term, e.g.
- * 'ownerin'
- * @param {string} expression - The second part of the search term, e.g.
- * 'polyger'
- * @return {!Promise} This returns a promise that resolves to an array of
- * strings.
- */
- _fetchGroups(predicate, expression) {
- if (expression.length === 0) { return Promise.resolve([]); }
- return this.$.restAPI.getSuggestedGroups(
- expression,
- MAX_AUTOCOMPLETE_RESULTS)
- .then(groups => {
- if (!groups) { return []; }
- const keys = Object.keys(groups);
- return keys.map(key => { return {text: predicate + ':' + key}; });
- });
- }
-
- /**
- * Fetch from the API the predicted accounts.
- *
- * @param {string} predicate - The first part of the search term, e.g.
- * 'owner'
- * @param {string} expression - The second part of the search term, e.g.
- * 'kasp'
- * @return {!Promise} This returns a promise that resolves to an array of
- * strings.
- */
- _fetchAccounts(predicate, expression) {
- if (expression.length === 0) { return Promise.resolve([]); }
- return this.$.restAPI.getSuggestedAccounts(
- expression,
- MAX_AUTOCOMPLETE_RESULTS)
- .then(accounts => {
- if (!accounts) { return []; }
- return this._mapAccountsHelper(accounts, predicate);
- })
- .then(accounts => {
- // When the expression supplied is a beginning substring of 'self',
- // add it as an autocomplete option.
- if (SELF_EXPRESSION.startsWith(expression)) {
- return accounts.concat(
- [{text: predicate + ':' + SELF_EXPRESSION}]);
- } else if (ME_EXPRESSION.startsWith(expression)) {
- return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
- } else {
- return accounts;
- }
- });
- }
-
- _mapAccountsHelper(accounts, predicate) {
- return accounts.map(account => {
- const userName = getUserName(this._serverConfig, account);
- return {
- label: account.name || '',
- text: account.email ?
- `${predicate}:${account.email}` :
- `${predicate}:"${userName}"`,
- };
- });
- }
-}
-
-customElements.define(GrSmartSearch.is, GrSmartSearch);
diff --git a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
new file mode 100644
index 0000000..a818c59
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search.ts
@@ -0,0 +1,197 @@
+/**
+ * @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-rest-api-interface/gr-rest-api-interface';
+import '../gr-search-bar/gr-search-bar';
+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-smart-search_html';
+import {GerritNav} from '../gr-navigation/gr-navigation';
+import {getUserName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {
+ SearchBarHandleSearchDetail,
+ SuggestionProvider,
+} from '../gr-search-bar/gr-search-bar';
+import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+const MAX_AUTOCOMPLETE_RESULTS = 10;
+const SELF_EXPRESSION = 'self';
+const ME_EXPRESSION = 'me';
+
+export interface GrSmartSearch {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-smart-search')
+export class GrSmartSearch extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ searchQuery?: string;
+
+ @property({type: Object})
+ _config?: ServerInfo;
+
+ @property({type: Object})
+ _projectSuggestions: SuggestionProvider = (predicate, expression) =>
+ this._fetchProjects(predicate, expression);
+
+ @property({type: Object})
+ _groupSuggestions: SuggestionProvider = (predicate, expression) =>
+ this._fetchGroups(predicate, expression);
+
+ @property({type: Object})
+ _accountSuggestions: SuggestionProvider = (predicate, expression) =>
+ this._fetchAccounts(predicate, expression);
+
+ @property({type: String})
+ label = '';
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.$.restAPI.getConfig().then(cfg => {
+ this._config = cfg;
+ });
+ }
+
+ _handleSearch(e: CustomEvent<SearchBarHandleSearchDetail>) {
+ const input = e.detail.inputVal;
+ if (input) {
+ GerritNav.navigateToSearchQuery(input);
+ }
+ }
+
+ /**
+ * Fetch from the API the predicted projects.
+ *
+ * @param predicate - The first part of the search term, e.g.
+ * 'project'
+ * @param expression - The second part of the search term, e.g.
+ * 'gerr'
+ */
+ _fetchProjects(
+ predicate: string,
+ expression: string
+ ): Promise<AutocompleteSuggestion[]> {
+ return this.$.restAPI
+ .getSuggestedProjects(expression, MAX_AUTOCOMPLETE_RESULTS)
+ .then(projects => {
+ if (!projects) {
+ return [];
+ }
+ const keys = Object.keys(projects);
+ return keys.map(key => {
+ return {text: predicate + ':' + key};
+ });
+ });
+ }
+
+ /**
+ * Fetch from the API the predicted groups.
+ *
+ * @param predicate - The first part of the search term, e.g.
+ * 'ownerin'
+ * @param expression - The second part of the search term, e.g.
+ * 'polyger'
+ */
+ _fetchGroups(
+ predicate: string,
+ expression: string
+ ): Promise<AutocompleteSuggestion[]> {
+ if (expression.length === 0) {
+ return Promise.resolve([]);
+ }
+ return this.$.restAPI
+ .getSuggestedGroups(expression, MAX_AUTOCOMPLETE_RESULTS)
+ .then(groups => {
+ if (!groups) {
+ return [];
+ }
+ const keys = Object.keys(groups);
+ return keys.map(key => {
+ return {text: predicate + ':' + key};
+ });
+ });
+ }
+
+ /**
+ * Fetch from the API the predicted accounts.
+ *
+ * @param predicate - The first part of the search term, e.g.
+ * 'owner'
+ * @param expression - The second part of the search term, e.g.
+ * 'kasp'
+ */
+ _fetchAccounts(
+ predicate: string,
+ expression: string
+ ): Promise<AutocompleteSuggestion[]> {
+ if (expression.length === 0) {
+ return Promise.resolve([]);
+ }
+ return this.$.restAPI
+ .getSuggestedAccounts(expression, MAX_AUTOCOMPLETE_RESULTS)
+ .then(accounts => {
+ if (!accounts) {
+ return [];
+ }
+ return this._mapAccountsHelper(accounts, predicate);
+ })
+ .then(accounts => {
+ // When the expression supplied is a beginning substring of 'self',
+ // add it as an autocomplete option.
+ if (SELF_EXPRESSION.startsWith(expression)) {
+ return accounts.concat([{text: predicate + ':' + SELF_EXPRESSION}]);
+ } else if (ME_EXPRESSION.startsWith(expression)) {
+ return accounts.concat([{text: predicate + ':' + ME_EXPRESSION}]);
+ } else {
+ return accounts;
+ }
+ });
+ }
+
+ _mapAccountsHelper(
+ accounts: AccountInfo[],
+ predicate: string
+ ): AutocompleteSuggestion[] {
+ return accounts.map(account => {
+ const userName = getUserName(this._config, account);
+ return {
+ label: account.name || '',
+ text: account.email
+ ? `${predicate}:${account.email}`
+ : `${predicate}:"${userName}"`,
+ };
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-smart-search': GrSmartSearch;
+ }
+}
diff --git a/polygerrit-ui/app/elements/custom-dark-theme_test.js b/polygerrit-ui/app/elements/custom-dark-theme_test.js
index d4f790f..ad12e14 100644
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.js
@@ -19,7 +19,7 @@
import {getComputedStyleValue} from '../utils/dom-util.js';
import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
import './gr-app.js';
-import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
import {removeTheme} from '../styles/themes/dark-theme.js';
const basicFixture = fixtureFromElement('gr-app');
@@ -30,8 +30,9 @@
window.localStorage.setItem('dark-theme', 'true');
element = basicFixture.instantiate();
- pluginLoader.loadPlugins([]);
- pluginLoader.awaitPluginsLoaded().then(() => flush(done));
+ getPluginLoader().loadPlugins([]);
+ getPluginLoader().awaitPluginsLoaded()
+ .then(() => flush(done));
});
teardown(() => {
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.js b/polygerrit-ui/app/elements/custom-light-theme_test.js
index 5c0cf28..6d5b61e 100644
--- a/polygerrit-ui/app/elements/custom-light-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.js
@@ -19,7 +19,7 @@
import {getComputedStyleValue} from '../utils/dom-util.js';
import './shared/gr-rest-api-interface/gr-rest-api-interface.js';
import './gr-app.js';
-import {pluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
const basicFixture = fixtureFromElement('gr-app');
@@ -36,8 +36,9 @@
_fetchSharedCacheURL() { return Promise.resolve({}); },
});
element = basicFixture.instantiate();
- pluginLoader.loadPlugins([]);
- pluginLoader.awaitPluginsLoaded().then(() => flush(done));
+ getPluginLoader().loadPlugins([]);
+ getPluginLoader().awaitPluginsLoaded()
+ .then(() => flush(done));
});
teardown(() => {
// The app sends requests to server. This can lead to
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
deleted file mode 100644
index 6394bff..0000000
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.js
+++ /dev/null
@@ -1,249 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 '../../../styles/shared-styles.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-diff/gr-diff.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-apply-fix-dialog_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/**
- * @extends PolymerElement
- */
-class GrApplyFixDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-apply-fix-dialog'; }
-
- static get properties() {
- return {
- // Diff rendering preference API response.
- prefs: Array,
- // ChangeInfo API response object.
- change: Object,
- changeNum: String,
- _patchNum: Number,
- // robot ID associated with a robot comment.
- _robotId: String,
- // Selected FixSuggestionInfo entity from robot comment API response.
- _currentFix: Object,
- // Flattened /preview API response DiffInfo map object.
- _currentPreviews: {type: Array, value: () => []},
- // FixSuggestionInfo entities from robot comment API response.
- _fixSuggestions: Array,
- _isApplyFixLoading: {
- type: Boolean,
- value: false,
- },
- // Index of currently showing suggested fix.
- _selectedFixIdx: Number,
- _disableApplyFixButton: {
- type: Boolean,
- computed: '_computeDisableApplyFixButton(_isApplyFixLoading, change, '
- + '_patchNum)',
- },
- };
- }
-
- /**
- * Given robot comment CustomEvent objevt, fetch diffs associated
- * with first robot comment suggested fix and open dialog.
- *
- * @param {*} e CustomEvent to be passed from gr-comment with
- * robot comment detail.
- * @return {Promise<undefined>} Promise that resolves either when all
- * preview diffs are fetched or no fix suggestions in custom event detail.
- */
- open(e) {
- this._patchNum = e.detail.patchNum;
- this._fixSuggestions = e.detail.comment.fix_suggestions;
- this._robotId = e.detail.comment.robot_id;
- if (this._fixSuggestions == null || this._fixSuggestions.length == 0) {
- return Promise.resolve();
- }
- this._selectedFixIdx = 0;
- const promises = [];
- promises.push(
- this._showSelectedFixSuggestion(this._fixSuggestions[0]),
- this.$.applyFixOverlay.open()
- );
- return Promise.all(promises)
- .then(() => {
- // ensures gr-overlay repositions overlay in center
- this.$.applyFixOverlay.dispatchEvent(
- new CustomEvent('iron-resize', {
- composed: true, bubbles: true,
- }));
- });
- }
-
- attached() {
- super.attached();
- this.refitOverlay = () => {
- // re-center the dialog as content changed
- this.$.applyFixOverlay.dispatchEvent(
- new CustomEvent('iron-resize', {
- composed: true, bubbles: true,
- }));
- };
- this.addEventListener('diff-context-expanded', this.refitOverlay);
- }
-
- detached() {
- super.detached();
- this.removeEventListener('diff-context-expanded', this.refitOverlay);
- }
-
- _showSelectedFixSuggestion(fixSuggestion) {
- this._currentFix = fixSuggestion;
- return this._fetchFixPreview(fixSuggestion.fix_id);
- }
-
- _fetchFixPreview(fixId) {
- return this.$.restAPI
- .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
- .then(res => {
- if (res != null) {
- this._currentPreviews = Object.keys(res).map(key => {
- return {filepath: key, preview: res[key]};
- });
- }
- })
- .catch(err => {
- this._close();
- throw err;
- });
- }
-
- hasSingleFix(_fixSuggestions) {
- return (_fixSuggestions || {}).length === 1;
- }
-
- overridePartialPrefs(prefs) {
- // generate a smaller gr-diff than fullscreen for dialog
- return Object.assign({}, prefs, {line_length: 50});
- }
-
- onCancel(e) {
- if (e) {
- e.stopPropagation();
- }
- this._close();
- }
-
- addOneTo(_selectedFixIdx) {
- return _selectedFixIdx + 1;
- }
-
- _onPrevFixClick(e) {
- if (e) e.stopPropagation();
- if (this._selectedFixIdx >= 1 && this._fixSuggestions != null) {
- this._selectedFixIdx -= 1;
- return this._showSelectedFixSuggestion(
- this._fixSuggestions[this._selectedFixIdx]);
- }
- }
-
- _onNextFixClick(e) {
- if (e) e.stopPropagation();
- if (this._fixSuggestions &&
- this._selectedFixIdx < this._fixSuggestions.length) {
- this._selectedFixIdx += 1;
- return this._showSelectedFixSuggestion(
- this._fixSuggestions[this._selectedFixIdx]);
- }
- }
-
- _noPrevFix(_selectedFixIdx) {
- return _selectedFixIdx === 0;
- }
-
- _noNextFix(_selectedFixIdx, fixSuggestions) {
- if (fixSuggestions == null) return true;
- return _selectedFixIdx === fixSuggestions.length - 1;
- }
-
- _close() {
- this._currentFix = {};
- this._currentPreviews = [];
- this._isApplyFixLoading = false;
-
- this.dispatchEvent(new CustomEvent('close-fix-preview', {
- bubbles: true,
- composed: true,
- }));
- this.$.applyFixOverlay.close();
- }
-
- _getApplyFixButtonLabel(isLoading) {
- return isLoading ? 'Saving...' : 'Apply Fix';
- }
-
- _computeTooltip(change, patchNum) {
- if (!change || patchNum == undefined) return '';
- // If change is defined, change.revisions and change.current_revisions
- // must be defined
- const latestPatchNum = change.revisions[change.current_revision]._number;
- return latestPatchNum !== patchNum ?
- 'Fix can only be applied to the latest patchset' : '';
- }
-
- _computeDisableApplyFixButton(isApplyFixLoading, change, patchNum) {
- if (!change || isApplyFixLoading == undefined || patchNum == undefined) {
- return true;
- }
- const currentPatchNum = change.revisions[change.current_revision]._number;
- if (patchNum !== currentPatchNum) {
- return true;
- }
- return isApplyFixLoading;
- }
-
- _handleApplyFix(e) {
- if (e) {
- e.stopPropagation();
- }
- if (this._currentFix == null || this._currentFix.fix_id == null) {
- return;
- }
- this._isApplyFixLoading = true;
- return this.$.restAPI
- .applyFixSuggestion(
- this.changeNum, this._patchNum, this._currentFix.fix_id
- )
- .then(res => {
- if (res && res.ok) {
- GerritNav.navigateToChange(this.change, 'edit', this._patchNum);
- this._close();
- }
- this._isApplyFixLoading = false;
- });
- }
-
- getFixDescription(currentFix) {
- return currentFix != null && currentFix.description ?
- currentFix.description : '';
- }
-}
-
-customElements.define(GrApplyFixDialog.is, GrApplyFixDialog);
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
new file mode 100644
index 0000000..0e73516
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -0,0 +1,314 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 '../../../styles/shared-styles';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-diff/gr-diff';
+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-apply-fix-dialog_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {ParsedChangeInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {
+ NumericChangeId,
+ DiffInfo,
+ DiffPreferencesInfo,
+ EditPatchSetNum,
+ FixId,
+ FixSuggestionInfo,
+ PatchSetNum,
+ RobotId,
+} from '../../../types/common';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {isRobot} from '../../../utils/comment-util';
+import {OpenFixPreviewEvent} from '../../../types/events';
+
+export interface GrApplyFixDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ applyFixOverlay: GrOverlay;
+ };
+}
+
+interface FilePreview {
+ filepath: string;
+ preview: DiffInfo;
+}
+
+@customElement('gr-apply-fix-dialog')
+export class GrApplyFixDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ prefs?: DiffPreferencesInfo;
+
+ @property({type: Object})
+ change?: ParsedChangeInfo;
+
+ @property({type: String})
+ changeNum?: NumericChangeId;
+
+ @property({type: Number})
+ _patchNum?: PatchSetNum;
+
+ @property({type: String})
+ _robotId?: RobotId;
+
+ @property({type: Object})
+ _currentFix?: FixSuggestionInfo;
+
+ @property({type: Array})
+ _currentPreviews: FilePreview[] = [];
+
+ @property({type: Array})
+ _fixSuggestions?: FixSuggestionInfo[];
+
+ @property({type: Boolean})
+ _isApplyFixLoading = false;
+
+ @property({type: Number})
+ _selectedFixIdx = 0;
+
+ @property({
+ type: Boolean,
+ computed:
+ '_computeDisableApplyFixButton(_isApplyFixLoading, change, ' +
+ '_patchNum)',
+ })
+ _disableApplyFixButton?: boolean;
+
+ private refitOverlay?: () => void;
+
+ /**
+ * Given robot comment CustomEvent object, fetch diffs associated
+ * with first robot comment suggested fix and open dialog.
+ *
+ * @param e to be passed from gr-comment with robot comment detail.
+ * @return Promise that resolves either when all
+ * preview diffs are fetched or no fix suggestions in custom event detail.
+ */
+ open(e: OpenFixPreviewEvent) {
+ const detail = e.detail;
+ const comment = detail.comment;
+ if (!detail.patchNum || !comment || !isRobot(comment)) {
+ return Promise.resolve();
+ }
+ this._patchNum = detail.patchNum;
+ this._fixSuggestions = comment.fix_suggestions;
+ this._robotId = comment.robot_id;
+ if (!this._fixSuggestions || !this._fixSuggestions.length) {
+ return Promise.resolve();
+ }
+ this._selectedFixIdx = 0;
+ const promises = [];
+ promises.push(
+ this._showSelectedFixSuggestion(this._fixSuggestions[0]),
+ this.$.applyFixOverlay.open()
+ );
+ return Promise.all(promises).then(() => {
+ // ensures gr-overlay repositions overlay in center
+ this.$.applyFixOverlay.dispatchEvent(
+ new CustomEvent('iron-resize', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }
+
+ attached() {
+ super.attached();
+ this.refitOverlay = () => {
+ // re-center the dialog as content changed
+ this.$.applyFixOverlay.dispatchEvent(
+ new CustomEvent('iron-resize', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+ this.addEventListener('diff-context-expanded', this.refitOverlay);
+ }
+
+ detached() {
+ super.detached();
+ if (this.refitOverlay) {
+ this.removeEventListener('diff-context-expanded', this.refitOverlay);
+ }
+ }
+
+ _showSelectedFixSuggestion(fixSuggestion: FixSuggestionInfo) {
+ this._currentFix = fixSuggestion;
+ return this._fetchFixPreview(fixSuggestion.fix_id);
+ }
+
+ _fetchFixPreview(fixId: FixId) {
+ if (!this.changeNum || !this._patchNum) {
+ return Promise.reject(
+ new Error('Both _patchNum and changeNum must be set')
+ );
+ }
+ return this.$.restAPI
+ .getRobotCommentFixPreview(this.changeNum, this._patchNum, fixId)
+ .then(res => {
+ if (res) {
+ this._currentPreviews = Object.keys(res).map(key => {
+ return {filepath: key, preview: res[key]};
+ });
+ }
+ })
+ .catch(err => {
+ this._close();
+ throw err;
+ });
+ }
+
+ hasSingleFix(_fixSuggestions?: FixSuggestionInfo[]) {
+ return (_fixSuggestions || []).length === 1;
+ }
+
+ overridePartialPrefs(prefs: DiffPreferencesInfo): DiffPreferencesInfo {
+ // generate a smaller gr-diff than fullscreen for dialog
+ return {...prefs, line_length: 50};
+ }
+
+ onCancel(e: CustomEvent) {
+ if (e) {
+ e.stopPropagation();
+ }
+ this._close();
+ }
+
+ addOneTo(_selectedFixIdx: number) {
+ return _selectedFixIdx + 1;
+ }
+
+ _onPrevFixClick(e: CustomEvent) {
+ if (e) e.stopPropagation();
+ if (this._selectedFixIdx >= 1 && this._fixSuggestions) {
+ this._selectedFixIdx -= 1;
+ this._showSelectedFixSuggestion(
+ this._fixSuggestions[this._selectedFixIdx]
+ );
+ }
+ }
+
+ _onNextFixClick(e: CustomEvent) {
+ if (e) e.stopPropagation();
+ if (
+ this._fixSuggestions &&
+ this._selectedFixIdx < this._fixSuggestions.length
+ ) {
+ this._selectedFixIdx += 1;
+ this._showSelectedFixSuggestion(
+ this._fixSuggestions[this._selectedFixIdx]
+ );
+ }
+ }
+
+ _noPrevFix(_selectedFixIdx: number) {
+ return _selectedFixIdx === 0;
+ }
+
+ _noNextFix(_selectedFixIdx: number, fixSuggestions?: FixSuggestionInfo[]) {
+ if (!fixSuggestions) return true;
+ return _selectedFixIdx === fixSuggestions.length - 1;
+ }
+
+ _close() {
+ this._currentFix = undefined;
+ this._currentPreviews = [];
+ this._isApplyFixLoading = false;
+
+ this.dispatchEvent(
+ new CustomEvent('close-fix-preview', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ this.$.applyFixOverlay.close();
+ }
+
+ _getApplyFixButtonLabel(isLoading: boolean) {
+ return isLoading ? 'Saving...' : 'Apply Fix';
+ }
+
+ _computeTooltip(change?: ParsedChangeInfo, patchNum?: PatchSetNum) {
+ if (!change || !patchNum) return '';
+ const latestPatchNum = change.revisions[change.current_revision]._number;
+ return latestPatchNum !== patchNum
+ ? 'Fix can only be applied to the latest patchset'
+ : '';
+ }
+
+ _computeDisableApplyFixButton(
+ isApplyFixLoading?: boolean,
+ change?: ParsedChangeInfo,
+ patchNum?: PatchSetNum
+ ) {
+ if (!change || isApplyFixLoading === undefined || patchNum === undefined) {
+ return true;
+ }
+ const currentPatchNum = change.revisions[change.current_revision]._number;
+ if (patchNum !== currentPatchNum) {
+ return true;
+ }
+ return isApplyFixLoading;
+ }
+
+ _handleApplyFix(e: CustomEvent) {
+ if (e) {
+ e.stopPropagation();
+ }
+
+ const changeNum = this.changeNum;
+ const patchNum = this._patchNum;
+ const change = this.change;
+ if (!changeNum || !patchNum || !change || !this._currentFix) {
+ return Promise.reject(new Error('Not all required properties are set.'));
+ }
+ this._isApplyFixLoading = true;
+ return this.$.restAPI
+ .applyFixSuggestion(changeNum, patchNum, this._currentFix.fix_id)
+ .then(res => {
+ if (res && res.ok) {
+ GerritNav.navigateToChange(change, EditPatchSetNum, patchNum);
+ this._close();
+ }
+ this._isApplyFixLoading = false;
+ });
+ }
+
+ getFixDescription(currentFix?: FixSuggestionInfo) {
+ return currentFix && currentFix.description ? currentFix.description : '';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-apply-fix-dialog': GrApplyFixDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
index b3ba637..cb73885 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.js
@@ -179,19 +179,19 @@
comment: ROBOT_COMMENT_WITH_TWO_FIXES}});
flush(() => {
assert.isTrue(errorStub.called);
- assert.deepEqual(element._currentFix, {});
+ assert.equal(element._currentFix, undefined);
done();
});
});
test('apply fix button should call apply ' +
- 'and navigate to change view', done => {
+ 'and navigate to change view', () => {
sinon.stub(element.$.restAPI, 'applyFixSuggestion')
.returns(Promise.resolve({ok: true}));
sinon.stub(GerritNav, 'navigateToChange');
element._currentFix = {fix_id: '123'};
- element._handleApplyFix().then(() => {
+ return element._handleApplyFix().then(() => {
assert.isTrue(element.$.restAPI.applyFixSuggestion
.calledWithExactly('1', 2, '123'));
assert.isTrue(GerritNav.navigateToChange.calledWithExactly({
@@ -205,9 +205,8 @@
}, 'edit', 2));
// reset gr-apply-fix-dialog and close
- assert.deepEqual(element._currentFix, {});
+ assert.equal(element._currentFix, undefined);
assert.equal(element._currentPreviews.length, 0);
- done();
});
});
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
deleted file mode 100644
index 89821fd..0000000
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
+++ /dev/null
@@ -1,635 +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 '../../shared/gr-rest-api-interface/gr-rest-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-comment-api_html.js';
-import {parseDate} from '../../../utils/date-util.js';
-import {
- getParentIndex,
- isMergeParent,
- patchNumEquals,
-} from '../../../utils/patch-set-util.js';
-
-const PARENT = 'PARENT';
-
-/**
- * Construct a change comments object, which can be data-bound to child
- * elements of that which uses the gr-comment-api.
- *
- * @constructor
- * @param {!Object} comments
- * @param {!Object} robotComments
- * @param {!Object} drafts
- * @param {number} changeNum
- */
-class ChangeComments {
- constructor(comments, robotComments, drafts, changeNum) {
- this._comments = this._addPath(comments);
- this._robotComments = this._addPath(robotComments);
- this._drafts = this._addPath(drafts);
- this._changeNum = changeNum;
- }
-
- /**
- * Add path info to every comment as CommentInfo returned
- * from server does not have that.
- *
- * TODO(taoalpha): should consider changing BE to send path
- * back within CommentInfo
- *
- * @param {Object} - map between file path and comments
- */
- _addPath(comments = {}) {
- const updatedComments = {};
- for (const filePath of Object.keys(comments)) {
- const allCommentsForPath = comments[filePath] || [];
- if (allCommentsForPath.length) {
- updatedComments[filePath] = allCommentsForPath
- .map(comment => { return {...comment, path: filePath}; });
- }
- }
- return updatedComments;
- }
-
- get comments() {
- return this._comments;
- }
-
- get drafts() {
- return this._drafts;
- }
-
- get robotComments() {
- return this._robotComments;
- }
-
- /**
- * Get an object mapping file paths to a boolean representing whether that
- * path contains diff comments in the given patch set (including drafts and
- * robot comments).
- *
- * Paths with comments are mapped to true, whereas paths without comments
- * are not mapped.
- *
- * @param {Gerrit.PatchRange=} opt_patchRange The patch-range object containing
- * patchNum and basePatchNum properties to represent the range.
- * @return {!Object}
- */
- getPaths(opt_patchRange) {
- const responses = [this.comments, this.drafts, this.robotComments];
- const commentMap = {};
- for (const response of responses) {
- for (const path in response) {
- if (response.hasOwnProperty(path) &&
- response[path].some(c => {
- // If don't care about patch range, we know that the path exists.
- if (!opt_patchRange) { return true; }
- return this._isInPatchRange(c, opt_patchRange);
- })) {
- commentMap[path] = true;
- }
- }
- }
- return commentMap;
- }
-
- /**
- * Gets all the comments and robot comments for the given change.
- *
- * @param {number=} opt_patchNum
- * @return {!Object}
- */
- getAllPublishedComments(opt_patchNum) {
- return this.getAllComments(false, opt_patchNum);
- }
-
- /**
- * Gets all the comments for a particular thread group. Used for refreshing
- * comments after the thread group has already been built.
- *
- * @param {string} rootId
- * @return {!Array} an array of comments
- */
- getCommentsForThread(rootId) {
- const allThreads = this.getAllThreadsForChange();
- const threadMatch = allThreads.find(t => t.rootId === rootId);
-
- // In the event that a single draft comment was removed by the thread-list
- // and the diff view is updating comments, there will no longer be a thread
- // found. In this case, return null.
- return threadMatch ? threadMatch.comments : null;
- }
-
- /**
- * Filters an array of comments by line and side
- *
- * @param {!Array} comments
- * @param {boolean} parentOnly whether the only comments returned should have
- * the side attribute set to PARENT
- * @param {string} commentSide whether the comment was left on the left or the
- * right side regardless or unified or side-by-side
- * @param {number=} opt_line line number, can be undefined if file comment
- * @return {!Array} an array of comments
- */
- _filterCommentsBySideAndLine(comments,
- parentOnly, commentSide, opt_line) {
- return comments.filter(c => {
- // if parentOnly, only match comments with PARENT for the side.
- let sideMatch = parentOnly ? c.side === PARENT : c.side !== PARENT;
- if (parentOnly) {
- sideMatch = sideMatch && c.side === PARENT;
- }
- return sideMatch && c.line === opt_line;
- }).map(c => {
- c.__commentSide = commentSide;
- return c;
- });
- }
-
- /**
- * Gets all the comments and robot comments for the given change.
- *
- * @param {boolean=} opt_includeDrafts
- * @param {number=} opt_patchNum
- * @return {!Object}
- */
- getAllComments(opt_includeDrafts,
- opt_patchNum) {
- const paths = this.getPaths();
- const publishedComments = {};
- for (const path of Object.keys(paths)) {
- publishedComments[path] = this.getAllCommentsForPath(
- path,
- opt_patchNum,
- opt_includeDrafts
- );
- }
- return publishedComments;
- }
-
- /**
- * Gets all the drafts for the given change.
- *
- * @param {number=} opt_patchNum
- * @return {!Object}
- */
- getAllDrafts(opt_patchNum) {
- const paths = this.getPaths();
- const drafts = {};
- for (const path of Object.keys(paths)) {
- drafts[path] = this.getAllDraftsForPath(path, opt_patchNum);
- }
- return drafts;
- }
-
- /**
- * Get the comments (robot comments) for a path and optional patch num.
- *
- * This method will always return a new shallow copy of all comments,
- * so manipulation on one copy won't affect other copies.
- *
- * @param {!string} path
- * @param {number=} opt_patchNum
- * @param {boolean=} opt_includeDrafts
- * @return {!Array}
- */
- getAllCommentsForPath(path,
- opt_patchNum, opt_includeDrafts) {
- const comments = this._comments[path] || [];
- const robotComments = this._robotComments[path] || [];
- let allComments = comments.concat(robotComments);
- if (opt_includeDrafts) {
- const drafts = this.getAllDraftsForPath(path);
- allComments = allComments.concat(drafts);
- }
- if (opt_patchNum) {
- allComments = allComments.filter(c =>
- patchNumEquals(c.patch_set, opt_patchNum)
- );
- }
- return allComments.map(c => { return {...c}; });
- }
-
- /**
- * Get the comments (robot comments) for a file.
- *
- * // TODO(taoalpha): maybe merge in *ForPath
- *
- * @param {!{path: string, basePath?: string, patchNum?: number}} file
- * @param {boolean=} opt_includeDrafts
- * @return {!Array}
- */
- getAllCommentsForFile(file, opt_includeDrafts) {
- let allComments = this.getAllCommentsForPath(
- file.path, file.patchNum, opt_includeDrafts
- );
-
- if (file.basePath) {
- allComments = allComments.concat(
- this.getAllCommentsForPath(
- file.basePath, file.patchNum, opt_includeDrafts
- )
- );
- }
-
- return allComments;
- }
-
- /**
- * Get the drafts for a path and optional patch num.
- *
- * This will return a shallow copy of all drafts every time,
- * so changes on any copy will not affect other copies.
- *
- * @param {!string} path
- * @param {number=} opt_patchNum
- * @return {!Array}
- */
- getAllDraftsForPath(path,
- opt_patchNum) {
- let comments = this._drafts[path] || [];
- if (opt_patchNum) {
- comments = comments.filter(c =>
- patchNumEquals(c.patch_set, opt_patchNum)
- );
- }
- return comments.map(c => { return {...c, __draft: true}; });
- }
-
- /**
- * Get the drafts for a file.
- *
- * // TODO(taoalpha): maybe merge in *ForPath
- *
- * @param {!{path: string, basePath?: string, patchNum?: number}} file
- * @return {!Array}
- */
- getAllDraftsForFile(file) {
- let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
- if (file.basePath) {
- allDrafts = allDrafts.concat(
- this.getAllDraftsForPath(file.basePath, file.patchNum)
- );
- }
- return allDrafts;
- }
-
- /**
- * Get the comments (with drafts and robot comments) for a path and
- * patch-range. Returns an object with left and right properties mapping to
- * arrays of comments in on either side of the patch range for that path.
- *
- * @param {!string} path
- * @param {!Gerrit.PatchRange} patchRange The patch-range object containing patchNum
- * and basePatchNum properties to represent the range.
- * @param {Object=} opt_projectConfig Optional project config object to
- * include in the meta sub-object.
- * @return {!Gerrit.CommentsBySide}
- */
- getCommentsBySideForPath(path,
- patchRange, opt_projectConfig) {
- let comments = [];
- let drafts = [];
- let robotComments = [];
- if (this.comments && this.comments[path]) {
- comments = this.comments[path];
- }
- if (this.drafts && this.drafts[path]) {
- drafts = this.drafts[path];
- }
- if (this.robotComments && this.robotComments[path]) {
- robotComments = this.robotComments[path];
- }
-
- drafts.forEach(d => { d.__draft = true; });
-
- const all = comments.concat(drafts).concat(robotComments)
- .map(c => { return {...c}; });
-
- const baseComments = all.filter(c =>
- this._isInBaseOfPatchRange(c, patchRange));
- const revisionComments = all.filter(c =>
- this._isInRevisionOfPatchRange(c, patchRange));
-
- return {
- meta: {
- changeNum: this._changeNum,
- path,
- patchRange,
- projectConfig: opt_projectConfig,
- },
- left: baseComments,
- right: revisionComments,
- };
- }
-
- /**
- * Get the comments (with drafts and robot comments) for a file and
- * patch-range. Returns an object with left and right properties mapping to
- * arrays of comments in on either side of the patch range for that path.
- *
- * // TODO(taoalpha): maybe merge *ForPath so find all comments in one pass
- *
- * @param {!{path: string, basePath?: string, patchNum?: number}} file
- * @param {!Gerrit.PatchRange} patchRange The patch-range object containing patchNum
- * and basePatchNum properties to represent the range.
- * @param {Object=} opt_projectConfig Optional project config object to
- * include in the meta sub-object.
- * @return {!Gerrit.CommentsBySide}
- */
- getCommentsBySideForFile(file, patchRange, opt_projectConfig) {
- const comments = this.getCommentsBySideForPath(
- file.path, patchRange, opt_projectConfig
- );
- if (file.basePath) {
- const commentsForBasePath = this.getCommentsBySideForPath(
- file.basePath, patchRange, opt_projectConfig
- );
- // merge in the left and right
- comments.left = comments.left.concat(commentsForBasePath.left);
- comments.right = comments.right.concat(commentsForBasePath.right);
- }
- return comments;
- }
-
- /**
- * @param {!Object} comments Object keyed by file, with a value of an array
- * of comments left on that file.
- * @return {!Array} A flattened list of all comments, where each comment
- * also includes the file that it was left on, which was the key of the
- * originall object.
- */
- _commentObjToArrayWithFile(comments) {
- let commentArr = [];
- for (const file of Object.keys(comments)) {
- const commentsForFile = [];
- for (const comment of comments[file]) {
- commentsForFile.push(Object.assign({__path: file}, comment));
- }
- commentArr = commentArr.concat(commentsForFile);
- }
- return commentArr;
- }
-
- _commentObjToArray(comments) {
- let commentArr = [];
- for (const file of Object.keys(comments)) {
- commentArr = commentArr.concat(comments[file]);
- }
- return commentArr;
- }
-
- /**
- * Computes a string counting the number of commens in a given file.
- *
- * @param {{path: string, basePath?: string, patchNum?: number}} file
- * @return {number}
- */
- computeCommentCount(file) {
- if (file.path) {
- return this.getAllCommentsForFile(file).length;
- }
- const allComments = this.getAllPublishedComments(file.patchNum);
- return this._commentObjToArray(allComments).length;
- }
-
- /**
- * Computes a string counting the number of draft comments in the entire
- * change, optionally filtered by path and/or patchNum.
- *
- * @param {?{path: string, basePath?: string, patchNum?: number}} file
- * @return {number}
- */
- computeDraftCount(file) {
- if (file && file.path) {
- return this.getAllDraftsForFile(file).length;
- }
- const allDrafts = this.getAllDrafts(file && file.patchNum);
- return this._commentObjToArray(allDrafts).length;
- }
-
- /**
- * Computes a number of unresolved comment threads in a given file and path.
- *
- * @param {{path: string, basePath?: string, patchNum?: number}} file
- * @return {number}
- */
- computeUnresolvedNum(file) {
- let comments = [];
- let drafts = [];
-
- if (file.path) {
- comments = this.getAllCommentsForFile(file);
- drafts = this.getAllDraftsForFile(file);
- } else {
- comments = this._commentObjToArray(
- this.getAllPublishedComments(file.patchNum));
- }
-
- comments = comments.concat(drafts);
-
- const threads = this.getCommentThreads(this._sortComments(comments));
-
- const unresolvedThreads = threads
- .filter(thread =>
- thread.comments.length &&
- thread.comments[thread.comments.length - 1].unresolved);
-
- return unresolvedThreads.length;
- }
-
- getAllThreadsForChange() {
- const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
- const sortedComments = this._sortComments(comments);
- return this.getCommentThreads(sortedComments);
- }
-
- _sortComments(comments) {
- return comments.slice(0)
- .sort(
- (c1, c2) => {
- const dateDiff =
- parseDate(c1.updated) - parseDate(c2.updated);
- if (dateDiff) {
- return dateDiff;
- }
- return c1.id - c2.id;
- }
- );
- }
-
- /**
- * Computes all of the comments in thread format.
- *
- * @param {!Array} comments sorted by updated timestamp.
- * @return {!Array}
- */
- getCommentThreads(comments) {
- const threads = [];
- const idThreadMap = {};
- for (const comment of comments) {
- // If the comment is in reply to another comment, find that comment's
- // thread and append to it.
- if (comment.in_reply_to) {
- const thread = idThreadMap[comment.in_reply_to];
- if (thread) {
- thread.comments.push(comment);
- idThreadMap[comment.id] = thread;
- continue;
- }
- }
-
- // Otherwise, this comment starts its own thread.
- const newThread = {
- comments: [comment],
- patchNum: comment.patch_set,
- path: comment.__path,
- line: comment.line,
- rootId: comment.id,
- };
- if (comment.side) {
- newThread.commentSide = comment.side;
- }
- threads.push(newThread);
- idThreadMap[comment.id] = newThread;
- }
- return threads;
- }
-
- /**
- * Whether the given comment should be included in the base side of the
- * given patch range.
- *
- * @param {!Object} comment
- * @param {!Gerrit.PatchRange} range
- * @return {boolean}
- */
- _isInBaseOfPatchRange(comment, range) {
- // If the base of the patch range is a parent of a merge, and the comment
- // appears on a specific parent then only show the comment if the parent
- // index of the comment matches that of the range.
- if (comment.parent && comment.side === PARENT) {
- return isMergeParent(range.basePatchNum) &&
- comment.parent === getParentIndex(range.basePatchNum);
- }
-
- // If the base of the range is the parent of the patch:
- if (range.basePatchNum === PARENT &&
- comment.side === PARENT &&
- patchNumEquals(comment.patch_set, range.patchNum)) {
- return true;
- }
- // If the base of the range is not the parent of the patch:
- return range.basePatchNum !== PARENT &&
- comment.side !== PARENT &&
- patchNumEquals(comment.patch_set, range.basePatchNum);
- }
-
- /**
- * Whether the given comment should be included in the revision side of the
- * given patch range.
- *
- * @param {!Object} comment
- * @param {!Gerrit.PatchRange} range
- * @return {boolean}
- */
- _isInRevisionOfPatchRange(comment,
- range) {
- return comment.side !== PARENT &&
- patchNumEquals(comment.patch_set, range.patchNum);
- }
-
- /**
- * Whether the given comment should be included in the given patch range.
- *
- * @param {!Object} comment
- * @param {!Gerrit.PatchRange} range
- * @return {boolean|undefined}
- */
- _isInPatchRange(comment, range) {
- return this._isInBaseOfPatchRange(comment, range) ||
- this._isInRevisionOfPatchRange(comment, range);
- }
-}
-
-/**
- * @extends PolymerElement
- */
-class GrCommentApi extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-comment-api'; }
-
- static get properties() {
- return {
- _changeComments: Object,
- };
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('reload-drafts',
- changeNum => this.reloadDrafts(changeNum));
- }
-
- /**
- * Load all comments (with drafts and robot comments) for the given change
- * number. The returned promise resolves when the comments have loaded, but
- * does not yield the comment data.
- *
- * @param {number} changeNum
- * @return {!Promise<!Object>}
- */
- loadAll(changeNum) {
- const promises = [];
- promises.push(this.$.restAPI.getDiffComments(changeNum));
- promises.push(this.$.restAPI.getDiffRobotComments(changeNum));
- promises.push(this.$.restAPI.getDiffDrafts(changeNum));
-
- return Promise.all(promises).then(([comments, robotComments, drafts]) => {
- this._changeComments = new ChangeComments(comments,
- robotComments, drafts, changeNum);
- return this._changeComments;
- });
- }
-
- /**
- * Re-initialize _changeComments with a new ChangeComments object, that
- * uses the previous values for comments and robot comments, but fetches
- * updated draft comments.
- *
- * @param {number} changeNum
- * @return {!Promise<!Object>}
- */
- reloadDrafts(changeNum) {
- if (!this._changeComments) {
- return this.loadAll(changeNum);
- }
- return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => {
- this._changeComments = new ChangeComments(this._changeComments.comments,
- this._changeComments.robotComments, drafts, changeNum);
- return this._changeComments;
- });
- }
-}
-
-customElements.define(GrCommentApi.is, GrCommentApi);
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
new file mode 100644
index 0000000..367bbd6
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.ts
@@ -0,0 +1,684 @@
+/**
+ * @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 '../../shared/gr-rest-api-interface/gr-rest-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-comment-api_html';
+import {
+ getParentIndex,
+ isMergeParent,
+ patchNumEquals,
+} from '../../../utils/patch-set-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+ CommentBasics,
+ ConfigInfo,
+ ParentPatchSetNum,
+ PatchRange,
+ PatchSetNum,
+ PathToRobotCommentsInfoMap,
+ RobotCommentInfo,
+ UrlEncodedCommentId,
+ NumericChangeId,
+ RevisionId,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {CommentSide} from '../../../constants/constants';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ Comment,
+ CommentMap,
+ CommentThread,
+ DraftInfo,
+ isUnresolved,
+ sortComments,
+ UIComment,
+ UIDraft,
+ UIHuman,
+ UIRobot,
+} from '../../../utils/comment-util';
+import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
+
+export type CommentIdToCommentThreadMap = {
+ [urlEncodedCommentId: string]: CommentThread;
+};
+
+export interface TwoSidesComments {
+ // TODO(TS): remove meta - it is not used anywhere
+ meta: {
+ changeNum: NumericChangeId;
+ path: string;
+ patchRange: PatchRange;
+ projectConfig?: ConfigInfo;
+ };
+ left: UIComment[];
+ right: UIComment[];
+}
+
+export class ChangeComments {
+ private readonly _comments: {[path: string]: UIHuman[]};
+
+ private readonly _robotComments: {[path: string]: UIRobot[]};
+
+ private readonly _drafts: {[path: string]: UIDraft[]};
+
+ private readonly _changeNum: NumericChangeId;
+
+ /**
+ * Construct a change comments object, which can be data-bound to child
+ * elements of that which uses the gr-comment-api.
+ */
+ constructor(
+ comments: {[path: string]: UIHuman[]} | undefined,
+ robotComments: {[path: string]: UIRobot[]} | undefined,
+ drafts: {[path: string]: UIDraft[]} | undefined,
+ changeNum: NumericChangeId
+ ) {
+ this._comments = this._addPath(comments);
+ this._robotComments = this._addPath(robotComments);
+ this._drafts = this._addPath(drafts);
+ // TODO(TS): remove changeNum param - it is not used anywhere
+ this._changeNum = changeNum;
+ }
+
+ /**
+ * Add path info to every comment as CommentInfo returned
+ * from server does not have that.
+ *
+ * TODO(taoalpha): should consider changing BE to send path
+ * back within CommentInfo
+ */
+ _addPath<T>(
+ comments: {[path: string]: T[]} = {}
+ ): {[path: string]: Array<T & {path: string}>} {
+ const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
+ for (const filePath of Object.keys(comments)) {
+ const allCommentsForPath = comments[filePath] || [];
+ if (allCommentsForPath.length) {
+ updatedComments[filePath] = allCommentsForPath.map(comment => {
+ return {...comment, path: filePath};
+ });
+ }
+ }
+ return updatedComments;
+ }
+
+ get comments() {
+ return this._comments;
+ }
+
+ get drafts() {
+ return this._drafts;
+ }
+
+ get robotComments() {
+ return this._robotComments;
+ }
+
+ findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
+ if (!commentId) return undefined;
+ const findComment = (comments: {[path: string]: UIComment[]}) => {
+ let comment;
+ for (const path of Object.keys(comments)) {
+ comment = comment || comments[path].find(c => c.id === commentId);
+ }
+ return comment;
+ };
+ return (
+ findComment(this._comments) ||
+ findComment(this._robotComments) ||
+ findComment(this._drafts)
+ );
+ }
+
+ /**
+ * Get an object mapping file paths to a boolean representing whether that
+ * path contains diff comments in the given patch set (including drafts and
+ * robot comments).
+ *
+ * Paths with comments are mapped to true, whereas paths without comments
+ * are not mapped.
+ *
+ * @param patchRange The patch-range object containing
+ * patchNum and basePatchNum properties to represent the range.
+ */
+ getPaths(patchRange?: PatchRange): CommentMap {
+ const responses: {[path: string]: UIComment[]}[] = [
+ this.comments,
+ this.drafts,
+ this.robotComments,
+ ];
+ const commentMap: CommentMap = {};
+ for (const response of responses) {
+ for (const path in response) {
+ if (
+ hasOwnProperty(response, path) &&
+ response[path].some(c => {
+ // If don't care about patch range, we know that the path exists.
+ if (!patchRange) {
+ return true;
+ }
+ return this._isInPatchRange(c, patchRange);
+ })
+ ) {
+ commentMap[path] = true;
+ }
+ }
+ }
+ return commentMap;
+ }
+
+ /**
+ * Gets all the comments and robot comments for the given change.
+ */
+ getAllPublishedComments(patchNum?: PatchSetNum) {
+ return this.getAllComments(false, patchNum);
+ }
+
+ /**
+ * Gets all the comments for a particular thread group. Used for refreshing
+ * comments after the thread group has already been built.
+ */
+ getCommentsForThread(rootId: UrlEncodedCommentId) {
+ const allThreads = this.getAllThreadsForChange();
+ const threadMatch = allThreads.find(t => t.rootId === rootId);
+
+ // In the event that a single draft comment was removed by the thread-list
+ // and the diff view is updating comments, there will no longer be a thread
+ // found. In this case, return null.
+ return threadMatch ? threadMatch.comments : null;
+ }
+
+ /**
+ * Gets all the comments and robot comments for the given change.
+ */
+ getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
+ const paths = this.getPaths();
+ const publishedComments: {[path: string]: CommentBasics[]} = {};
+ for (const path of Object.keys(paths)) {
+ publishedComments[path] = this.getAllCommentsForPath(
+ path,
+ patchNum,
+ includeDrafts
+ );
+ }
+ return publishedComments;
+ }
+
+ /**
+ * Gets all the drafts for the given change.
+ */
+ getAllDrafts(patchNum?: PatchSetNum) {
+ const paths = this.getPaths();
+ const drafts: {[path: string]: UIDraft[]} = {};
+ for (const path of Object.keys(paths)) {
+ drafts[path] = this.getAllDraftsForPath(path, patchNum);
+ }
+ return drafts;
+ }
+
+ /**
+ * Get the comments (robot comments) for a path and optional patch num.
+ *
+ * This method will always return a new shallow copy of all comments,
+ * so manipulation on one copy won't affect other copies.
+ *
+ */
+ getAllCommentsForPath(
+ path: string,
+ patchNum?: PatchSetNum,
+ includeDrafts?: boolean
+ ): Comment[] {
+ const comments: Comment[] = this._comments[path] || [];
+ const robotComments = this._robotComments[path] || [];
+ let allComments = comments.concat(robotComments);
+ if (includeDrafts) {
+ const drafts = this.getAllDraftsForPath(path);
+ allComments = allComments.concat(drafts);
+ }
+ if (patchNum) {
+ allComments = allComments.filter(c =>
+ patchNumEquals(c.patch_set, patchNum)
+ );
+ }
+ return allComments.map(c => {
+ return {...c};
+ });
+ }
+
+ /**
+ * Get the comments (robot comments) for a file.
+ *
+ * // TODO(taoalpha): maybe merge in *ForPath
+ */
+ getAllCommentsForFile(file: PatchSetFile, includeDrafts?: boolean) {
+ let allComments = this.getAllCommentsForPath(
+ file.path,
+ file.patchNum,
+ includeDrafts
+ );
+
+ if (file.basePath) {
+ allComments = allComments.concat(
+ this.getAllCommentsForPath(file.basePath, file.patchNum, includeDrafts)
+ );
+ }
+
+ return allComments;
+ }
+
+ /**
+ * Get the drafts for a path and optional patch num.
+ *
+ * This will return a shallow copy of all drafts every time,
+ * so changes on any copy will not affect other copies.
+ */
+ getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
+ let comments = this._drafts[path] || [];
+ if (patchNum) {
+ comments = comments.filter(c => patchNumEquals(c.patch_set, patchNum));
+ }
+ return comments.map(c => {
+ return {...c, __draft: true};
+ });
+ }
+
+ /**
+ * Get the drafts for a file.
+ *
+ * // TODO(taoalpha): maybe merge in *ForPath
+ */
+ getAllDraftsForFile(file: PatchSetFile): Comment[] {
+ let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
+ if (file.basePath) {
+ allDrafts = allDrafts.concat(
+ this.getAllDraftsForPath(file.basePath, file.patchNum)
+ );
+ }
+ return allDrafts;
+ }
+
+ /**
+ * Get the comments (with drafts and robot comments) for a path and
+ * patch-range. Returns an object with left and right properties mapping to
+ * arrays of comments in on either side of the patch range for that path.
+ *
+ * @param patchRange The patch-range object containing patchNum
+ * and basePatchNum properties to represent the range.
+ * @param projectConfig Optional project config object to
+ * include in the meta sub-object.
+ */
+ getCommentsBySideForPath(
+ path: string,
+ patchRange: PatchRange,
+ projectConfig?: ConfigInfo
+ ): TwoSidesComments {
+ let comments: Comment[] = [];
+ let drafts: DraftInfo[] = [];
+ let robotComments: RobotCommentInfo[] = [];
+ if (this.comments && this.comments[path]) {
+ comments = this.comments[path];
+ }
+ if (this.drafts && this.drafts[path]) {
+ drafts = this.drafts[path];
+ }
+ if (this.robotComments && this.robotComments[path]) {
+ robotComments = this.robotComments[path];
+ }
+
+ drafts.forEach(d => {
+ d.__draft = true;
+ });
+
+ const all: Comment[] = comments
+ .concat(drafts)
+ .concat(robotComments)
+ .map(c => {
+ return {...c};
+ });
+
+ const baseComments = all.filter(c =>
+ this._isInBaseOfPatchRange(c, patchRange)
+ );
+ const revisionComments = all.filter(c =>
+ this._isInRevisionOfPatchRange(c, patchRange)
+ );
+
+ return {
+ meta: {
+ changeNum: this._changeNum,
+ path,
+ patchRange,
+ projectConfig,
+ },
+ left: baseComments,
+ right: revisionComments,
+ };
+ }
+
+ /**
+ * Get the comments (with drafts and robot comments) for a file and
+ * patch-range. Returns an object with left and right properties mapping to
+ * arrays of comments in on either side of the patch range for that path.
+ *
+ * // TODO(taoalpha): maybe merge *ForPath so find all comments in one pass
+ *
+ * @param patchRange The patch-range object containing patchNum
+ * and basePatchNum properties to represent the range.
+ * @param projectConfig Optional project config object to
+ * include in the meta sub-object.
+ */
+ getCommentsBySideForFile(
+ file: PatchSetFile,
+ patchRange: PatchRange,
+ projectConfig?: ConfigInfo
+ ): TwoSidesComments {
+ const comments = this.getCommentsBySideForPath(
+ file.path,
+ patchRange,
+ projectConfig
+ );
+ if (file.basePath) {
+ const commentsForBasePath = this.getCommentsBySideForPath(
+ file.basePath,
+ patchRange,
+ projectConfig
+ );
+ // merge in the left and right
+ comments.left = comments.left.concat(commentsForBasePath.left);
+ comments.right = comments.right.concat(commentsForBasePath.right);
+ }
+ return comments;
+ }
+
+ /**
+ * @param comments Object keyed by file, with a value of an array
+ * of comments left on that file.
+ * @return A flattened list of all comments, where each comment
+ * also includes the file that it was left on, which was the key of the
+ * originall object.
+ */
+ _commentObjToArrayWithFile<T>(comments: {
+ [path: string]: T[];
+ }): Array<T & {__path: string}> {
+ let commentArr: Array<T & {__path: string}> = [];
+ for (const file of Object.keys(comments)) {
+ const commentsForFile: Array<T & {__path: string}> = [];
+ for (const comment of comments[file]) {
+ commentsForFile.push({...comment, __path: file});
+ }
+ commentArr = commentArr.concat(commentsForFile);
+ }
+ return commentArr;
+ }
+
+ _commentObjToArray<T>(comments: {[path: string]: T[]}): T[] {
+ let commentArr: T[] = [];
+ for (const file of Object.keys(comments)) {
+ commentArr = commentArr.concat(comments[file]);
+ }
+ return commentArr;
+ }
+
+ /**
+ * Computes the number of comment threads in a given file or patch.
+ */
+ computeCommentThreadCount(file: PatchSetFile | PatchNumOnly) {
+ let comments: Comment[] = [];
+ if (isPatchSetFile(file)) {
+ comments = this.getAllCommentsForFile(file);
+ } else {
+ comments = this._commentObjToArray(
+ this.getAllPublishedComments(file.patchNum)
+ );
+ }
+
+ return this.getCommentThreads(comments).length;
+ }
+
+ /**
+ * Computes a string counting the number of draft comments in the entire
+ * change, optionally filtered by path and/or patchNum.
+ */
+ computeDraftCount(file?: PatchSetFile | PatchNumOnly) {
+ if (file && isPatchSetFile(file)) {
+ return this.getAllDraftsForFile(file).length;
+ }
+ const allDrafts = this.getAllDrafts(file && file.patchNum);
+ return this._commentObjToArray(allDrafts).length;
+ }
+
+ /**
+ * Computes a number of unresolved comment threads in a given file and path.
+ */
+ computeUnresolvedNum(file: PatchSetFile | PatchNumOnly) {
+ let comments: Comment[] = [];
+ let drafts: Comment[] = [];
+
+ if (isPatchSetFile(file)) {
+ comments = this.getAllCommentsForFile(file);
+ drafts = this.getAllDraftsForFile(file);
+ } else {
+ comments = this._commentObjToArray(
+ this.getAllPublishedComments(file.patchNum)
+ );
+ }
+
+ comments = comments.concat(drafts);
+ const threads = this.getCommentThreads(sortComments(comments));
+ const unresolvedThreads = threads.filter(isUnresolved);
+ return unresolvedThreads.length;
+ }
+
+ getAllThreadsForChange() {
+ const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
+ const sortedComments = sortComments(comments);
+ return this.getCommentThreads(sortedComments);
+ }
+
+ /**
+ * Computes all of the comments in thread format.
+ *
+ * @param comments sorted by updated timestamp.
+ */
+ getCommentThreads(comments: UIComment[]) {
+ const threads: CommentThread[] = [];
+ const idThreadMap: CommentIdToCommentThreadMap = {};
+ for (const comment of comments) {
+ if (!comment.id) continue;
+ // If the comment is in reply to another comment, find that comment's
+ // thread and append to it.
+ if (comment.in_reply_to) {
+ const thread = idThreadMap[comment.in_reply_to];
+ if (thread) {
+ thread.comments.push(comment);
+ idThreadMap[comment.id] = thread;
+ continue;
+ }
+ }
+
+ // Otherwise, this comment starts its own thread.
+ if (!comment.__path && !comment.path) {
+ throw new Error('Comment missing required "path".');
+ }
+ const newThread: CommentThread = {
+ comments: [comment],
+ patchNum: comment.patch_set,
+ path: comment.__path || comment.path!,
+ line: comment.line,
+ rootId: comment.id,
+ };
+ if (comment.side) {
+ newThread.commentSide = comment.side;
+ }
+ threads.push(newThread);
+ idThreadMap[comment.id] = newThread;
+ }
+ return threads;
+ }
+
+ /**
+ * Whether the given comment should be included in the base side of the
+ * given patch range.
+ */
+ _isInBaseOfPatchRange(comment: CommentBasics, range: PatchRange) {
+ // If the base of the patch range is a parent of a merge, and the comment
+ // appears on a specific parent then only show the comment if the parent
+ // index of the comment matches that of the range.
+ if (comment.parent && comment.side === CommentSide.PARENT) {
+ return (
+ isMergeParent(range.basePatchNum) &&
+ comment.parent === getParentIndex(range.basePatchNum)
+ );
+ }
+
+ // If the base of the range is the parent of the patch:
+ if (
+ range.basePatchNum === ParentPatchSetNum &&
+ comment.side === CommentSide.PARENT &&
+ patchNumEquals(comment.patch_set, range.patchNum)
+ ) {
+ return true;
+ }
+ // If the base of the range is not the parent of the patch:
+ return (
+ range.basePatchNum !== ParentPatchSetNum &&
+ comment.side !== CommentSide.PARENT &&
+ patchNumEquals(comment.patch_set, range.basePatchNum)
+ );
+ }
+
+ /**
+ * Whether the given comment should be included in the revision side of the
+ * given patch range.
+ */
+ _isInRevisionOfPatchRange(comment: CommentBasics, range: PatchRange) {
+ return (
+ comment.side !== CommentSide.PARENT &&
+ patchNumEquals(comment.patch_set, range.patchNum)
+ );
+ }
+
+ /**
+ * Whether the given comment should be included in the given patch range.
+ */
+ _isInPatchRange(comment: CommentBasics, range: PatchRange): boolean {
+ return (
+ this._isInBaseOfPatchRange(comment, range) ||
+ this._isInRevisionOfPatchRange(comment, range)
+ );
+ }
+}
+
+// TODO(TS): move findCommentById out of class
+export const _testOnly_findCommentById =
+ ChangeComments.prototype.findCommentById;
+
+export interface GrCommentApi {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-comment-api')
+export class GrCommentApi extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ _changeComments?: ChangeComments;
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('reload-drafts', changeNum =>
+ // TODO(TS): This is a wrong code, however keep it as is for now
+ // If changeNum param in ChangeComments is removed, this also must be
+ // removed
+ this.reloadDrafts((changeNum as unknown) as NumericChangeId)
+ );
+ }
+
+ getPortedComments(changeNum: NumericChangeId, revision?: RevisionId) {
+ if (!revision) revision = 'current';
+ return Promise.all([
+ this.$.restAPI.getPortedComments(changeNum, revision),
+ this.$.restAPI.getPortedDrafts(changeNum, revision),
+ ]).then(result => {
+ return {
+ portedComments: result[0],
+ portedDrafts: result[1],
+ };
+ });
+ }
+
+ /**
+ * Load all comments (with drafts and robot comments) for the given change
+ * number. The returned promise resolves when the comments have loaded, but
+ * does not yield the comment data.
+ */
+ loadAll(changeNum: NumericChangeId) {
+ const promises = [];
+ promises.push(this.$.restAPI.getDiffComments(changeNum));
+ promises.push(this.$.restAPI.getDiffRobotComments(changeNum));
+ promises.push(this.$.restAPI.getDiffDrafts(changeNum));
+
+ return Promise.all(promises).then(([comments, robotComments, drafts]) => {
+ this._changeComments = new ChangeComments(
+ comments,
+ // TODO(TS): Promise.all somehow resolve all types to
+ // PathToCommentsInfoMap given its PathToRobotCommentsInfoMap
+ // returned from the second promise
+ robotComments as PathToRobotCommentsInfoMap,
+ drafts,
+ changeNum
+ );
+ return this._changeComments;
+ });
+ }
+
+ /**
+ * Re-initialize _changeComments with a new ChangeComments object, that
+ * uses the previous values for comments and robot comments, but fetches
+ * updated draft comments.
+ */
+ reloadDrafts(changeNum: NumericChangeId) {
+ if (!this._changeComments) {
+ return this.loadAll(changeNum);
+ }
+ const oldChangeComments = this._changeComments;
+ return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => {
+ this._changeComments = new ChangeComments(
+ oldChangeComments.comments,
+ (oldChangeComments.robotComments as unknown) as PathToRobotCommentsInfoMap,
+ drafts,
+ changeNum
+ );
+ return this._changeComments;
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-comment-api': GrCommentApi;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
index 0a7c3b5..be6f646 100644
--- a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.js
@@ -17,6 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-comment-api.js';
+import {ChangeComments} from './gr-comment-api.js';
const basicFixture = fixtureFromElement('gr-comment-api');
@@ -216,36 +217,38 @@
}
setup(() => {
- element._changeComments._drafts = {
+ const drafts = {
'file/one': [
{
- id: 11,
+ id: '12',
patch_set: 2,
side: PARENT,
line: 1,
updated: makeTime(3),
},
{
- id: 12,
- in_reply_to: 2,
+ id: '13',
+ in_reply_to: '04',
patch_set: 2,
line: 1,
- updated: makeTime(3),
+ // Draft gets lower timestamp than published comment, because we
+ // want to test that the draft still gets sorted to the end.
+ updated: makeTime(2),
},
],
'file/two': [
{
- id: 5,
+ id: '05',
patch_set: 3,
line: 1,
updated: makeTime(3),
},
],
};
- element._changeComments._robotComments = {
+ const robotComments = {
'file/one': [
{
- id: 1,
+ id: '01',
patch_set: 2,
side: PARENT,
line: 1,
@@ -257,40 +260,63 @@
end_character: 2,
},
}, {
- id: 2,
- in_reply_to: 4,
+ id: '02',
+ in_reply_to: '04',
patch_set: 2,
unresolved: true,
line: 1,
- updated: makeTime(2),
+ updated: makeTime(3),
},
],
};
- element._changeComments._comments = {
+ const comments = {
'file/one': [
- {id: 3, patch_set: 2, side: PARENT, line: 2, updated: makeTime(1)},
- {id: 4, patch_set: 2, line: 1, updated: makeTime(1)},
+ {
+ id: '03',
+ patch_set: 2,
+ side: PARENT,
+ line: 2,
+ updated: makeTime(1),
+ },
+ {id: '04', patch_set: 2, line: 1, updated: makeTime(1)},
],
'file/two': [
- {id: 5, patch_set: 2, line: 2, updated: makeTime(1)},
- {id: 6, patch_set: 3, line: 2, updated: makeTime(1)},
+ {id: '05', patch_set: 2, line: 2, updated: makeTime(1)},
+ {id: '06', patch_set: 3, line: 2, updated: makeTime(1)},
],
'file/three': [
{
- id: 7,
+ id: '07',
patch_set: 2,
side: PARENT,
- unresolved: true,
+ unresolved: false,
line: 1,
updated: makeTime(1),
},
- {id: 8, patch_set: 3, line: 1, updated: makeTime(1)},
+ {
+ id: '08',
+ patch_set: 2,
+ side: PARENT,
+ unresolved: true,
+ in_reply_to: '07',
+ line: 1,
+ updated: makeTime(1),
+ },
+ {id: '09', patch_set: 3, line: 1, updated: makeTime(1)},
],
'file/four': [
- {id: 9, patch_set: 5, side: PARENT, line: 1, updated: makeTime(1)},
- {id: 10, patch_set: 5, line: 1, updated: makeTime(1)},
+ {
+ id: '10',
+ patch_set: 5,
+ side: PARENT,
+ line: 1,
+ updated: makeTime(1),
+ },
+ {id: '11', patch_set: 5, line: 1, updated: makeTime(1)},
],
};
+ element._changeComments =
+ new ChangeComments(comments, robotComments, drafts, 1234);
});
test('getPaths', () => {
@@ -391,9 +417,7 @@
});
test('computeUnresolvedNum w/ non-linear thread', () => {
- element._changeComments._drafts = {};
- element._changeComments._robotComments = {};
- element._changeComments._comments = {
+ const comments = {
path: [{
id: '9c6ba3c6_28b7d467',
patch_set: 1,
@@ -419,23 +443,24 @@
unresolved: false,
}],
};
+ element._changeComments = new ChangeComments(comments, {}, {}, 1234);
assert.equal(
element._changeComments.computeUnresolvedNum(1, 'path'), 0);
});
- test('computeCommentCount', () => {
+ test('computeCommentThreadCount', () => {
assert.equal(element._changeComments
- .computeCommentCount({
+ .computeCommentThreadCount({
patchNum: 2,
path: 'file/one',
- }), 4);
+ }), 3);
assert.equal(element._changeComments
- .computeCommentCount({
+ .computeCommentThreadCount({
patchNum: 1,
path: 'file/one',
}), 0);
assert.equal(element._changeComments
- .computeCommentCount({
+ .computeCommentThreadCount({
patchNum: 2,
path: 'file/three',
}), 1);
@@ -498,7 +523,7 @@
{
comments: [
{
- id: 1,
+ id: '01',
patch_set: 2,
side: 'PARENT',
line: 1,
@@ -509,6 +534,7 @@
end_line: 2,
end_character: 2,
},
+ path: 'file/one',
__path: 'file/one',
},
],
@@ -516,14 +542,15 @@
patchNum: 2,
path: 'file/one',
line: 1,
- rootId: 1,
+ rootId: '01',
}, {
comments: [
{
- id: 3,
+ id: '03',
patch_set: 2,
side: 'PARENT',
line: 2,
+ path: 'file/one',
__path: 'file/one',
updated: '2013-02-26 15:01:43.986000000',
},
@@ -532,45 +559,49 @@
patchNum: 2,
path: 'file/one',
line: 2,
- rootId: 3,
+ rootId: '03',
}, {
comments: [
{
- id: 4,
+ id: '04',
patch_set: 2,
line: 1,
+ path: 'file/one',
__path: 'file/one',
updated: '2013-02-26 15:01:43.986000000',
},
{
- id: 2,
- in_reply_to: 4,
+ id: '02',
+ in_reply_to: '04',
patch_set: 2,
unresolved: true,
line: 1,
+ path: 'file/one',
__path: 'file/one',
+ updated: '2013-02-26 15:03:43.986000000',
+ },
+ {
+ id: '13',
+ in_reply_to: '04',
+ patch_set: 2,
+ line: 1,
+ path: 'file/one',
+ __path: 'file/one',
+ __draft: true,
updated: '2013-02-26 15:02:43.986000000',
},
- {
- id: 12,
- in_reply_to: 2,
- patch_set: 2,
- line: 1,
- __path: 'file/one',
- __draft: true,
- updated: '2013-02-26 15:03:43.986000000',
- },
],
patchNum: 2,
path: 'file/one',
line: 1,
- rootId: 4,
+ rootId: '04',
}, {
comments: [
{
- id: 5,
+ id: '05',
patch_set: 2,
line: 2,
+ path: 'file/two',
__path: 'file/two',
updated: '2013-02-26 15:01:43.986000000',
},
@@ -578,13 +609,14 @@
patchNum: 2,
path: 'file/two',
line: 2,
- rootId: 5,
+ rootId: '05',
}, {
comments: [
{
- id: 6,
+ id: '06',
patch_set: 3,
line: 2,
+ path: 'file/two',
__path: 'file/two',
updated: '2013-02-26 15:01:43.986000000',
},
@@ -592,15 +624,27 @@
patchNum: 3,
path: 'file/two',
line: 2,
- rootId: 6,
+ rootId: '06',
}, {
comments: [
{
- id: 7,
+ id: '07',
+ patch_set: 2,
+ side: 'PARENT',
+ unresolved: false,
+ line: 1,
+ path: 'file/three',
+ __path: 'file/three',
+ updated: '2013-02-26 15:01:43.986000000',
+ },
+ {
+ id: '08',
+ in_reply_to: '07',
patch_set: 2,
side: 'PARENT',
unresolved: true,
line: 1,
+ path: 'file/three',
__path: 'file/three',
updated: '2013-02-26 15:01:43.986000000',
},
@@ -609,13 +653,14 @@
patchNum: 2,
path: 'file/three',
line: 1,
- rootId: 7,
+ rootId: '07',
}, {
comments: [
{
- id: 8,
+ id: '09',
patch_set: 3,
line: 1,
+ path: 'file/three',
__path: 'file/three',
updated: '2013-02-26 15:01:43.986000000',
},
@@ -623,14 +668,15 @@
patchNum: 3,
path: 'file/three',
line: 1,
- rootId: 8,
+ rootId: '09',
}, {
comments: [
{
- id: 9,
+ id: '10',
patch_set: 5,
side: 'PARENT',
line: 1,
+ path: 'file/four',
__path: 'file/four',
updated: '2013-02-26 15:01:43.986000000',
},
@@ -639,49 +685,52 @@
patchNum: 5,
path: 'file/four',
line: 1,
- rootId: 9,
+ rootId: '10',
}, {
comments: [
{
- id: 10,
+ id: '11',
patch_set: 5,
line: 1,
+ path: 'file/four',
__path: 'file/four',
updated: '2013-02-26 15:01:43.986000000',
},
],
- rootId: 10,
+ rootId: '11',
patchNum: 5,
path: 'file/four',
line: 1,
}, {
comments: [
{
- id: 5,
+ id: '05',
patch_set: 3,
line: 1,
+ path: 'file/two',
__path: 'file/two',
__draft: true,
updated: '2013-02-26 15:03:43.986000000',
},
],
- rootId: 5,
+ rootId: '05',
patchNum: 3,
path: 'file/two',
line: 1,
}, {
comments: [
{
- id: 11,
+ id: '12',
patch_set: 2,
side: 'PARENT',
line: 1,
+ path: 'file/one',
__path: 'file/one',
__draft: true,
updated: '2013-02-26 15:03:43.986000000',
},
],
- rootId: 11,
+ rootId: '12',
commentSide: 'PARENT',
patchNum: 2,
path: 'file/one',
@@ -696,47 +745,51 @@
let expectedComments = [
{
__path: 'file/one',
- id: 4,
+ path: 'file/one',
+ id: '04',
patch_set: 2,
line: 1,
updated: '2013-02-26 15:01:43.986000000',
},
{
__path: 'file/one',
- id: 2,
- in_reply_to: 4,
+ path: 'file/one',
+ id: '02',
+ in_reply_to: '04',
patch_set: 2,
unresolved: true,
line: 1,
- updated: '2013-02-26 15:02:43.986000000',
+ updated: '2013-02-26 15:03:43.986000000',
},
{
__path: 'file/one',
+ path: 'file/one',
__draft: true,
- id: 12,
- in_reply_to: 2,
+ id: '13',
+ in_reply_to: '04',
patch_set: 2,
line: 1,
- updated: '2013-02-26 15:03:43.986000000',
+ updated: '2013-02-26 15:02:43.986000000',
},
];
- assert.deepEqual(element._changeComments.getCommentsForThread(4),
+ assert.deepEqual(element._changeComments.getCommentsForThread('04'),
expectedComments);
expectedComments = [{
- id: 11,
+ id: '12',
patch_set: 2,
side: 'PARENT',
line: 1,
+ path: 'file/one',
__path: 'file/one',
__draft: true,
updated: '2013-02-26 15:03:43.986000000',
}];
- assert.deepEqual(element._changeComments.getCommentsForThread(11),
+ assert.deepEqual(element._changeComments.getCommentsForThread('12'),
expectedComments);
- assert.deepEqual(element._changeComments.getCommentsForThread(1000),
+ assert.deepEqual(element._changeComments.getCommentsForThread('1000'),
null);
});
});
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
deleted file mode 100644
index 86537e8..0000000
--- a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.js
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 {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-coverage-layer_html.js';
-import {CoverageType} from '../../../types/types.js';
-
-const TOOLTIP_MAP = new Map([
- [CoverageType.COVERED, 'Covered by tests.'],
- [CoverageType.NOT_COVERED, 'Not covered by tests.'],
- [CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
- [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
-]);
-
-/** @extends PolymerElement */
-class GrCoverageLayer extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-coverage-layer'; }
-
- static get properties() {
- return {
- /**
- * Must be sorted by code_range.start_line.
- * Must only contain ranges that match the side.
- *
- * @type {!Array<!Gerrit.CoverageRange>}
- */
- coverageRanges: Array,
- side: String,
-
- /**
- * We keep track of the line number from the previous annotate() call,
- * and also of the index of the coverage range that had matched.
- * annotate() calls are coming in with increasing line numbers and
- * coverage ranges are sorted by line number. So this is a very simple
- * and efficient way for finding the coverage range that matches a given
- * line number.
- */
- _lineNumber: {
- type: Number,
- value: 0,
- },
- _index: {
- type: Number,
- value: 0,
- },
- };
- }
-
- /**
- * Layer method to add annotations to a line.
- *
- * @param {!HTMLElement} el Not used for this layer.
- * @param {!HTMLElement} lineNumberEl The <td> element with the line number.
- * @param {!Object} line Not used for this layer.
- */
- annotate(el, lineNumberEl, line) {
- if (!lineNumberEl || !lineNumberEl.classList.contains(this.side)) {
- return;
- }
- const elementLineNumber = parseInt(
- lineNumberEl.getAttribute('data-value'), 10);
- if (!elementLineNumber || elementLineNumber < 1) return;
-
- // If the line number is smaller than before, then we have to reset our
- // algorithm and start searching the coverage ranges from the beginning.
- // That happens for example when you expand diff sections.
- if (elementLineNumber < this._lineNumber) {
- this._index = 0;
- }
- this._lineNumber = elementLineNumber;
-
- // We simply loop through all the coverage ranges until we find one that
- // matches the line number.
- while (this._index < this.coverageRanges.length) {
- const coverageRange = this.coverageRanges[this._index];
-
- // If the line number has moved past the current coverage range, then
- // try the next coverage range.
- if (this._lineNumber > coverageRange.code_range.end_line) {
- this._index++;
- continue;
- }
-
- // If the line number has not reached the next coverage range (and the
- // range before also did not match), then this line has not been
- // instrumented. Nothing to do for this line.
- if (this._lineNumber < coverageRange.code_range.start_line) {
- return;
- }
-
- // The line number is within the current coverage range. Style it!
- lineNumberEl.classList.add(coverageRange.type);
- lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type);
- return;
- }
- }
-}
-
-customElements.define(GrCoverageLayer.is, GrCoverageLayer);
diff --git a/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
new file mode 100644
index 0000000..6f9705f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-coverage-layer/gr-coverage-layer.ts
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {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-coverage-layer_html';
+import {CoverageRange, CoverageType, DiffLayer} from '../../../types/types';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-coverage-layer': GrCoverageLayer;
+ }
+}
+
+const TOOLTIP_MAP = new Map([
+ [CoverageType.COVERED, 'Covered by tests.'],
+ [CoverageType.NOT_COVERED, 'Not covered by tests.'],
+ [CoverageType.PARTIALLY_COVERED, 'Partially covered by tests.'],
+ [CoverageType.NOT_INSTRUMENTED, 'Not instrumented by any tests.'],
+]);
+
+@customElement('gr-coverage-layer')
+export class GrCoverageLayer
+ extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+ implements DiffLayer {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Must be sorted by code_range.start_line.
+ * Must only contain ranges that match the side.
+ */
+ @property({type: Array})
+ coverageRanges: CoverageRange[] = [];
+
+ @property({type: String})
+ side?: string;
+
+ /**
+ * We keep track of the line number from the previous annotate() call,
+ * and also of the index of the coverage range that had matched.
+ * annotate() calls are coming in with increasing line numbers and
+ * coverage ranges are sorted by line number. So this is a very simple
+ * and efficient way for finding the coverage range that matches a given
+ * line number.
+ */
+ @property({type: Number})
+ _lineNumber = 0;
+
+ @property({type: Number})
+ _index = 0;
+
+ /**
+ * Layer method to add annotations to a line.
+ *
+ * @param _el Not used for this layer. (unused parameter)
+ * @param lineNumberEl The <td> element with the line number.
+ * @param line Not used for this layer.
+ */
+ annotate(_el: HTMLElement, lineNumberEl: HTMLElement) {
+ if (
+ !this.side ||
+ !lineNumberEl ||
+ !lineNumberEl.classList.contains(this.side)
+ ) {
+ return;
+ }
+ let elementLineNumber;
+ const dataValue = lineNumberEl.getAttribute('data-value');
+ if (dataValue) {
+ elementLineNumber = Number(dataValue);
+ }
+ if (!elementLineNumber || elementLineNumber < 1) return;
+
+ // If the line number is smaller than before, then we have to reset our
+ // algorithm and start searching the coverage ranges from the beginning.
+ // That happens for example when you expand diff sections.
+ if (elementLineNumber < this._lineNumber) {
+ this._index = 0;
+ }
+ this._lineNumber = elementLineNumber;
+
+ // We simply loop through all the coverage ranges until we find one that
+ // matches the line number.
+ while (this._index < this.coverageRanges.length) {
+ const coverageRange = this.coverageRanges[this._index];
+
+ // If the line number has moved past the current coverage range, then
+ // try the next coverage range.
+ if (this._lineNumber > coverageRange.code_range.end_line) {
+ this._index++;
+ continue;
+ }
+
+ // If the line number has not reached the next coverage range (and the
+ // range before also did not match), then this line has not been
+ // instrumented. Nothing to do for this line.
+ if (this._lineNumber < coverageRange.code_range.start_line) {
+ return;
+ }
+
+ // The line number is within the current coverage range. Style it!
+ lineNumberEl.classList.add(coverageRange.type);
+ lineNumberEl.title = TOOLTIP_MAP.get(coverageRange.type) || '';
+ return;
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
deleted file mode 100644
index 9a56797..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.js
+++ /dev/null
@@ -1,44 +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 {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
-
-/** @constructor */
-export function GrDiffBuilderBinary(diff, prefs, outputEl) {
- GrDiffBuilderUnified.call(this, diff, prefs, outputEl, []);
-}
-
-GrDiffBuilderBinary.prototype = Object.create(GrDiffBuilderUnified.prototype);
-GrDiffBuilderBinary.prototype.constructor = GrDiffBuilderBinary;
-
-// This method definition is a no-op to satisfy the parent type.
-GrDiffBuilderBinary.prototype.addColumns = function(outputEl, fontSize) {};
-
-GrDiffBuilderBinary.prototype.buildSectionElement = function() {
- const section = this._createElement('tbody', 'binary-diff');
- const fileRow = this._createRow(section, {
- beforeNumber: 'FILE',
- afterNumber: 'FILE',
- type: 'both',
- text: '',
- });
- const contentTd = fileRow.querySelector('td.both.file');
- contentTd.textContent = ' Difference in binary files';
-
- section.appendChild(fileRow);
- return section;
-};
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
new file mode 100644
index 0000000..7a26e77
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-binary.ts
@@ -0,0 +1,40 @@
+/**
+ * @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 {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+
+export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
+ constructor(
+ diff: DiffInfo,
+ prefs: DiffPreferencesInfo,
+ outputEl: HTMLElement
+ ) {
+ super(diff, prefs, outputEl);
+ }
+
+ buildSectionElement(): HTMLElement {
+ const section = this._createElement('tbody', 'binary-diff');
+ const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
+ const fileRow = this._createRow(line);
+ const contentTd = fileRow.querySelector('td.both.file')!;
+ contentTd.textContent = ' Difference in binary files';
+ section.appendChild(fileRow);
+ return section;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
deleted file mode 100644
index bb617f0..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.js
+++ /dev/null
@@ -1,453 +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 '../gr-coverage-layer/gr-coverage-layer.js';
-import '../gr-diff-processor/gr-diff-processor.js';
-import '../../shared/gr-hovercard/gr-hovercard.js';
-import '../gr-ranged-comment-layer/gr-ranged-comment-layer.js';
-import './gr-diff-builder-side-by-side.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-diff-builder-element_html.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffBuilder} from './gr-diff-builder.js';
-import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
-import {GrDiffBuilderImage} from './gr-diff-builder-image.js';
-import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
-import {GrDiffBuilderBinary} from './gr-diff-builder-binary.js';
-import {util} from '../../../scripts/util.js';
-
-const DiffViewMode = {
- SIDE_BY_SIDE: 'SIDE_BY_SIDE',
- UNIFIED: 'UNIFIED_DIFF',
-};
-
-const TRAILING_WHITESPACE_PATTERN = /\s+$/;
-
-// https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
-const COMMIT_MSG_PATH = '/COMMIT_MSG';
-const COMMIT_MSG_LINE_LENGTH = 72;
-
-/**
- * @extends PolymerElement
- */
-class GrDiffBuilderElement extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff-builder'; }
- /**
- * Fired when the diff begins rendering.
- *
- * @event render-start
- */
-
- /**
- * Fired when the diff finishes rendering text content.
- *
- * @event render-content
- */
-
- static get properties() {
- return {
- diff: Object,
- changeNum: String,
- patchNum: String,
- viewMode: String,
- isImageDiff: Boolean,
- baseImage: Object,
- revisionImage: Object,
- parentIndex: Number,
- path: String,
- projectName: String,
-
- _builder: Object,
- _groups: Array,
- _layers: Array,
- _showTabs: Boolean,
- /** @type {!Array<!Gerrit.HoveredRange>} */
- commentRanges: {
- type: Array,
- value: () => [],
- },
- /** @type {!Array<!Gerrit.CoverageRange>} */
- coverageRanges: {
- type: Array,
- value: () => [],
- },
- _leftCoverageRanges: {
- type: Array,
- computed: '_computeLeftCoverageRanges(coverageRanges)',
- },
- _rightCoverageRanges: {
- type: Array,
- computed: '_computeRightCoverageRanges(coverageRanges)',
- },
- /**
- * The promise last returned from `render()` while the asynchronous
- * rendering is running - `null` otherwise. Provides a `cancel()`
- * method that rejects it with `{isCancelled: true}`.
- *
- * @type {?Object}
- */
- _cancelableRenderPromise: Object,
- layers: {
- type: Array,
- value: [],
- },
- };
- }
-
- /** @override */
- detached() {
- super.detached();
- if (this._builder) {
- this._builder.clear();
- }
- }
-
- get diffElement() {
- return this.queryEffectiveChildren('#diffTable');
- }
-
- static get observers() {
- return [
- '_groupsChanged(_groups.splices)',
- ];
- }
-
- _computeLeftCoverageRanges(coverageRanges) {
- return coverageRanges.filter(range => range && range.side === 'left');
- }
-
- _computeRightCoverageRanges(coverageRanges) {
- return coverageRanges.filter(range => range && range.side === 'right');
- }
-
- render(keyLocations, prefs) {
- // Setting up annotation layers must happen after plugins are
- // installed, and |render| satisfies the requirement, however,
- // |attached| doesn't because in the diff view page, the element is
- // attached before plugins are installed.
- this._setupAnnotationLayers();
-
- this._showTabs = !!prefs.show_tabs;
- this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
-
- // Stop the processor if it's running.
- this.cancel();
-
- if (this._builder) {
- this._builder.clear();
- }
- this._builder = this._getDiffBuilder(this.diff, prefs);
-
- this.$.processor.context = prefs.context;
- this.$.processor.keyLocations = keyLocations;
-
- this._clearDiffContent();
- this._builder.addColumns(this.diffElement, prefs.font_size);
-
- const isBinary = !!(this.isImageDiff || this.diff.binary);
-
- this.dispatchEvent(new CustomEvent(
- 'render-start', {bubbles: true, composed: true}));
- this._cancelableRenderPromise = util.makeCancelable(
- this.$.processor.process(this.diff.content, isBinary)
- .then(() => {
- if (this.isImageDiff) {
- this._builder.renderDiff();
- }
- this.dispatchEvent(new CustomEvent('render-content',
- {bubbles: true, composed: true}));
- }));
- return this._cancelableRenderPromise
- .finally(() => { this._cancelableRenderPromise = null; })
- // Mocca testing does not like uncaught rejections, so we catch
- // the cancels which are expected and should not throw errors in
- // tests.
- .catch(e => { if (!e.isCanceled) return Promise.reject(e); });
- }
-
- _setupAnnotationLayers() {
- const layers = [
- this._createTrailingWhitespaceLayer(),
- this._createIntralineLayer(),
- this._createTabIndicatorLayer(),
- this.$.rangeLayer,
- this.$.coverageLayerLeft,
- this.$.coverageLayerRight,
- ];
-
- if (this.layers) {
- layers.push(...this.layers);
- }
- this._layers = layers;
- }
-
- getLineElByChild(node) {
- while (node) {
- if (node instanceof Element) {
- if (node.classList.contains('lineNum')) {
- return node;
- }
- if (node.classList.contains('section')) {
- return null;
- }
- }
- node = node.previousSibling || node.parentElement;
- }
- return null;
- }
-
- getLineNumberByChild(node) {
- const lineEl = this.getLineElByChild(node);
- return lineEl ?
- parseInt(lineEl.getAttribute('data-value'), 10) :
- null;
- }
-
- getContentTdByLine(lineNumber, opt_side, opt_root) {
- return this._builder.getContentTdByLine(lineNumber, opt_side, opt_root);
- }
-
- _getDiffRowByChild(child) {
- while (!child.classList.contains('diff-row') && child.parentElement) {
- child = child.parentElement;
- }
- return child;
- }
-
- getContentTdByLineEl(lineEl) {
- if (!lineEl) return;
- const line = lineEl.getAttribute('data-value');
- const side = this.getSideByLineEl(lineEl);
- // Performance optimization because we already have an element in the
- // correct row
- const row = dom(this._getDiffRowByChild(lineEl));
- return this.getContentTdByLine(line, side, row);
- }
-
- getLineElByNumber(lineNumber, opt_side) {
- const sideSelector = opt_side ? ('.' + opt_side) : '';
- return this.diffElement.querySelector(
- '.lineNum[data-value="' + lineNumber + '"]' + sideSelector);
- }
-
- getContentsByLineRange(startLine, endLine, opt_side) {
- const result = [];
- this._builder.findLinesByRange(startLine, endLine, opt_side, null,
- result);
- return result;
- }
-
- getSideByLineEl(lineEl) {
- return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
- GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
- }
-
- emitGroup(group, sectionEl) {
- this._builder.emitGroup(group, sectionEl);
- }
-
- showContext(newGroups, sectionEl) {
- const groups = this._builder.groups;
-
- const contextIndex = groups.findIndex(group =>
- group.element === sectionEl
- );
- groups.splice(contextIndex, 1, ...newGroups);
-
- for (const newGroup of newGroups) {
- this._builder.emitGroup(newGroup, sectionEl);
- }
- sectionEl.parentNode.removeChild(sectionEl);
-
- this.async(() => this.dispatchEvent(new CustomEvent('render-content', {
- composed: true, bubbles: true,
- })), 1);
- }
-
- cancel() {
- this.$.processor.cancel();
- if (this._cancelableRenderPromise) {
- this._cancelableRenderPromise.cancel();
- this._cancelableRenderPromise = null;
- }
- }
-
- _handlePreferenceError(pref) {
- const message = `The value of the '${pref}' user preference is ` +
- `invalid. Fix in diff preferences`;
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message,
- }, bubbles: true, composed: true}));
- throw Error(`Invalid preference value: ${pref}`);
- }
-
- _getDiffBuilder(diff, prefs) {
- if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
- this._handlePreferenceError('tab size');
- return;
- }
-
- if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
- this._handlePreferenceError('diff width');
- return;
- }
-
- const localPrefs = Object.assign({}, prefs);
- if (this.path === COMMIT_MSG_PATH) {
- // override line_length for commit msg the same way as
- // in gr-diff
- localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
- }
-
- let builder = null;
- if (this.isImageDiff) {
- builder = new GrDiffBuilderImage(
- diff,
- localPrefs,
- this.diffElement,
- this.baseImage,
- this.revisionImage);
- } else if (diff.binary) {
- // If the diff is binary, but not an image.
- return new GrDiffBuilderBinary(
- diff,
- localPrefs,
- this.diffElement);
- } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
- builder = new GrDiffBuilderSideBySide(
- diff,
- localPrefs,
- this.diffElement,
- this._layers
- );
- } else if (this.viewMode === DiffViewMode.UNIFIED) {
- builder = new GrDiffBuilderUnified(
- diff,
- localPrefs,
- this.diffElement,
- this._layers);
- }
- if (!builder) {
- throw Error('Unsupported diff view mode: ' + this.viewMode);
- }
- return builder;
- }
-
- _clearDiffContent() {
- this.diffElement.innerHTML = null;
- }
-
- _groupsChanged(changeRecord) {
- if (!changeRecord) { return; }
- for (const splice of changeRecord.indexSplices) {
- let group;
- for (let i = 0; i < splice.addedCount; i++) {
- group = splice.object[splice.index + i];
- this._builder.groups.push(group);
- this._builder.emitGroup(group);
- }
- }
- }
-
- _createIntralineLayer() {
- return {
- // Take a DIV.contentText element and a line object with intraline
- // differences to highlight and apply them to the element as
- // annotations.
- annotate(contentEl, lineNumberEl, line) {
- const HL_CLASS = 'style-scope gr-diff intraline';
- for (const highlight of line.highlights) {
- // The start and end indices could be the same if a highlight is
- // meant to start at the end of a line and continue onto the
- // next one. Ignore it.
- if (highlight.startIndex === highlight.endIndex) { continue; }
-
- // If endIndex isn't present, continue to the end of the line.
- const endIndex = highlight.endIndex === undefined ?
- line.text.length :
- highlight.endIndex;
-
- GrAnnotation.annotateElement(
- contentEl,
- highlight.startIndex,
- endIndex - highlight.startIndex,
- HL_CLASS);
- }
- },
- };
- }
-
- _createTabIndicatorLayer() {
- const show = () => this._showTabs;
- return {
- annotate(contentEl, lineNumberEl, line) {
- // If visible tabs are disabled, do nothing.
- if (!show()) { return; }
-
- // Find and annotate the locations of tabs.
- const split = line.text.split('\t');
- if (!split) { return; }
- for (let i = 0, pos = 0; i < split.length - 1; i++) {
- // Skip forward by the length of the content
- pos += split[i].length;
-
- GrAnnotation.annotateElement(contentEl, pos, 1,
- 'style-scope gr-diff tab-indicator');
-
- // Skip forward by one tab character.
- pos++;
- }
- },
- };
- }
-
- _createTrailingWhitespaceLayer() {
- const show = function() {
- return this._showTrailingWhitespace;
- }.bind(this);
-
- return {
- annotate(contentEl, lineNumberEl, line) {
- if (!show()) { return; }
-
- const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
- if (match) {
- // Normalize string positions in case there is unicode before or
- // within the match.
- const index = GrAnnotation.getStringLength(
- line.text.substr(0, match.index));
- const length = GrAnnotation.getStringLength(match[0]);
- GrAnnotation.annotateElement(contentEl, index, length,
- 'style-scope gr-diff trailing-whitespace');
- }
- },
- };
- }
-
- setBlame(blame) {
- if (!this._builder || !blame) { return; }
- this._builder.setBlame(blame);
- }
-}
-
-customElements.define(GrDiffBuilderElement.is, GrDiffBuilderElement);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
new file mode 100644
index 0000000..6d81e9b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element.ts
@@ -0,0 +1,552 @@
+/**
+ * @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 '../gr-coverage-layer/gr-coverage-layer';
+import '../gr-diff-processor/gr-diff-processor';
+import '../../shared/gr-hovercard/gr-hovercard';
+import '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import './gr-diff-builder-side-by-side';
+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-diff-builder-element_html';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {GrDiffBuilderImage} from './gr-diff-builder-image';
+import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
+import {GrDiffBuilderBinary} from './gr-diff-builder-binary';
+import {CancelablePromise, util} from '../../../scripts/util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {
+ BlameInfo,
+ DiffInfo,
+ DiffPreferencesInfo,
+ ImageInfo,
+} from '../../../types/common';
+import {CoverageRange, DiffLayer} from '../../../types/types';
+import {
+ GrDiffProcessor,
+ KeyLocations,
+} from '../gr-diff-processor/gr-diff-processor';
+import {
+ CommentRangeLayer,
+ GrRangedCommentLayer,
+} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {GrCoverageLayer} from '../gr-coverage-layer/gr-coverage-layer';
+import {Side} from '../../../constants/constants';
+import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup} from '../gr-diff/gr-diff-group';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {getLineNumber} from '../gr-diff/gr-diff-utils';
+
+const DiffViewMode = {
+ SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+ UNIFIED: 'UNIFIED_DIFF',
+};
+
+const TRAILING_WHITESPACE_PATTERN = /\s+$/;
+
+// https://gerrit.googlesource.com/gerrit/+/234616a8627334686769f1de989d286039f4d6a5/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js#740
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+export interface GrDiffBuilderElement {
+ $: {
+ processor: GrDiffProcessor;
+ rangeLayer: GrRangedCommentLayer;
+ coverageLayerLeft: GrCoverageLayer;
+ coverageLayerRight: GrCoverageLayer;
+ };
+}
+
+@customElement('gr-diff-builder')
+export class GrDiffBuilderElement extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the diff begins rendering.
+ *
+ * @event render-start
+ */
+
+ /**
+ * Fired when the diff finishes rendering text content.
+ *
+ * @event render-content
+ */
+
+ @property({type: Object})
+ diff?: DiffInfo;
+
+ @property({type: String})
+ changeNum?: string;
+
+ @property({type: String})
+ patchNum?: string;
+
+ @property({type: String})
+ viewMode?: string;
+
+ @property({type: Boolean})
+ isImageDiff?: boolean;
+
+ @property({type: Object})
+ baseImage: ImageInfo | null = null;
+
+ @property({type: Object})
+ revisionImage: ImageInfo | null = null;
+
+ @property({type: Number})
+ parentIndex?: number;
+
+ @property({type: String})
+ path?: string;
+
+ @property({type: Object})
+ _builder?: GrDiffBuilder;
+
+ @property({type: Array})
+ _groups: GrDiffGroup[] = [];
+
+ /**
+ * Layers passed in from the outside.
+ */
+ @property({type: Array})
+ layers: DiffLayer[] = [];
+
+ /**
+ * All layers, both from the outside and the default ones.
+ */
+ @property({type: Array})
+ _layers: DiffLayer[] = [];
+
+ @property({type: Boolean})
+ _showTabs?: boolean;
+
+ @property({type: Boolean})
+ _showTrailingWhitespace?: boolean;
+
+ @property({type: Array})
+ commentRanges: CommentRangeLayer[] = [];
+
+ @property({type: Array})
+ coverageRanges: CoverageRange[] = [];
+
+ @property({type: Boolean})
+ useNewContextControls = false;
+
+ @property({
+ type: Array,
+ computed: '_computeLeftCoverageRanges(coverageRanges)',
+ })
+ _leftCoverageRanges?: CoverageRange[];
+
+ @property({
+ type: Array,
+ computed: '_computeRightCoverageRanges(coverageRanges)',
+ })
+ _rightCoverageRanges?: CoverageRange[];
+
+ /**
+ * The promise last returned from `render()` while the asynchronous
+ * rendering is running - `null` otherwise. Provides a `cancel()`
+ * method that rejects it with `{isCancelled: true}`.
+ */
+ @property({type: Object})
+ _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
+
+ /** @override */
+ detached() {
+ super.detached();
+ if (this._builder) {
+ this._builder.clear();
+ }
+ }
+
+ get diffElement() {
+ return this.queryEffectiveChildren('#diffTable') as HTMLTableElement;
+ }
+
+ _computeLeftCoverageRanges(coverageRanges: CoverageRange[]) {
+ return coverageRanges.filter(range => range && range.side === 'left');
+ }
+
+ _computeRightCoverageRanges(coverageRanges: CoverageRange[]) {
+ return coverageRanges.filter(range => range && range.side === 'right');
+ }
+
+ render(keyLocations: KeyLocations, prefs: DiffPreferencesInfo) {
+ // Setting up annotation layers must happen after plugins are
+ // installed, and |render| satisfies the requirement, however,
+ // |attached| doesn't because in the diff view page, the element is
+ // attached before plugins are installed.
+ this._setupAnnotationLayers();
+
+ this._showTabs = !!prefs.show_tabs;
+ this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
+
+ // Stop the processor if it's running.
+ this.cancel();
+
+ if (this._builder) {
+ this._builder.clear();
+ }
+ if (!this.diff) {
+ throw Error('Cannot render a diff without DiffInfo.');
+ }
+ this._builder = this._getDiffBuilder(this.diff, prefs);
+
+ this.$.processor.context = prefs.context;
+ this.$.processor.keyLocations = keyLocations;
+
+ this._clearDiffContent();
+ this._builder.addColumns(this.diffElement, prefs.font_size);
+
+ const isBinary = !!(this.isImageDiff || this.diff.binary);
+
+ this.dispatchEvent(
+ new CustomEvent('render-start', {bubbles: true, composed: true})
+ );
+ this._cancelableRenderPromise = util.makeCancelable(
+ this.$.processor.process(this.diff.content, isBinary).then(() => {
+ if (this.isImageDiff) {
+ (this._builder as GrDiffBuilderImage).renderDiff();
+ }
+ this.dispatchEvent(
+ new CustomEvent('render-content', {bubbles: true, composed: true})
+ );
+ })
+ );
+ return (
+ this._cancelableRenderPromise
+ .finally(() => {
+ this._cancelableRenderPromise = null;
+ })
+ // Mocca testing does not like uncaught rejections, so we catch
+ // the cancels which are expected and should not throw errors in
+ // tests.
+ .catch(e => {
+ if (!e.isCanceled) return Promise.reject(e);
+ return;
+ })
+ );
+ }
+
+ _setupAnnotationLayers() {
+ const layers: DiffLayer[] = [
+ this._createTrailingWhitespaceLayer(),
+ this._createIntralineLayer(),
+ this._createTabIndicatorLayer(),
+ this.$.rangeLayer,
+ this.$.coverageLayerLeft,
+ this.$.coverageLayerRight,
+ ];
+
+ if (this.layers) {
+ layers.push(...this.layers);
+ }
+ this._layers = layers;
+ }
+
+ getLineElByChild(node?: Node): HTMLElement | null {
+ while (node) {
+ if (node instanceof Element) {
+ if (node.classList.contains('lineNum')) {
+ return node as HTMLElement;
+ }
+ if (node.classList.contains('section')) {
+ return null;
+ }
+ }
+ node = node.previousSibling ?? node.parentElement ?? undefined;
+ }
+ return null;
+ }
+
+ getLineNumberByChild(node: Node) {
+ const lineEl = this.getLineElByChild(node);
+ return getLineNumber(lineEl);
+ }
+
+ getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
+ if (!this._builder) return null;
+ return this._builder.getContentTdByLine(lineNumber, side, root);
+ }
+
+ _getDiffRowByChild(child: Element) {
+ while (!child.classList.contains('diff-row') && child.parentElement) {
+ child = child.parentElement;
+ }
+ return child;
+ }
+
+ getContentTdByLineEl(lineEl?: Element): Element | null {
+ if (!lineEl) return null;
+ const line = getLineNumber(lineEl);
+ if (!line) return null;
+ const side = this.getSideByLineEl(lineEl);
+ // Performance optimization because we already have an element in the
+ // correct row
+ const row = this._getDiffRowByChild(lineEl);
+ return this.getContentTdByLine(line, side, row);
+ }
+
+ getLineElByNumber(lineNumber: string | number, side?: Side) {
+ const sideSelector = side ? '.' + side : '';
+ return this.diffElement.querySelector(
+ `.lineNum[data-value="${lineNumber}"]${sideSelector}`
+ );
+ }
+
+ getSideByLineEl(lineEl: Element) {
+ return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
+ }
+
+ emitGroup(group: GrDiffGroup, sectionEl: HTMLElement) {
+ if (!this._builder) return;
+ this._builder.emitGroup(group, sectionEl);
+ }
+
+ showContext(newGroups: GrDiffGroup[], sectionEl: HTMLElement) {
+ if (!this._builder) return;
+ const groups = this._builder.groups;
+
+ const contextIndex = groups.findIndex(group => group.element === sectionEl);
+ groups.splice(contextIndex, 1, ...newGroups);
+
+ for (const newGroup of newGroups) {
+ this._builder.emitGroup(newGroup, sectionEl);
+ }
+ if (sectionEl.parentNode) {
+ sectionEl.parentNode.removeChild(sectionEl);
+ }
+
+ this.async(
+ () =>
+ this.dispatchEvent(
+ new CustomEvent('render-content', {
+ composed: true,
+ bubbles: true,
+ })
+ ),
+ 1
+ );
+ }
+
+ cancel() {
+ this.$.processor.cancel();
+ if (this._cancelableRenderPromise) {
+ this._cancelableRenderPromise.cancel();
+ this._cancelableRenderPromise = null;
+ }
+ }
+
+ _handlePreferenceError(pref: string): never {
+ const message =
+ `The value of the '${pref}' user preference is ` +
+ 'invalid. Fix in diff preferences';
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message,
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ throw Error(`Invalid preference value: ${pref}`);
+ }
+
+ _getDiffBuilder(diff: DiffInfo, prefs: DiffPreferencesInfo): GrDiffBuilder {
+ if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+ this._handlePreferenceError('tab size');
+ }
+
+ if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+ this._handlePreferenceError('diff width');
+ }
+
+ const localPrefs = {...prefs};
+ if (this.path === COMMIT_MSG_PATH) {
+ // override line_length for commit msg the same way as
+ // in gr-diff
+ localPrefs.line_length = COMMIT_MSG_LINE_LENGTH;
+ }
+
+ let builder = null;
+ if (this.isImageDiff) {
+ builder = new GrDiffBuilderImage(
+ diff,
+ localPrefs,
+ this.diffElement,
+ this.baseImage,
+ this.revisionImage
+ );
+ } else if (diff.binary) {
+ // If the diff is binary, but not an image.
+ return new GrDiffBuilderBinary(diff, localPrefs, this.diffElement);
+ } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
+ builder = new GrDiffBuilderSideBySide(
+ diff,
+ localPrefs,
+ this.diffElement,
+ this._layers,
+ this.useNewContextControls
+ );
+ } else if (this.viewMode === DiffViewMode.UNIFIED) {
+ builder = new GrDiffBuilderUnified(
+ diff,
+ localPrefs,
+ this.diffElement,
+ this._layers,
+ this.useNewContextControls
+ );
+ }
+ if (!builder) {
+ throw Error(`Unsupported diff view mode: ${this.viewMode}`);
+ }
+ return builder;
+ }
+
+ _clearDiffContent() {
+ this.diffElement.innerHTML = '';
+ }
+
+ @observe('_groups.splices')
+ _groupsChanged(changeRecord: PolymerSpliceChange<GrDiffGroup[]>) {
+ if (!changeRecord || !this._builder) {
+ return;
+ }
+ for (const splice of changeRecord.indexSplices) {
+ let group;
+ for (let i = 0; i < splice.addedCount; i++) {
+ group = splice.object[splice.index + i];
+ this._builder.groups.push(group);
+ this._builder.emitGroup(group, null);
+ }
+ }
+ }
+
+ _createIntralineLayer(): DiffLayer {
+ return {
+ // Take a DIV.contentText element and a line object with intraline
+ // differences to highlight and apply them to the element as
+ // annotations.
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ const HL_CLASS = 'style-scope gr-diff intraline';
+ for (const highlight of line.highlights) {
+ // The start and end indices could be the same if a highlight is
+ // meant to start at the end of a line and continue onto the
+ // next one. Ignore it.
+ if (highlight.startIndex === highlight.endIndex) {
+ continue;
+ }
+
+ // If endIndex isn't present, continue to the end of the line.
+ const endIndex =
+ highlight.endIndex === undefined
+ ? line.text.length
+ : highlight.endIndex;
+
+ GrAnnotation.annotateElement(
+ contentEl,
+ highlight.startIndex,
+ endIndex - highlight.startIndex,
+ HL_CLASS
+ );
+ }
+ },
+ };
+ }
+
+ _createTabIndicatorLayer(): DiffLayer {
+ const show = () => this._showTabs;
+ return {
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ // If visible tabs are disabled, do nothing.
+ if (!show()) {
+ return;
+ }
+
+ // Find and annotate the locations of tabs.
+ const split = line.text.split('\t');
+ if (!split) {
+ return;
+ }
+ for (let i = 0, pos = 0; i < split.length - 1; i++) {
+ // Skip forward by the length of the content
+ pos += split[i].length;
+
+ GrAnnotation.annotateElement(
+ contentEl,
+ pos,
+ 1,
+ 'style-scope gr-diff tab-indicator'
+ );
+
+ // Skip forward by one tab character.
+ pos++;
+ }
+ },
+ };
+ }
+
+ _createTrailingWhitespaceLayer(): DiffLayer {
+ const show = () => {
+ return this._showTrailingWhitespace;
+ };
+
+ return {
+ annotate(contentEl: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ if (!show()) {
+ return;
+ }
+
+ const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+ if (match) {
+ // Normalize string positions in case there is unicode before or
+ // within the match.
+ const index = GrAnnotation.getStringLength(
+ line.text.substr(0, match.index)
+ );
+ const length = GrAnnotation.getStringLength(match[0]);
+ GrAnnotation.annotateElement(
+ contentEl,
+ index,
+ length,
+ 'style-scope gr-diff trailing-whitespace'
+ );
+ }
+ },
+ };
+ }
+
+ setBlame(blame: BlameInfo[] | null) {
+ if (!this._builder) return;
+ this._builder.setBlame(blame);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-builder': GrDiffBuilderElement;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
index dfa4599..b10b251 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-element_test.js
@@ -22,11 +22,12 @@
import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
import './gr-diff-builder-element.js';
import {stubBaseUrl} from '../../../test/test-utils.js';
-import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
import {GrDiffBuilder} from './gr-diff-builder.js';
+import {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
const basicFixture = fixtureFromTemplate(html`
@@ -80,68 +81,128 @@
});
suite('context control', () => {
- function createContextLine(options) {
+ function createContextGroups(options) {
const offset = options.offset || 0;
const numLines = options.count || 10;
const lines = [];
for (let i = 0; i < numLines; i++) {
- const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+ const line = new GrDiffLine(GrDiffLineType.BOTH);
line.beforeNumber = offset + i + 1;
line.afterNumber = offset + i + 1;
line.text = 'lorem upsum';
lines.push(line);
}
- return {
- contextGroups: [new GrDiffGroup(GrDiffGroup.Type.BOTH, lines)],
- };
+ return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
}
- test('no +10 buttons for 10 or less lines', () => {
- const contextLine = createContextLine({count: 10});
+ function createContextSectionForGroups(options) {
+ const section = document.createElement('div');
+ builder._createContextControls(
+ section, createContextGroups(options), DiffViewMode.UNIFIED);
+ return section;
+ }
- const td = builder._createContextControl({}, contextLine);
- const buttons = td.querySelectorAll('gr-button.showContext');
+ suite('old style', () => {
+ setup(() => {
+ builder = new GrDiffBuilder(
+ {content: []}, prefs, null, [], false /* useNewContextControls */);
+ });
- assert.equal(buttons.length, 1);
- assert.equal(dom(buttons[0]).textContent, 'Show 10 common lines');
+ test('no +10 buttons for 10 or less lines', () => {
+ const section = createContextSectionForGroups({count: 10});
+ const buttons = section.querySelectorAll('gr-button.showContext');
+
+ assert.equal(buttons.length, 1);
+ assert.equal(buttons[0].textContent, 'Show 10 common lines');
+ });
+
+ test('context control at the top', () => {
+ builder._numLinesLeft = 50;
+ const section = createContextSectionForGroups({offset: 0, count: 20});
+ const buttons = section.querySelectorAll('gr-button.showContext');
+
+ assert.equal(buttons.length, 2);
+ assert.equal(buttons[0].textContent, 'Show 20 common lines');
+ assert.equal(buttons[1].textContent, '+10 below');
+ });
+
+ test('context control in the middle', () => {
+ builder._numLinesLeft = 50;
+ const section = createContextSectionForGroups({offset: 10, count: 20});
+ const buttons = section.querySelectorAll('gr-button.showContext');
+
+ assert.equal(buttons.length, 3);
+ assert.equal(buttons[0].textContent, '+10 above');
+ assert.equal(buttons[1].textContent, 'Show 20 common lines');
+ assert.equal(buttons[2].textContent, '+10 below');
+ });
+
+ test('context control at the bottom', () => {
+ builder._numLinesLeft = 50;
+ const section = createContextSectionForGroups({offset: 30, count: 20});
+ const buttons = section.querySelectorAll('gr-button.showContext');
+
+ assert.equal(buttons.length, 2);
+ assert.equal(buttons[0].textContent, '+10 above');
+ assert.equal(buttons[1].textContent, 'Show 20 common lines');
+ });
});
- test('context control at the top', () => {
- const contextLine = createContextLine({offset: 0, count: 20});
+ suite('new style', () => {
+ setup(() => {
+ builder = new GrDiffBuilder(
+ {content: []}, prefs, null, [], true /* useNewContextControls */);
+ });
- builder._numLinesLeft = 50;
- const td = builder._createContextControl({}, contextLine);
- const buttons = td.querySelectorAll('gr-button.showContext');
+ test('no +10 buttons for 10 or less lines', () => {
+ const section = createContextSectionForGroups({count: 10});
+ const buttons = section.querySelectorAll('gr-button.showContext');
- assert.equal(buttons.length, 2);
- assert.equal(dom(buttons[0]).textContent, 'Show 20 common lines');
- assert.equal(dom(buttons[1]).textContent, '+10 below');
- });
+ assert.equal(buttons.length, 1);
+ assert.equal(buttons[0].textContent, '+10 common lines');
+ });
- test('context control in the middle', () => {
- const contextLine = createContextLine({offset: 10, count: 20});
+ test('context control at the top', () => {
+ builder._numLinesLeft = 50;
+ const section = createContextSectionForGroups({offset: 0, count: 20});
+ const buttons = section.querySelectorAll('gr-button.showContext');
- builder._numLinesLeft = 50;
- const td = builder._createContextControl({}, contextLine);
- const buttons = td.querySelectorAll('gr-button.showContext');
+ assert.equal(buttons.length, 2);
+ assert.equal(buttons[0].textContent, '+20 common lines');
+ assert.equal(buttons[1].textContent, '+10');
- assert.equal(buttons.length, 3);
- assert.equal(dom(buttons[0]).textContent, '+10 above');
- assert.equal(dom(buttons[1]).textContent, 'Show 20 common lines');
- assert.equal(dom(buttons[2]).textContent, '+10 below');
- });
+ assert.include([...buttons[0].classList.values()], 'belowButton');
+ assert.include([...buttons[1].classList.values()], 'belowButton');
+ });
- test('context control at the top', () => {
- const contextLine = createContextLine({offset: 30, count: 20});
+ test('context control in the middle', () => {
+ builder._numLinesLeft = 50;
+ const section = createContextSectionForGroups({offset: 10, count: 20});
+ const buttons = section.querySelectorAll('gr-button.showContext');
- builder._numLinesLeft = 50;
- const td = builder._createContextControl({}, contextLine);
- const buttons = td.querySelectorAll('gr-button.showContext');
+ assert.equal(buttons.length, 3);
+ assert.equal(buttons[0].textContent, '+20 common lines');
+ assert.equal(buttons[1].textContent, '+10');
+ assert.equal(buttons[2].textContent, '+10');
- assert.equal(buttons.length, 2);
- assert.equal(dom(buttons[0]).textContent, '+10 above');
- assert.equal(dom(buttons[1]).textContent, 'Show 20 common lines');
+ assert.include([...buttons[0].classList.values()], 'centeredButton');
+ assert.include([...buttons[1].classList.values()], 'aboveButton');
+ assert.include([...buttons[2].classList.values()], 'belowButton');
+ });
+
+ test('context control at the bottom', () => {
+ builder._numLinesLeft = 50;
+ const section = createContextSectionForGroups({offset: 30, count: 20});
+ const buttons = section.querySelectorAll('gr-button.showContext');
+
+ assert.equal(buttons.length, 2);
+ assert.equal(buttons[0].textContent, '+20 common lines');
+ assert.equal(buttons[1].textContent, '+10');
+
+ assert.include([...buttons[0].classList.values()], 'aboveButton');
+ assert.include([...buttons[1].classList.values()], 'aboveButton');
+ });
});
});
@@ -292,27 +353,24 @@
test('tab wrapper style', () => {
const pattern = new RegExp('^<span class="style-scope gr-diff tab" ' +
- 'style="(?:-moz-)?tab-size: (\\d+);">\\t<\\/span>$');
+ 'style="((?:-moz-)?tab-size: (\\d+);.?)+">\\t<\\/span>$');
for (const size of [1, 3, 8, 55]) {
const html = builder._getTabWrapper(size).outerHTML;
expect(html).to.match(pattern);
- assert.equal(html.match(pattern)[1], size);
+ assert.equal(html.match(pattern)[2], size);
}
});
- test('_handlePreferenceError called with invalid preference', () => {
- sinon.stub(element, '_handlePreferenceError');
+ test('_handlePreferenceError throws with invalid preference', () => {
const prefs = {tab_size: 0};
- element._getDiffBuilder(element.diff, prefs);
- assert.isTrue(element._handlePreferenceError.lastCall
- .calledWithExactly('tab size'));
+ assert.throws(() => element._getDiffBuilder(element.diff, prefs));
});
test('_handlePreferenceError triggers alert and javascript error', () => {
const errorStub = sinon.stub();
element.addEventListener('show-alert', errorStub);
- assert.throws(element._handlePreferenceError.bind(element, 'tab size'));
+ assert.throws(() => element._handlePreferenceError('tab size'));
assert.equal(errorStub.lastCall.args[0].detail.message,
`The value of the 'tab size' user preference is invalid. ` +
`Fix in diff preferences`);
@@ -320,30 +378,30 @@
suite('_isTotal', () => {
test('is total for add', () => {
- const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+ const group = new GrDiffGroup(GrDiffGroupType.DELTA);
for (let idx = 0; idx < 10; idx++) {
- group.addLine(new GrDiffLine(GrDiffLine.Type.ADD));
+ group.addLine(new GrDiffLine(GrDiffLineType.ADD));
}
assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
});
test('is total for remove', () => {
- const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+ const group = new GrDiffGroup(GrDiffGroupType.DELTA);
for (let idx = 0; idx < 10; idx++) {
- group.addLine(new GrDiffLine(GrDiffLine.Type.REMOVE));
+ group.addLine(new GrDiffLine(GrDiffLineType.REMOVE));
}
assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
});
test('not total for empty', () => {
- const group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+ const group = new GrDiffGroup(GrDiffGroupType.BOTH);
assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
});
test('not total for non-delta', () => {
- const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+ const group = new GrDiffGroup(GrDiffGroupType.DELTA);
for (let idx = 0; idx < 10; idx++) {
- group.addLine(new GrDiffLine(GrDiffLine.Type.BOTH));
+ group.addLine(new GrDiffLine(GrDiffLineType.BOTH));
}
assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
});
@@ -843,7 +901,7 @@
outputEl = element.queryEffectiveChildren('#diffTable');
keyLocations = {left: {}, right: {}};
sinon.stub(element, '_getDiffBuilder').callsFake(() => {
- const builder = new GrDiffBuilder({content}, prefs, outputEl);
+ const builder = new GrDiffBuilderSideBySide({content}, prefs, outputEl);
sinon.stub(builder, 'addColumns');
builder.buildSectionElement = function(group) {
const section = document.createElement('stub');
@@ -1013,10 +1071,13 @@
test('_renderContentByRange notexistent elements', () => {
const spy = sinon.spy(builder, '_createTextEl');
+ sinon.stub(builder, '_getLineNumberEl').returns(
+ document.createElement('div')
+ );
sinon.stub(builder, 'findLinesByRange').callsFake(
(s, e, d, lines, elements) => {
// Add a line and a corresponding element.
- lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH));
const tr = document.createElement('tr');
const td = document.createElement('td');
const el = document.createElement('div');
@@ -1025,8 +1086,8 @@
elements.push(el);
// Add 2 lines without corresponding elements.
- lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
- lines.push(new GrDiffLine(GrDiffLine.Type.BOTH));
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH));
+ lines.push(new GrDiffLine(GrDiffLineType.BOTH));
});
builder._renderContentByRange(1, 10, 'left');
@@ -1176,6 +1237,7 @@
});
test('_getBlameCommitForBaseLine', () => {
+ sinon.stub(builder, '_getBlameByLineNum').returns(null);
builder.setBlame(mockBlame);
assert.isOk(builder._getBlameCommitForBaseLine(1));
assert.equal(builder._getBlameCommitForBaseLine(1).id, 'commit 1');
@@ -1199,11 +1261,11 @@
const mocbBlameCell = document.createElement('span');
const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
.returns(mocbBlameCell);
- const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+ const line = new GrDiffLine(GrDiffLineType.BOTH);
line.beforeNumber = 3;
line.afterNumber = 5;
- const result = builder._createBlameCell(line);
+ const result = builder._createBlameCell(line.beforeNumber);
assert.isTrue(getBlameStub.calledWithExactly(3));
assert.equal(result.getAttribute('data-line-number'), '3');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
deleted file mode 100644
index 6983af0..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ /dev/null
@@ -1,179 +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 {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-// MIME types for images we allow showing. Do not include SVG, it can contain
-// arbitrary JavaScript.
-const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
-
-/** @constructor */
-export function GrDiffBuilderImage(diff, prefs, outputEl, baseImage,
- revisionImage) {
- GrDiffBuilderSideBySide.call(this, diff, prefs, outputEl, []);
- this._baseImage = baseImage;
- this._revisionImage = revisionImage;
-}
-
-GrDiffBuilderImage.prototype = Object.create(
- GrDiffBuilderSideBySide.prototype);
-GrDiffBuilderImage.prototype.constructor = GrDiffBuilderImage;
-
-GrDiffBuilderImage.prototype.renderDiff = function() {
- const section = this._createElement('tbody', 'image-diff');
-
- this._emitImagePair(section);
- this._emitImageLabels(section);
-
- this._outputEl.appendChild(section);
- this._outputEl.appendChild(this._createEndpoint());
-};
-
-GrDiffBuilderImage.prototype._createEndpoint = function() {
- const tbody = this._createElement('tbody');
- const tr = this._createElement('tr');
- const td = this._createElement('td');
-
- // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
- // column limit.
- td.setAttribute('colspan', '4');
- const endpoint = this._createElement('gr-endpoint-decorator');
- const endpointDomApi = dom(endpoint);
- endpointDomApi.setAttribute('name', 'image-diff');
- endpointDomApi.appendChild(
- this._createEndpointParam('baseImage', this._baseImage));
- endpointDomApi.appendChild(
- this._createEndpointParam('revisionImage', this._revisionImage));
- td.appendChild(endpoint);
- tr.appendChild(td);
- tbody.appendChild(tr);
- return tbody;
-};
-
-GrDiffBuilderImage.prototype._createEndpointParam = function(name, value) {
- const endpointParam = this._createElement('gr-endpoint-param');
- endpointParam.setAttribute('name', name);
- endpointParam.value = value;
- return endpointParam;
-};
-
-GrDiffBuilderImage.prototype._emitImagePair = function(section) {
- const tr = this._createElement('tr');
-
- tr.appendChild(this._createElement('td', 'left lineNum blank'));
- tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
-
- tr.appendChild(this._createElement('td', 'right lineNum blank'));
- tr.appendChild(this._createImageCell(
- this._revisionImage, 'right', section));
-
- section.appendChild(tr);
-};
-
-GrDiffBuilderImage.prototype._createImageCell = function(image, className,
- section) {
- const td = this._createElement('td', className);
- if (image && IMAGE_MIME_PATTERN.test(image.type)) {
- const imageEl = this._createElement('img');
- imageEl.onload = function() {
- image._height = imageEl.naturalHeight;
- image._width = imageEl.naturalWidth;
- this._updateImageLabel(section, className, image);
- }.bind(this);
- imageEl.setAttribute('src', `data:${image.type};base64, ${image.body}`);
- imageEl.addEventListener('error', () => {
- imageEl.remove();
- td.textContent = '[Image failed to load]';
- });
- td.appendChild(imageEl);
- }
- return td;
-};
-
-GrDiffBuilderImage.prototype._updateImageLabel = function(section, className,
- image) {
- const label = dom(section)
- .querySelector('.' + className + ' span.label');
- this._setLabelText(label, image);
-};
-
-GrDiffBuilderImage.prototype._setLabelText = function(label, image) {
- label.textContent = this._getImageLabel(image);
-};
-
-GrDiffBuilderImage.prototype._emitImageLabels = function(section) {
- const tr = this._createElement('tr');
-
- let addNamesInLabel = false;
-
- if (this._baseImage && this._revisionImage &&
- this._baseImage._name !== this._revisionImage._name) {
- addNamesInLabel = true;
- }
-
- tr.appendChild(this._createElement('td', 'left lineNum blank'));
- let td = this._createElement('td', 'left');
- let label = this._createElement('label');
- let nameSpan;
- let labelSpan = this._createElement('span', 'label');
-
- if (addNamesInLabel) {
- nameSpan = this._createElement('span', 'name');
- nameSpan.textContent = this._baseImage._name;
- label.appendChild(nameSpan);
- label.appendChild(this._createElement('br'));
- }
-
- this._setLabelText(labelSpan, this._baseImage, addNamesInLabel);
-
- label.appendChild(labelSpan);
- td.appendChild(label);
- tr.appendChild(td);
-
- tr.appendChild(this._createElement('td', 'right lineNum blank'));
- td = this._createElement('td', 'right');
- label = this._createElement('label');
- labelSpan = this._createElement('span', 'label');
-
- if (addNamesInLabel) {
- nameSpan = this._createElement('span', 'name');
- nameSpan.textContent = this._revisionImage._name;
- label.appendChild(nameSpan);
- label.appendChild(this._createElement('br'));
- }
-
- this._setLabelText(labelSpan, this._revisionImage, addNamesInLabel);
-
- label.appendChild(labelSpan);
- td.appendChild(label);
- tr.appendChild(td);
-
- section.appendChild(tr);
-};
-
-GrDiffBuilderImage.prototype._getImageLabel = function(image) {
- if (image) {
- const type = image.type || image._expectedType;
- if (image._width && image._height) {
- return image._width + '×' + image._height + ' ' + type;
- } else {
- return type;
- }
- }
- return 'No image';
-};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
new file mode 100644
index 0000000..15264ea
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.ts
@@ -0,0 +1,194 @@
+/**
+ * @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 {GrDiffBuilderSideBySide} from './gr-diff-builder-side-by-side';
+import {DiffInfo, DiffPreferencesInfo, ImageInfo} from '../../../types/common';
+import {GrEndpointParam} from '../../plugins/gr-endpoint-param/gr-endpoint-param';
+
+// MIME types for images we allow showing. Do not include SVG, it can contain
+// arbitrary JavaScript.
+const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|x-icon|jpeg|jpg|png|tiff|webp)$/;
+
+export class GrDiffBuilderImage extends GrDiffBuilderSideBySide {
+ constructor(
+ diff: DiffInfo,
+ prefs: DiffPreferencesInfo,
+ outputEl: HTMLElement,
+ private readonly _baseImage: ImageInfo | null,
+ private readonly _revisionImage: ImageInfo | null
+ ) {
+ super(diff, prefs, outputEl, []);
+ }
+
+ public renderDiff() {
+ const section = this._createElement('tbody', 'image-diff');
+
+ this._emitImagePair(section);
+ this._emitImageLabels(section);
+
+ this._outputEl.appendChild(section);
+ this._outputEl.appendChild(this._createEndpoint());
+ }
+
+ private _createEndpoint() {
+ const tbody = this._createElement('tbody');
+ const tr = this._createElement('tr');
+ const td = this._createElement('td');
+
+ // TODO(kaspern): Support blame for image diffs and remove the hardcoded 4
+ // column limit.
+ td.setAttribute('colspan', '4');
+ const endpointDomApi = this._createElement('gr-endpoint-decorator');
+ endpointDomApi.setAttribute('name', 'image-diff');
+ endpointDomApi.appendChild(
+ this._createEndpointParam('baseImage', this._baseImage)
+ );
+ endpointDomApi.appendChild(
+ this._createEndpointParam('revisionImage', this._revisionImage)
+ );
+ td.appendChild(endpointDomApi);
+ tr.appendChild(td);
+ tbody.appendChild(tr);
+ return tbody;
+ }
+
+ private _createEndpointParam(name: string, value: ImageInfo | null) {
+ const endpointParam = this._createElement(
+ 'gr-endpoint-param'
+ ) as GrEndpointParam;
+ endpointParam.name = name;
+ endpointParam.value = value;
+ return endpointParam;
+ }
+
+ private _emitImagePair(section: HTMLElement) {
+ const tr = this._createElement('tr');
+
+ tr.appendChild(this._createElement('td', 'left lineNum blank'));
+ tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
+
+ tr.appendChild(this._createElement('td', 'right lineNum blank'));
+ tr.appendChild(
+ this._createImageCell(this._revisionImage, 'right', section)
+ );
+
+ section.appendChild(tr);
+ }
+
+ private _createImageCell(
+ image: ImageInfo | null,
+ className: string,
+ section: HTMLElement
+ ) {
+ const td = this._createElement('td', className);
+ if (image && IMAGE_MIME_PATTERN.test(image.type)) {
+ const imageEl = this._createElement('img') as HTMLImageElement;
+ imageEl.onload = () => {
+ image._height = imageEl.naturalHeight;
+ image._width = imageEl.naturalWidth;
+ this._updateImageLabel(section, className, image);
+ };
+ imageEl.setAttribute('src', `data:${image.type};base64, ${image.body}`);
+ imageEl.addEventListener('error', (e: Event) => {
+ imageEl.remove();
+ td.textContent = '[Image failed to load] ' + e.type;
+ });
+ td.appendChild(imageEl);
+ }
+ return td;
+ }
+
+ private _updateImageLabel(
+ section: HTMLElement,
+ className: string,
+ image: ImageInfo
+ ) {
+ const label = section.querySelector(
+ '.' + className + ' span.label'
+ ) as HTMLElement;
+ this._setLabelText(label, image);
+ }
+
+ private _setLabelText(label: HTMLElement, image: ImageInfo | null) {
+ label.textContent = _getImageLabel(image);
+ }
+
+ private _emitImageLabels(section: HTMLElement) {
+ const tr = this._createElement('tr');
+
+ let addNamesInLabel = false;
+
+ if (
+ this._baseImage &&
+ this._revisionImage &&
+ this._baseImage._name !== this._revisionImage._name
+ ) {
+ addNamesInLabel = true;
+ }
+
+ tr.appendChild(this._createElement('td', 'left lineNum blank'));
+ let td = this._createElement('td', 'left');
+ let label = this._createElement('label');
+ let nameSpan;
+ let labelSpan = this._createElement('span', 'label');
+
+ if (addNamesInLabel) {
+ nameSpan = this._createElement('span', 'name');
+ nameSpan.textContent = this._baseImage?._name ?? '';
+ label.appendChild(nameSpan);
+ label.appendChild(this._createElement('br'));
+ }
+
+ this._setLabelText(labelSpan, this._baseImage);
+
+ label.appendChild(labelSpan);
+ td.appendChild(label);
+ tr.appendChild(td);
+
+ tr.appendChild(this._createElement('td', 'right lineNum blank'));
+ td = this._createElement('td', 'right');
+ label = this._createElement('label');
+ labelSpan = this._createElement('span', 'label');
+
+ if (addNamesInLabel) {
+ nameSpan = this._createElement('span', 'name');
+ nameSpan.textContent = this._revisionImage?._name ?? '';
+ label.appendChild(nameSpan);
+ label.appendChild(this._createElement('br'));
+ }
+
+ this._setLabelText(labelSpan, this._revisionImage);
+
+ label.appendChild(labelSpan);
+ td.appendChild(label);
+ tr.appendChild(td);
+
+ section.appendChild(tr);
+ }
+}
+
+function _getImageLabel(image: ImageInfo | null) {
+ if (image) {
+ const type = image.type ?? image._expectedType;
+ if (image._width && image._height) {
+ return `${image._width}×${image._height} ${type}`;
+ } else {
+ return type;
+ }
+ }
+ return 'No image';
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
deleted file mode 100644
index 8b73936..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ /dev/null
@@ -1,113 +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 {GrDiffBuilder} from './gr-diff-builder.js';
-
-/** @constructor */
-export function GrDiffBuilderSideBySide(diff, prefs, outputEl, layers) {
- GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
-}
-GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
-GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
-
-GrDiffBuilderSideBySide.prototype.buildSectionElement = function(group) {
- const sectionEl = this._createElement('tbody', 'section');
- sectionEl.classList.add(group.type);
- if (this._isTotal(group)) {
- sectionEl.classList.add('total');
- }
- if (group.dueToRebase) {
- sectionEl.classList.add('dueToRebase');
- }
- if (group.ignoredWhitespaceOnly) {
- sectionEl.classList.add('ignoredWhitespaceOnly');
- }
- const pairs = group.getSideBySidePairs();
- for (let i = 0; i < pairs.length; i++) {
- sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
- pairs[i].right));
- }
- return sectionEl;
-};
-
-GrDiffBuilderSideBySide.prototype.addColumns = function(outputEl, fontSize) {
- const width = fontSize * 4;
- const colgroup = document.createElement('colgroup');
-
- // Add the blame column.
- let col = this._createElement('col', 'blame');
- colgroup.appendChild(col);
-
- // Add left-side line number.
- col = document.createElement('col');
- col.setAttribute('width', width);
- colgroup.appendChild(col);
-
- // Add left-side content.
- colgroup.appendChild(document.createElement('col'));
-
- // Add right-side line number.
- col = document.createElement('col');
- col.setAttribute('width', width);
- colgroup.appendChild(col);
-
- // Add right-side content.
- colgroup.appendChild(document.createElement('col'));
-
- outputEl.appendChild(colgroup);
-};
-
-GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
- rightLine) {
- const row = this._createElement('tr');
- row.classList.add('diff-row', 'side-by-side');
- row.setAttribute('left-type', leftLine.type);
- row.setAttribute('right-type', rightLine.type);
- row.tabIndex = -1;
-
- row.appendChild(this._createBlameCell(leftLine));
-
- this._appendPair(section, row, leftLine, leftLine.beforeNumber,
- GrDiffBuilder.Side.LEFT);
- this._appendPair(section, row, rightLine, rightLine.afterNumber,
- GrDiffBuilder.Side.RIGHT);
- return row;
-};
-
-GrDiffBuilderSideBySide.prototype._appendPair = function(section, row, line,
- lineNumber, side) {
- const lineNumberEl = this._createLineEl(line, lineNumber, line.type, side);
- row.appendChild(lineNumberEl);
- const action = this._createContextControl(section, line);
- if (action) {
- row.appendChild(action);
- } else {
- const textEl = this._createTextEl(lineNumberEl, line, side);
- row.appendChild(textEl);
- }
-};
-
-GrDiffBuilderSideBySide.prototype._getNextContentOnSide = function(
- content, side) {
- let tr = content.parentElement.parentElement;
- while (tr = tr.nextSibling) {
- content = tr.querySelector(
- 'td.content .contentText[data-side="' + side + '"]');
- if (content) { return content; }
- }
- return null;
-};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
new file mode 100644
index 0000000..657dfa2
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.ts
@@ -0,0 +1,138 @@
+/**
+ * @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 {GrDiffBuilder} from './gr-diff-builder';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {GrDiffLine, LineNumber} from '../gr-diff/gr-diff-line';
+import {DiffViewMode, Side} from '../../../constants/constants';
+
+export class GrDiffBuilderSideBySide extends GrDiffBuilder {
+ constructor(
+ diff: DiffInfo,
+ prefs: DiffPreferencesInfo,
+ outputEl: HTMLElement,
+ // TODO(TS): Replace any by a layer interface.
+ readonly layers: any[] = [],
+ useNewContextControls = false
+ ) {
+ super(diff, prefs, outputEl, layers, useNewContextControls);
+ }
+
+ _getMoveControlsConfig() {
+ return {
+ numberOfCells: 4,
+ movedOutIndex: 1,
+ movedInIndex: 3,
+ };
+ }
+
+ buildSectionElement(group: GrDiffGroup) {
+ const sectionEl = this._createElement('tbody', 'section');
+ sectionEl.classList.add(group.type);
+ if (this._isTotal(group)) {
+ sectionEl.classList.add('total');
+ }
+ if (group.dueToRebase) {
+ sectionEl.classList.add('dueToRebase');
+ }
+ if (group.dueToMove) {
+ sectionEl.classList.add('dueToMove');
+ sectionEl.appendChild(this._buildMoveControls(group));
+ }
+ if (group.ignoredWhitespaceOnly) {
+ sectionEl.classList.add('ignoredWhitespaceOnly');
+ }
+ if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
+ this._createContextControls(
+ sectionEl,
+ group.contextGroups,
+ DiffViewMode.SIDE_BY_SIDE
+ );
+ return sectionEl;
+ }
+
+ const pairs = group.getSideBySidePairs();
+ for (let i = 0; i < pairs.length; i++) {
+ sectionEl.appendChild(this._createRow(pairs[i].left, pairs[i].right));
+ }
+ return sectionEl;
+ }
+
+ addColumns(outputEl: HTMLElement, fontSize: number): void {
+ const width = fontSize * 4;
+ const colgroup = document.createElement('colgroup');
+
+ // Add the blame column.
+ let col = this._createElement('col', 'blame');
+ colgroup.appendChild(col);
+
+ // Add left-side line number.
+ col = document.createElement('col');
+ col.setAttribute('width', width.toString());
+ colgroup.appendChild(col);
+
+ // Add left-side content.
+ colgroup.appendChild(document.createElement('col'));
+
+ // Add right-side line number.
+ col = document.createElement('col');
+ col.setAttribute('width', width.toString());
+ colgroup.appendChild(col);
+
+ // Add right-side content.
+ colgroup.appendChild(document.createElement('col'));
+
+ outputEl.appendChild(colgroup);
+ }
+
+ _createRow(leftLine: GrDiffLine, rightLine: GrDiffLine) {
+ const row = this._createElement('tr');
+ row.classList.add('diff-row', 'side-by-side');
+ row.setAttribute('left-type', leftLine.type);
+ row.setAttribute('right-type', rightLine.type);
+ row.tabIndex = -1;
+
+ row.appendChild(this._createBlameCell(leftLine.beforeNumber));
+
+ this._appendPair(row, leftLine, leftLine.beforeNumber, Side.LEFT);
+ this._appendPair(row, rightLine, rightLine.afterNumber, Side.RIGHT);
+ return row;
+ }
+
+ _appendPair(
+ row: HTMLElement,
+ line: GrDiffLine,
+ lineNumber: LineNumber,
+ side: Side
+ ) {
+ const lineNumberEl = this._createLineEl(line, lineNumber, line.type, side);
+ row.appendChild(lineNumberEl);
+ row.appendChild(this._createTextEl(lineNumberEl, line, side));
+ }
+
+ _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
+ let tr: HTMLElement = content.parentElement!.parentElement!;
+ while ((tr = tr.nextSibling as HTMLElement)) {
+ const nextContent = tr.querySelector(
+ 'td.content .contentText[data-side="' + side + '"]'
+ );
+ if (nextContent) return nextContent as HTMLElement;
+ }
+ return null;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
deleted file mode 100644
index 8163176..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ /dev/null
@@ -1,109 +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 {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffBuilder} from './gr-diff-builder.js';
-
-export function GrDiffBuilderUnified(diff, prefs, outputEl, layers) {
- GrDiffBuilder.call(this, diff, prefs, outputEl, layers);
-}
-GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
-GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
-
-GrDiffBuilderUnified.prototype.buildSectionElement = function(group) {
- const sectionEl = this._createElement('tbody', 'section');
- sectionEl.classList.add(group.type);
- if (this._isTotal(group)) {
- sectionEl.classList.add('total');
- }
- if (group.dueToRebase) {
- sectionEl.classList.add('dueToRebase');
- }
- if (group.ignoredWhitespaceOnly) {
- sectionEl.classList.add('ignoredWhitespaceOnly');
- }
-
- for (let i = 0; i < group.lines.length; ++i) {
- const line = group.lines[i];
- // If only whitespace has changed and the settings ask for whitespace to
- // be ignored, only render the right-side line in unified diff mode.
- if (group.ignoredWhitespaceOnly && line.type == GrDiffLine.Type.REMOVE) {
- continue;
- }
- sectionEl.appendChild(this._createRow(sectionEl, line));
- }
- return sectionEl;
-};
-
-GrDiffBuilderUnified.prototype.addColumns = function(outputEl, fontSize) {
- const width = fontSize * 4;
- const colgroup = document.createElement('colgroup');
-
- // Add the blame column.
- let col = this._createElement('col', 'blame');
- colgroup.appendChild(col);
-
- // Add left-side line number.
- col = document.createElement('col');
- col.setAttribute('width', width);
- colgroup.appendChild(col);
-
- // Add right-side line number.
- col = document.createElement('col');
- col.setAttribute('width', width);
- colgroup.appendChild(col);
-
- // Add the content.
- colgroup.appendChild(document.createElement('col'));
-
- outputEl.appendChild(colgroup);
-};
-
-GrDiffBuilderUnified.prototype._createRow = function(section, line) {
- const row = this._createElement('tr', line.type);
- row.classList.add('diff-row', 'unified');
- row.tabIndex = -1;
- row.appendChild(this._createBlameCell(line));
-
- let lineNumberEl = this._createLineEl(line, line.beforeNumber,
- GrDiffLine.Type.REMOVE, 'left');
- row.appendChild(lineNumberEl);
- lineNumberEl = this._createLineEl(line, line.afterNumber,
- GrDiffLine.Type.ADD, 'right');
- row.appendChild(lineNumberEl);
-
- const action = this._createContextControl(section, line);
- if (action) {
- row.appendChild(action);
- } else {
- const textEl = this._createTextEl(lineNumberEl, line);
- row.appendChild(textEl);
- }
- return row;
-};
-
-GrDiffBuilderUnified.prototype._getNextContentOnSide = function(
- content, side) {
- let tr = content.parentElement.parentElement;
- while (tr = tr.nextSibling) {
- if (tr.classList.contains('both') || (
- (side === 'left' && tr.classList.contains('remove')) ||
- (side === 'right' && tr.classList.contains('add')))) {
- return tr.querySelector('.contentText');
- }
- }
- return null;
-};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
new file mode 100644
index 0000000..2028b0c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.ts
@@ -0,0 +1,140 @@
+/**
+ * @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 {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffBuilder} from './gr-diff-builder';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {DiffViewMode, Side} from '../../../constants/constants';
+
+export class GrDiffBuilderUnified extends GrDiffBuilder {
+ constructor(
+ diff: DiffInfo,
+ prefs: DiffPreferencesInfo,
+ outputEl: HTMLElement,
+ // TODO(TS): Replace any by a layer interface.
+ readonly layers: any[] = [],
+ useNewContextControls = false
+ ) {
+ super(diff, prefs, outputEl, layers, useNewContextControls);
+ }
+
+ _getMoveControlsConfig() {
+ return {
+ numberOfCells: 3,
+ movedOutIndex: 2,
+ movedInIndex: 2,
+ };
+ }
+
+ buildSectionElement(group: GrDiffGroup): HTMLElement {
+ const sectionEl = this._createElement('tbody', 'section');
+ sectionEl.classList.add(group.type);
+ if (this._isTotal(group)) {
+ sectionEl.classList.add('total');
+ }
+ if (group.dueToRebase) {
+ sectionEl.classList.add('dueToRebase');
+ }
+ if (group.dueToMove) {
+ sectionEl.classList.add('dueToMove');
+ sectionEl.appendChild(this._buildMoveControls(group));
+ }
+ if (group.ignoredWhitespaceOnly) {
+ sectionEl.classList.add('ignoredWhitespaceOnly');
+ }
+ if (group.type === GrDiffGroupType.CONTEXT_CONTROL) {
+ this._createContextControls(
+ sectionEl,
+ group.contextGroups,
+ DiffViewMode.UNIFIED
+ );
+ return sectionEl;
+ }
+
+ for (let i = 0; i < group.lines.length; ++i) {
+ const line = group.lines[i];
+ // If only whitespace has changed and the settings ask for whitespace to
+ // be ignored, only render the right-side line in unified diff mode.
+ if (group.ignoredWhitespaceOnly && line.type === GrDiffLineType.REMOVE) {
+ continue;
+ }
+ sectionEl.appendChild(this._createRow(line));
+ }
+ return sectionEl;
+ }
+
+ addColumns(outputEl: HTMLElement, fontSize: number): void {
+ const width = fontSize * 4;
+ const colgroup = document.createElement('colgroup');
+
+ // Add the blame column.
+ let col = this._createElement('col', 'blame');
+ colgroup.appendChild(col);
+
+ // Add left-side line number.
+ col = document.createElement('col');
+ col.setAttribute('width', width.toString());
+ colgroup.appendChild(col);
+
+ // Add right-side line number.
+ col = document.createElement('col');
+ col.setAttribute('width', width.toString());
+ colgroup.appendChild(col);
+
+ // Add the content.
+ colgroup.appendChild(document.createElement('col'));
+
+ outputEl.appendChild(colgroup);
+ }
+
+ _createRow(line: GrDiffLine) {
+ const row = this._createElement('tr', line.type);
+ row.classList.add('diff-row', 'unified');
+ row.tabIndex = -1;
+ row.appendChild(this._createBlameCell(line.beforeNumber));
+ let lineNumberEl = this._createLineEl(
+ line,
+ line.beforeNumber,
+ GrDiffLineType.REMOVE,
+ Side.LEFT
+ );
+ row.appendChild(lineNumberEl);
+ lineNumberEl = this._createLineEl(
+ line,
+ line.afterNumber,
+ GrDiffLineType.ADD,
+ Side.RIGHT
+ );
+ row.appendChild(lineNumberEl);
+ row.appendChild(this._createTextEl(lineNumberEl, line));
+ return row;
+ }
+
+ _getNextContentOnSide(content: HTMLElement, side: Side): HTMLElement | null {
+ let tr: HTMLElement = content.parentElement!.parentElement!;
+ while ((tr = tr.nextSibling as HTMLElement)) {
+ if (
+ tr.classList.contains('both') ||
+ (side === 'left' && tr.classList.contains('remove')) ||
+ (side === 'right' && tr.classList.contains('add'))
+ ) {
+ return tr.querySelector('.contentText');
+ }
+ }
+ return null;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
index 7346bf7..07c6410 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified_test.js
@@ -19,8 +19,8 @@
import '../gr-diff/gr-diff-group.js';
import './gr-diff-builder.js';
import './gr-diff-builder-unified.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
import {GrDiffBuilderUnified} from './gr-diff-builder-unified.js';
suite('GrDiffBuilderUnified tests', () => {
@@ -44,15 +44,15 @@
setup(() => {
lines = [
- new GrDiffLine(GrDiffLine.Type.BOTH, 1, 2),
- new GrDiffLine(GrDiffLine.Type.BOTH, 2, 3),
- new GrDiffLine(GrDiffLine.Type.BOTH, 3, 4),
+ new GrDiffLine(GrDiffLineType.BOTH, 1, 2),
+ new GrDiffLine(GrDiffLineType.BOTH, 2, 3),
+ new GrDiffLine(GrDiffLineType.BOTH, 3, 4),
];
lines[0].text = 'def hello_world():';
lines[1].text = ' print "Hello World";';
lines[2].text = ' return True';
- group = new GrDiffGroup(GrDiffGroup.Type.BOTH, lines);
+ group = new GrDiffGroup(GrDiffGroupType.BOTH, lines);
});
test('creates the section', () => {
@@ -96,23 +96,71 @@
});
});
+ suite('buildSectionElement for moved chunks', () => {
+ test('creates a moved out group', () => {
+ const lines = [
+ new GrDiffLine(GrDiffLineType.REMOVE, 15),
+ new GrDiffLine(GrDiffLineType.REMOVE, 16),
+ ];
+ lines[0].text = 'def hello_world():';
+ lines[1].text = ' print "Hello World"';
+ const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
+ group.dueToMove = true;
+
+ const sectionEl = diffBuilder.buildSectionElement(group);
+
+ const rowEls = sectionEl.querySelectorAll('tr');
+ const moveControlsRow = rowEls[0];
+ const cells = moveControlsRow.querySelectorAll('td');
+ assert.isTrue(sectionEl.classList.contains('dueToMove'));
+ assert.equal(rowEls.length, 3);
+ assert.isTrue(moveControlsRow.classList.contains('movedOut'));
+ assert.equal(cells.length, 3);
+ assert.isTrue(cells[2].classList.contains('moveDescription'));
+ assert.equal(cells[2].textContent, 'Moved out');
+ });
+
+ test('creates a moved in group', () => {
+ const lines = [
+ new GrDiffLine(GrDiffLineType.ADD, 37),
+ new GrDiffLine(GrDiffLineType.ADD, 38),
+ ];
+ lines[0].text = 'def hello_world():';
+ lines[1].text = ' print "Hello World"';
+ const group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
+ group.dueToMove = true;
+
+ const sectionEl = diffBuilder.buildSectionElement(group);
+
+ const rowEls = sectionEl.querySelectorAll('tr');
+ const moveControlsRow = rowEls[0];
+ const cells = moveControlsRow.querySelectorAll('td');
+ assert.isTrue(sectionEl.classList.contains('dueToMove'));
+ assert.equal(rowEls.length, 3);
+ assert.isTrue(moveControlsRow.classList.contains('movedIn'));
+ assert.equal(cells.length, 3);
+ assert.isTrue(cells[2].classList.contains('moveDescription'));
+ assert.equal(cells[2].textContent, 'Moved in');
+ });
+ });
+
suite('buildSectionElement for DELTA group', () => {
let lines;
let group;
setup(() => {
lines = [
- new GrDiffLine(GrDiffLine.Type.REMOVE, 1),
- new GrDiffLine(GrDiffLine.Type.REMOVE, 2),
- new GrDiffLine(GrDiffLine.Type.ADD, 2),
- new GrDiffLine(GrDiffLine.Type.ADD, 3),
+ new GrDiffLine(GrDiffLineType.REMOVE, 1),
+ new GrDiffLine(GrDiffLineType.REMOVE, 2),
+ new GrDiffLine(GrDiffLineType.ADD, 2),
+ new GrDiffLine(GrDiffLineType.ADD, 3),
];
lines[0].text = 'def hello_world():';
lines[1].text = ' print "Hello World"';
lines[2].text = 'def hello_universe()';
lines[3].text = ' print "Hello Universe"';
- group = new GrDiffGroup(GrDiffGroup.Type.DELTA, lines);
+ group = new GrDiffGroup(GrDiffGroupType.DELTA, lines);
});
test('creates the section', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
deleted file mode 100644
index 3a6e0a3..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ /dev/null
@@ -1,667 +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 {getBaseUrl} from '../../../utils/url-util.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-/**
- * In JS, unicode code points above 0xFFFF occupy two elements of a string.
- * For example '𐀏'.length is 2. An occurence of such a code point is called a
- * surrogate pair.
- *
- * This regex segments a string along tabs ('\t') and surrogate pairs, since
- * these are two cases where '1 char' does not automatically imply '1 column'.
- *
- * TODO: For human languages whose orthographies use combining marks, this
- * approach won't correctly identify the grapheme boundaries. In those cases,
- * a grapheme consists of multiple code points that should count as only one
- * character against the column limit. Getting that correct (if it's desired)
- * is probably beyond the limits of a regex, but there are nonstandard APIs to
- * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
- *
- * Further reading:
- * On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
- * Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
- * A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
- */
-const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
-
-export function GrDiffBuilder(diff, prefs, outputEl, layers) {
- this._diff = diff;
- this._numLinesLeft = this._diff.content ? this._diff.content.reduce(
- (sum, chunk) => {
- const left = chunk.a || chunk.ab;
- return sum + (left ? left.length : 0);
- }, 0) : 0;
- this._prefs = prefs;
- this._outputEl = outputEl;
- this.groups = [];
- this._blameInfo = null;
-
- this.layers = layers || [];
-
- if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
- throw Error('Invalid tab size from preferences.');
- }
-
- if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
- throw Error('Invalid line length from preferences.');
- }
-
- this._layerUpdateListener = this._handleLayerUpdate.bind(this);
- for (const layer of this.layers) {
- if (layer.addListener) {
- layer.addListener(this._layerUpdateListener);
- }
- }
-}
-
-GrDiffBuilder.prototype.clear = function() {
- for (const layer of this.layers) {
- if (layer.removeListener) {
- layer.removeListener(this._layerUpdateListener);
- }
- }
-};
-
-GrDiffBuilder.GroupType = {
- ADDED: 'b',
- BOTH: 'ab',
- REMOVED: 'a',
-};
-
-GrDiffBuilder.Highlights = {
- ADDED: 'edit_b',
- REMOVED: 'edit_a',
-};
-
-GrDiffBuilder.Side = {
- LEFT: 'left',
- RIGHT: 'right',
-};
-
-GrDiffBuilder.ContextButtonType = {
- ABOVE: 'above',
- BELOW: 'below',
- ALL: 'all',
-};
-
-const PARTIAL_CONTEXT_AMOUNT = 10;
-
-/**
- * Abstract method
- *
- * @param {string} outputEl
- * @param {number} fontSize
- */
-GrDiffBuilder.prototype.addColumns = function() {
- throw Error('Subclasses must implement addColumns');
-};
-
-/**
- * Abstract method
- *
- * @param {Object} group
- */
-GrDiffBuilder.prototype.buildSectionElement = function() {
- throw Error('Subclasses must implement buildSectionElement');
-};
-
-GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
- const element = this.buildSectionElement(group);
- this._outputEl.insertBefore(element, opt_beforeSection);
- group.element = element;
-};
-
-GrDiffBuilder.prototype.getGroupsByLineRange = function(
- startLine, endLine, opt_side) {
- const groups = [];
- for (let i = 0; i < this.groups.length; i++) {
- const group = this.groups[i];
- if (group.lines.length === 0) {
- continue;
- }
- let groupStartLine = 0;
- let groupEndLine = 0;
- if (opt_side) {
- groupStartLine = group.lineRange[opt_side].start;
- groupEndLine = group.lineRange[opt_side].end;
- }
-
- if (groupStartLine === 0) { // Line was removed or added.
- groupStartLine = groupEndLine;
- }
- if (groupEndLine === 0) { // Line was removed or added.
- groupEndLine = groupStartLine;
- }
- if (startLine <= groupEndLine && endLine >= groupStartLine) {
- groups.push(group);
- }
- }
- return groups;
-};
-
-GrDiffBuilder.prototype.getContentTdByLine = function(
- lineNumber, opt_side, opt_root) {
- const root = dom(opt_root || this._outputEl);
- const sideSelector = opt_side ? ('.' + opt_side) : '';
- return root.querySelector('td.lineNum[data-value="' + lineNumber +
- '"]' + sideSelector + ' ~ td.content');
-};
-
-GrDiffBuilder.prototype.getContentByLine = function(
- lineNumber, opt_side, opt_root) {
- return this.getContentTdByLine(lineNumber, opt_side, opt_root)
- .querySelector('.contentText');
-};
-
-/**
- * Find line elements or line objects by a range of line numbers and a side.
- *
- * @param {number} start The first line number
- * @param {number} end The last line number
- * @param {string} opt_side The side of the range. Either 'left' or 'right'.
- * @param {!Array<GrDiffLine>} out_lines The output list of line objects. Use
- * null if not desired.
- * @param {!Array<HTMLElement>} out_elements The output list of line elements.
- * Use null if not desired.
- */
-GrDiffBuilder.prototype.findLinesByRange = function(start, end, opt_side,
- out_lines, out_elements) {
- const groups = this.getGroupsByLineRange(start, end, opt_side);
- for (const group of groups) {
- let content = null;
- for (const line of group.lines) {
- if ((opt_side === 'left' && line.type === GrDiffLine.Type.ADD) ||
- (opt_side === 'right' && line.type === GrDiffLine.Type.REMOVE)) {
- continue;
- }
- const lineNumber = opt_side === 'left' ?
- line.beforeNumber : line.afterNumber;
- if (lineNumber < start || lineNumber > end) { continue; }
-
- if (out_lines) { out_lines.push(line); }
- if (out_elements) {
- if (content) {
- content = this._getNextContentOnSide(content, opt_side);
- } else {
- content = this.getContentByLine(lineNumber, opt_side,
- group.element);
- }
- if (content) { out_elements.push(content); }
- }
- }
- }
-};
-
-/**
- * Re-renders the DIV.contentText elements for the given side and range of
- * diff content.
- */
-GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) {
- const lines = [];
- const elements = [];
- let line;
- let el;
- this.findLinesByRange(start, end, side, lines, elements);
- for (let i = 0; i < lines.length; i++) {
- line = lines[i];
- el = elements[i];
- if (!el) {
- // Cannot re-render an element if it does not exist. This can happen
- // if lines are collapsed and not visible on the page yet.
- continue;
- }
- const lineNumberEl = this._getLineNumberEl(el, side);
- el.parentElement.replaceChild(
- this._createTextEl(lineNumberEl, line, side).firstChild,
- el);
- }
-};
-
-GrDiffBuilder.prototype.getSectionsByLineRange = function(
- startLine, endLine, opt_side) {
- return this.getGroupsByLineRange(startLine, endLine, opt_side).map(
- group => group.element);
-};
-
-GrDiffBuilder.prototype._createContextControl = function(section, line) {
- if (!line.contextGroups) return null;
-
- const leftStart = line.contextGroups[0].lineRange.left.start;
- const leftEnd =
- line.contextGroups[line.contextGroups.length - 1].lineRange.left.end;
-
- const numLines = leftEnd - leftStart + 1;
-
- if (numLines === 0) return null;
-
- const td = this._createElement('td');
- const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
-
- if (showPartialLinks && leftStart > 1) {
- td.appendChild(this._createContextButton(
- GrDiffBuilder.ContextButtonType.ABOVE, section, line, numLines));
- }
-
- td.appendChild(this._createContextButton(
- GrDiffBuilder.ContextButtonType.ALL, section, line, numLines));
-
- if (showPartialLinks && leftEnd < this._numLinesLeft) {
- td.appendChild(this._createContextButton(
- GrDiffBuilder.ContextButtonType.BELOW, section, line, numLines));
- }
-
- return td;
-};
-
-GrDiffBuilder.prototype._createContextButton = function(type, section, line,
- numLines) {
- const context = PARTIAL_CONTEXT_AMOUNT;
-
- const button = this._createElement('gr-button', 'showContext');
- button.setAttribute('link', true);
- button.setAttribute('no-uppercase', true);
-
- let text;
- let groups = []; // The groups that replace this one if tapped.
- if (type === GrDiffBuilder.ContextButtonType.ALL) {
- const icon = this._createElement('iron-icon', 'showContext');
- icon.setAttribute('icon', 'gr-icons:unfold-more');
- dom(button).appendChild(icon);
-
- text = 'Show ' + numLines + ' common line';
- if (numLines > 1) { text += 's'; }
- groups.push(...line.contextGroups);
- } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
- text = '+' + context + ' above';
- groups = GrDiffGroup.hideInContextControl(line.contextGroups,
- context, numLines);
- } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
- text = '+' + context + ' below';
- groups = GrDiffGroup.hideInContextControl(line.contextGroups,
- 0, numLines - context);
- }
- const textSpan = this._createElement('span', 'showContext');
- dom(textSpan).textContent = text;
- dom(button).appendChild(textSpan);
-
- button.addEventListener('tap', e => {
- e.detail = {
- groups,
- section,
- numLines,
- };
- // Let it bubble up the DOM tree.
- });
-
- return button;
-};
-
-GrDiffBuilder.prototype._createLineEl = function(
- line, number, type, side) {
- const td = this._createElement('td');
- if (line.type === GrDiffLine.Type.BLANK) {
- return td;
- }
- if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
- td.classList.add('contextLineNum');
- return td;
- }
-
- if (line.type === GrDiffLine.Type.BOTH || line.type === type) {
- // Both td and button need a number of classes/attributes for various
- // selectors to work.
- this._decorateLineEl(td, number, side);
- td.classList.add('lineNum');
-
- if (this._prefs.show_file_comment_button === false && number === 'FILE') {
- return td;
- }
-
- const button = this._createElement('button');
- td.appendChild(button);
- button.tabIndex = -1;
- this._decorateLineEl(button, number, side);
-
- button.classList.add('lineNumButton');
-
- button.textContent = number === 'FILE' ? 'File' : number;
-
- // Add aria-labels for valid line numbers.
- // For unified diff, this method will be called with number set to 0 for
- // the empty line number column for added/removed lines. This should not
- // be announced to the screenreader.
- if (number > 0) {
- if (line.type === GrDiffLine.Type.REMOVE) {
- button.setAttribute('aria-label', `${number} removed`);
- } else if (line.type === GrDiffLine.Type.ADD) {
- button.setAttribute('aria-label', `${number} added`);
- }
- }
- }
-
- return td;
-};
-
-GrDiffBuilder.prototype._decorateLineEl = function(el, number, side) {
- el.classList.add(side);
- el.dataset.value = number;
-};
-
-GrDiffBuilder.prototype._createTextEl = function(
- lineNumberEl, line, opt_side) {
- const td = this._createElement('td');
- if (line.type !== GrDiffLine.Type.BLANK) {
- td.classList.add('content');
- }
-
- // If intraline info is not available, the entire line will be
- // considered as changed and marked as dark red / green color
- if (!line.hasIntralineInfo) {
- td.classList.add('no-intraline-info');
- }
- td.classList.add(line.type);
-
- if (line.beforeNumber !== 'FILE') {
- const lineLimit =
- !this._prefs.line_wrapping ? this._prefs.line_length : Infinity;
- const contentText =
- this._formatText(line.text, this._prefs.tab_size, lineLimit);
-
- if (opt_side) {
- contentText.setAttribute('data-side', opt_side);
- }
-
- for (const layer of this.layers) {
- if (typeof layer.annotate == 'function') {
- layer.annotate(contentText, lineNumberEl, line);
- }
- }
-
- td.appendChild(contentText);
- } else {
- td.classList.add('file');
- }
-
- return td;
-};
-
-/**
- * Returns a 'div' element containing the supplied |text| as its innerText,
- * with '\t' characters expanded to a width determined by |tabSize|, and the
- * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
- * desired.
- *
- * @param {string} text The text to be formatted.
- * @param {number} tabSize The width of each tab stop.
- * @param {number} lineLimit The column after which to wrap lines.
- * @return {HTMLElement}
- */
-GrDiffBuilder.prototype._formatText = function(text, tabSize, lineLimit) {
- const contentText = this._createElement('div', 'contentText');
-
- let columnPos = 0;
- let textOffset = 0;
- for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
- if (segment) {
- // |segment| contains only normal characters. If |segment| doesn't fit
- // entirely on the current line, append chunks of |segment| followed by
- // line breaks.
- let rowStart = 0;
- let rowEnd = lineLimit - columnPos;
- while (rowEnd < segment.length) {
- contentText.appendChild(
- document.createTextNode(segment.substring(rowStart, rowEnd)));
- contentText.appendChild(this._createElement('span', 'br'));
- columnPos = 0;
- rowStart = rowEnd;
- rowEnd += lineLimit;
- }
- // Append the last part of |segment|, which fits on the current line.
- contentText.appendChild(
- document.createTextNode(segment.substring(rowStart)));
- columnPos += (segment.length - rowStart);
- textOffset += segment.length;
- }
- if (textOffset < text.length) {
- // Handle the special character at |textOffset|.
- if (text.startsWith('\t', textOffset)) {
- // Append a single '\t' character.
- let effectiveTabSize = tabSize - (columnPos % tabSize);
- if (columnPos + effectiveTabSize > lineLimit) {
- contentText.appendChild(this._createElement('span', 'br'));
- columnPos = 0;
- effectiveTabSize = tabSize;
- }
- contentText.appendChild(this._getTabWrapper(effectiveTabSize));
- columnPos += effectiveTabSize;
- textOffset++;
- } else {
- // Append a single surrogate pair.
- if (columnPos >= lineLimit) {
- contentText.appendChild(this._createElement('span', 'br'));
- columnPos = 0;
- }
- contentText.appendChild(document.createTextNode(
- text.substring(textOffset, textOffset + 2)));
- textOffset += 2;
- columnPos += 1;
- }
- }
- }
- return contentText;
-};
-
-/**
- * Returns a <span> element holding a '\t' character, that will visually
- * occupy |tabSize| many columns.
- *
- * @param {number} tabSize The effective size of this tab stop.
- * @return {HTMLElement}
- */
-GrDiffBuilder.prototype._getTabWrapper = function(tabSize) {
- // Force this to be a number to prevent arbitrary injection.
- const result = this._createElement('span', 'tab');
- result.style['tab-size'] = tabSize;
- result.style['-moz-tab-size'] = tabSize;
- result.innerText = '\t';
- return result;
-};
-
-GrDiffBuilder.prototype._createElement = function(tagName, classStr) {
- const el = document.createElement(tagName);
- // When Shady DOM is being used, these classes are added to account for
- // Polymer's polyfill behavior. In order to guarantee sufficient
- // specificity within the CSS rules, these are added to every element.
- // Since the Polymer DOM utility functions (which would do this
- // automatically) are not being used for performance reasons, this is
- // done manually.
- el.classList.add('style-scope', 'gr-diff');
- if (classStr) {
- for (const className of classStr.split(' ')) {
- el.classList.add(className);
- }
- }
- return el;
-};
-
-GrDiffBuilder.prototype._handleLayerUpdate = function(start, end, side) {
- this._renderContentByRange(start, end, side);
-};
-
-/**
- * Finds the next DIV.contentText element following the given element, and on
- * the same side. Will only search within a group.
- *
- * @param {HTMLElement} content
- * @param {string} side Either 'left' or 'right'
- * @return {HTMLElement}
- */
-GrDiffBuilder.prototype._getNextContentOnSide = function(content, side) {
- throw Error('Subclasses must implement _getNextContentOnSide');
-};
-
-/**
- * Determines whether the given group is either totally an addition or totally
- * a removal.
- *
- * @param {!Object} group (GrDiffGroup)
- * @return {boolean}
- */
-GrDiffBuilder.prototype._isTotal = function(group) {
- return group.type === GrDiffGroup.Type.DELTA &&
- (!group.adds.length || !group.removes.length) &&
- !(!group.adds.length && !group.removes.length);
-};
-
-/**
- * Set the blame information for the diff. For any already-rendered line,
- * re-render its blame cell content.
- *
- * @param {Object} blame
- */
-GrDiffBuilder.prototype.setBlame = function(blame) {
- this._blameInfo = blame;
-
- // TODO(wyatta): make this loop asynchronous.
- for (const commit of blame) {
- for (const range of commit.ranges) {
- for (let i = range.start; i <= range.end; i++) {
- // TODO(wyatta): this query is expensive, but, when traversing a
- // range, the lines are consecutive, and given the previous blame
- // cell, the next one can be reached cheaply.
- const el = this._getBlameByLineNum(i);
- if (!el) { continue; }
- // Remove the element's children (if any).
- while (el.hasChildNodes()) {
- el.removeChild(el.lastChild);
- }
- const blame = this._getBlameForBaseLine(i, commit);
- el.appendChild(blame);
- }
- }
- }
-};
-
-/**
- * Find the blame cell for a given line number.
- *
- * @param {number} lineNum
- * @return {HTMLTableDataCellElement}
- */
-GrDiffBuilder.prototype._getBlameByLineNum = function(lineNum) {
- const root = dom(this._outputEl);
- return root.querySelector(`td.blame[data-line-number="${lineNum}"]`);
-};
-
-/**
- * Given a base line number, return the commit containing that line in the
- * current set of blame information. If no blame information has been
- * provided, null is returned.
- *
- * @param {number} lineNum
- * @return {Object} The commit information.
- */
-GrDiffBuilder.prototype._getBlameCommitForBaseLine = function(lineNum) {
- if (!this._blameInfo) { return null; }
-
- for (const blameCommit of this._blameInfo) {
- for (const range of blameCommit.ranges) {
- if (range.start <= lineNum && range.end >= lineNum) {
- return blameCommit;
- }
- }
- }
- return null;
-};
-
-/**
- * Given the number of a base line, get the content for the blame cell of that
- * line. If there is no blame information for that line, returns null.
- *
- * @param {number} lineNum
- * @param {Object=} opt_commit Optionally provide the commit object, so that
- * it does not need to be searched.
- * @return {HTMLSpanElement}
- */
-GrDiffBuilder.prototype._getBlameForBaseLine = function(lineNum, opt_commit) {
- const commit = opt_commit || this._getBlameCommitForBaseLine(lineNum);
- if (!commit) { return null; }
-
- const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
-
- const date = (new Date(commit.time * 1000)).toLocaleDateString();
- const blameNode = this._createElement('span',
- isStartOfRange ? 'startOfRange' : '');
-
- const shaNode = this._createElement('a', 'blameDate');
- shaNode.innerText = `${date}`;
- shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`);
- blameNode.appendChild(shaNode);
-
- const shortName = commit.author.split(' ')[0];
- const authorNode = this._createElement('span', 'blameAuthor');
- authorNode.innerText = ` ${shortName}`;
- blameNode.appendChild(authorNode);
-
- const hoverCardFragment = this._createElement('span', 'blameHoverCard');
- hoverCardFragment.innerText =
- `Commit ${commit.id}
-Author: ${commit.author}
-Date: ${date}
-
-${commit.commit_msg}`;
- const hovercard = this._createElement('gr-hovercard');
- hovercard.appendChild(hoverCardFragment);
- blameNode.appendChild(hovercard);
-
- return blameNode;
-};
-
-/**
- * Create a blame cell for the given base line. Blame information will be
- * included in the cell if available.
- *
- * @param {GrDiffLine} line
- * @return {HTMLTableDataCellElement}
- */
-GrDiffBuilder.prototype._createBlameCell = function(line) {
- const blameTd = this._createElement('td', 'blame');
- blameTd.setAttribute('data-line-number', line.beforeNumber);
- if (line.beforeNumber) {
- const content = this._getBlameForBaseLine(line.beforeNumber);
- if (content) {
- blameTd.appendChild(content);
- }
- }
- return blameTd;
-};
-
-/**
- * Finds the line number element given the content element by walking up the
- * DOM tree to the diff row and then querying for a .lineNum element on the
- * requested side.
- *
- * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
- */
-GrDiffBuilder.prototype._getLineNumberEl = function(content, side) {
- let row = content;
- while (row && !row.classList.contains('diff-row')) row = row.parentElement;
- return row ? row.querySelector('.lineNum.' + side) : null;
-};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
new file mode 100644
index 0000000..29af31c
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.ts
@@ -0,0 +1,1042 @@
+/**
+ * @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 {getBaseUrl} from '../../../utils/url-util';
+import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {
+ GrDiffGroup,
+ GrDiffGroupRange,
+ GrDiffGroupType,
+ hideInContextControl,
+ rangeBySide,
+} from '../gr-diff/gr-diff-group';
+import {BlameInfo, DiffInfo, DiffPreferencesInfo} from '../../../types/common';
+import {DiffViewMode, Side} from '../../../constants/constants';
+import {DiffLayer} from '../../../types/types';
+
+/**
+ * In JS, unicode code points above 0xFFFF occupy two elements of a string.
+ * For example '𐀏'.length is 2. An occurence of such a code point is called a
+ * surrogate pair.
+ *
+ * This regex segments a string along tabs ('\t') and surrogate pairs, since
+ * these are two cases where '1 char' does not automatically imply '1 column'.
+ *
+ * TODO: For human languages whose orthographies use combining marks, this
+ * approach won't correctly identify the grapheme boundaries. In those cases,
+ * a grapheme consists of multiple code points that should count as only one
+ * character against the column limit. Getting that correct (if it's desired)
+ * is probably beyond the limits of a regex, but there are nonstandard APIs to
+ * do this, and proposed (but, as of Nov 2017, unimplemented) standard APIs.
+ *
+ * Further reading:
+ * On Unicode in JS: https://mathiasbynens.be/notes/javascript-unicode
+ * Graphemes: http://unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries
+ * A proposed JS API: https://github.com/tc39/proposal-intl-segmenter
+ */
+const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+const PARTIAL_CONTEXT_AMOUNT = 10;
+
+enum ContextButtonType {
+ ABOVE = 'above',
+ BELOW = 'below',
+ ALL = 'all',
+}
+
+export interface ContextEvent extends Event {
+ detail: {
+ groups: GrDiffGroup[];
+ section: HTMLElement;
+ numLines: number;
+ };
+}
+
+export interface ContentLoadNeededEventDetail {
+ lineRange: GrDiffGroupRange;
+}
+
+export abstract class GrDiffBuilder {
+ private readonly _diff: DiffInfo;
+
+ private readonly _numLinesLeft: number;
+
+ private readonly _prefs: DiffPreferencesInfo;
+
+ protected readonly _outputEl: HTMLElement;
+
+ readonly groups: GrDiffGroup[];
+
+ private _blameInfo: BlameInfo[] | null;
+
+ private readonly _layerUpdateListener: (
+ start: LineNumber,
+ end: LineNumber,
+ side: Side
+ ) => void;
+
+ constructor(
+ diff: DiffInfo,
+ prefs: DiffPreferencesInfo,
+ outputEl: HTMLElement,
+ readonly layers: DiffLayer[] = [],
+ protected readonly useNewContextControls: boolean = false
+ ) {
+ this._diff = diff;
+ this._numLinesLeft = this._diff.content
+ ? this._diff.content.reduce((sum, chunk) => {
+ const left = chunk.a || chunk.ab;
+ return sum + (left?.length || chunk.skip || 0);
+ }, 0)
+ : 0;
+ this._prefs = prefs;
+ this._outputEl = outputEl;
+ this.groups = [];
+ this._blameInfo = null;
+
+ if (isNaN(prefs.tab_size) || prefs.tab_size <= 0) {
+ throw Error('Invalid tab size from preferences.');
+ }
+
+ if (isNaN(prefs.line_length) || prefs.line_length <= 0) {
+ throw Error('Invalid line length from preferences.');
+ }
+
+ this._layerUpdateListener = (
+ start: LineNumber,
+ end: LineNumber,
+ side: Side
+ ) => this._handleLayerUpdate(start, end, side);
+ for (const layer of this.layers) {
+ if (layer.addListener) {
+ layer.addListener(this._layerUpdateListener);
+ }
+ }
+ }
+
+ clear() {
+ for (const layer of this.layers) {
+ if (layer.removeListener) {
+ layer.removeListener(this._layerUpdateListener);
+ }
+ }
+ }
+
+ // TODO(TS): Convert to enum.
+ static readonly GroupType = {
+ ADDED: 'b',
+ BOTH: 'ab',
+ REMOVED: 'a',
+ };
+
+ // TODO(TS): Convert to enum.
+ static readonly Highlights = {
+ ADDED: 'edit_b',
+ REMOVED: 'edit_a',
+ };
+
+ // TODO(TS): Replace usages with ContextButtonType enum.
+ static readonly ContextButtonType = {
+ ABOVE: 'above',
+ BELOW: 'below',
+ ALL: 'all',
+ };
+
+ abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
+
+ abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
+
+ emitGroup(group: GrDiffGroup, beforeSection: HTMLElement | null) {
+ const element = this.buildSectionElement(group);
+ this._outputEl.insertBefore(element, beforeSection);
+ group.element = element;
+ }
+
+ getGroupsByLineRange(
+ startLine: LineNumber,
+ endLine: LineNumber,
+ side?: Side
+ ) {
+ const groups = [];
+ for (let i = 0; i < this.groups.length; i++) {
+ const group = this.groups[i];
+ if (group.lines.length === 0) {
+ continue;
+ }
+ let groupStartLine = 0;
+ let groupEndLine = 0;
+ if (side) {
+ const range = rangeBySide(group.lineRange, side);
+ groupStartLine = range.start || 0;
+ groupEndLine = range.end || 0;
+ }
+
+ if (groupStartLine === 0) {
+ // Line was removed or added.
+ groupStartLine = groupEndLine;
+ }
+ if (groupEndLine === 0) {
+ // Line was removed or added.
+ groupEndLine = groupStartLine;
+ }
+ if (startLine <= groupEndLine && endLine >= groupStartLine) {
+ groups.push(group);
+ }
+ }
+ return groups;
+ }
+
+ getContentTdByLine(
+ lineNumber: LineNumber,
+ side?: Side,
+ root: Element = this._outputEl
+ ): Element | null {
+ const sideSelector: string = side ? `.${side}` : '';
+ return root.querySelector(
+ `td.lineNum[data-value="${lineNumber}"]${sideSelector} ~ td.content`
+ );
+ }
+
+ getContentByLine(
+ lineNumber: LineNumber,
+ side?: Side,
+ root?: HTMLElement
+ ): HTMLElement | null {
+ const td = this.getContentTdByLine(lineNumber, side, root);
+ return td ? td.querySelector('.contentText') : null;
+ }
+
+ /**
+ * Find line elements or line objects by a range of line numbers and a side.
+ *
+ * @param start The first line number
+ * @param end The last line number
+ * @param side The side of the range. Either 'left' or 'right'.
+ * @param out_lines The output list of line objects. Use null if not desired.
+ * @param out_elements The output list of line elements. Use null if not
+ * desired.
+ */
+ findLinesByRange(
+ start: LineNumber,
+ end: LineNumber,
+ side: Side,
+ out_lines: GrDiffLine[] | null,
+ out_elements: HTMLElement[] | null
+ ) {
+ const groups = this.getGroupsByLineRange(start, end, side);
+ for (const group of groups) {
+ let content: HTMLElement | null = null;
+ for (const line of group.lines) {
+ if (
+ (side === 'left' && line.type === GrDiffLineType.ADD) ||
+ (side === 'right' && line.type === GrDiffLineType.REMOVE)
+ ) {
+ continue;
+ }
+ const lineNumber =
+ side === 'left' ? line.beforeNumber : line.afterNumber;
+ if (lineNumber < start || lineNumber > end) {
+ continue;
+ }
+
+ if (out_lines) {
+ out_lines.push(line);
+ }
+ if (out_elements) {
+ if (content) {
+ content = this._getNextContentOnSide(content, side);
+ } else {
+ content = this.getContentByLine(lineNumber, side, group.element);
+ }
+ if (content) {
+ out_elements.push(content);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Re-renders the DIV.contentText elements for the given side and range of
+ * diff content.
+ */
+ _renderContentByRange(start: LineNumber, end: LineNumber, side: Side) {
+ const lines: GrDiffLine[] = [];
+ const elements: HTMLElement[] = [];
+ let line;
+ let el;
+ this.findLinesByRange(start, end, side, lines, elements);
+ for (let i = 0; i < lines.length; i++) {
+ line = lines[i];
+ el = elements[i];
+ if (!el || !el.parentElement) {
+ // Cannot re-render an element if it does not exist. This can happen
+ // if lines are collapsed and not visible on the page yet.
+ continue;
+ }
+ const lineNumberEl = this._getLineNumberEl(el, side);
+ el.parentElement.replaceChild(
+ this._createTextEl(lineNumberEl, line, side).firstChild!,
+ el
+ );
+ }
+ }
+
+ getSectionsByLineRange(
+ startLine: LineNumber,
+ endLine: LineNumber,
+ side: Side
+ ) {
+ return this.getGroupsByLineRange(startLine, endLine, side).map(
+ group => group.element
+ );
+ }
+
+ _createContextControls(
+ section: HTMLElement,
+ contextGroups: GrDiffGroup[],
+ viewMode: DiffViewMode
+ ) {
+ const leftStart = contextGroups[0].lineRange.left.start!;
+ const leftEnd = contextGroups[contextGroups.length - 1].lineRange.left.end!;
+ const numLines = leftEnd - leftStart + 1;
+
+ if (numLines === 0) console.error('context group without lines');
+
+ const firstGroupIsSkipped = !!contextGroups[0].skip;
+ const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
+
+ const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
+ const showAbove = leftStart > 1 && !firstGroupIsSkipped;
+ const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
+
+ if (this.useNewContextControls) {
+ section.classList.add('newStyle');
+ if (showAbove) {
+ const paddingRow = this._createContextControlPaddingRow(viewMode);
+ paddingRow.classList.add('above');
+ section.appendChild(paddingRow);
+ }
+ section.appendChild(
+ this._createNewContextControlRow(
+ section,
+ contextGroups,
+ showAbove,
+ showBelow,
+ numLines
+ )
+ );
+ if (showBelow) {
+ const paddingRow = this._createContextControlPaddingRow(viewMode);
+ paddingRow.classList.add('below');
+ section.appendChild(paddingRow);
+ }
+ } else {
+ section.appendChild(
+ this._createOldContextControlRow(
+ section,
+ contextGroups,
+ viewMode,
+ showAbove && showPartialLinks,
+ showBelow && showPartialLinks,
+ numLines
+ )
+ );
+ }
+ }
+
+ /**
+ * Creates old-style context controls: a single row of "+X above" and
+ * "+X below" buttons.
+ */
+ _createOldContextControlRow(
+ section: HTMLElement,
+ contextGroups: GrDiffGroup[],
+ viewMode: DiffViewMode,
+ showAbove: boolean,
+ showBelow: boolean,
+ numLines: number
+ ) {
+ const row = this._createElement('tr', GrDiffGroupType.CONTEXT_CONTROL);
+
+ row.classList.add('diff-row');
+ row.classList.add(
+ viewMode === DiffViewMode.SIDE_BY_SIDE ? 'side-by-side' : 'unified'
+ );
+
+ row.tabIndex = -1;
+ row.appendChild(this._createBlameCell(0));
+ row.appendChild(this._createElement('td', 'contextLineNum'));
+ if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+ row.appendChild(
+ this._createOldContextControlButtons(
+ section,
+ contextGroups,
+ showAbove,
+ showBelow,
+ numLines
+ )
+ );
+ }
+ row.appendChild(this._createElement('td', 'contextLineNum'));
+ row.appendChild(
+ this._createOldContextControlButtons(
+ section,
+ contextGroups,
+ showAbove,
+ showBelow,
+ numLines
+ )
+ );
+
+ return row;
+ }
+
+ _createOldContextControlButtons(
+ section: HTMLElement,
+ contextGroups: GrDiffGroup[],
+ showAbove: boolean,
+ showBelow: boolean,
+ numLines: number
+ ): HTMLElement {
+ const td = this._createElement('td');
+
+ if (showAbove) {
+ td.appendChild(
+ this._createContextButton(
+ ContextButtonType.ABOVE,
+ section,
+ contextGroups,
+ numLines
+ )
+ );
+ }
+
+ td.appendChild(
+ this._createContextButton(
+ ContextButtonType.ALL,
+ section,
+ contextGroups,
+ numLines
+ )
+ );
+
+ if (showBelow) {
+ td.appendChild(
+ this._createContextButton(
+ ContextButtonType.BELOW,
+ section,
+ contextGroups,
+ numLines
+ )
+ );
+ }
+
+ return td;
+ }
+
+ /**
+ * Creates new-style context controls: buttons extend from the gap created by
+ * this method up or down into the area of code that they affect.
+ */
+ _createNewContextControlRow(
+ section: HTMLElement,
+ contextGroups: GrDiffGroup[],
+ showAbove: boolean,
+ showBelow: boolean,
+ numLines: number
+ ): HTMLElement {
+ const row = this._createElement('tr', 'contextDivider');
+ if (!(showAbove && showBelow)) {
+ row.classList.add('collapsed');
+ }
+
+ const element = this._createElement('td', 'dividerCell');
+ row.appendChild(element);
+
+ const showAllContainer = this._createElement('div', 'aboveBelowButtons');
+ element.appendChild(showAllContainer);
+
+ const showAllButton = this._createContextButton(
+ ContextButtonType.ALL,
+ section,
+ contextGroups,
+ numLines
+ );
+ showAllButton.classList.add(
+ showAbove && showBelow
+ ? 'centeredButton'
+ : showAbove
+ ? 'aboveButton'
+ : 'belowButton'
+ );
+ showAllContainer.appendChild(showAllButton);
+
+ const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
+ if (showPartialLinks) {
+ const container = this._createElement('div', 'aboveBelowButtons');
+ if (showAbove) {
+ container.appendChild(
+ this._createContextButton(
+ ContextButtonType.ABOVE,
+ section,
+ contextGroups,
+ numLines
+ )
+ );
+ }
+ if (showBelow) {
+ container.appendChild(
+ this._createContextButton(
+ ContextButtonType.BELOW,
+ section,
+ contextGroups,
+ numLines
+ )
+ );
+ }
+ element.appendChild(container);
+ }
+
+ return row;
+ }
+
+ /**
+ * Creates a table row to serve as padding between code and context controls.
+ * Blame column, line gutters, and content area will continue visually, but
+ * context controls can render over this background to map more clearly to
+ * the area of code they expand.
+ */
+ _createContextControlPaddingRow(viewMode: DiffViewMode) {
+ const row = this._createElement('tr', 'contextBackground');
+
+ if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+ row.classList.add('side-by-side');
+ row.setAttribute('left-type', GrDiffGroupType.CONTEXT_CONTROL);
+ row.setAttribute('right-type', GrDiffGroupType.CONTEXT_CONTROL);
+ } else {
+ row.classList.add('unified');
+ }
+
+ row.appendChild(this._createBlameCell(0));
+ row.appendChild(this._createElement('td', 'contextLineNum'));
+ if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
+ row.appendChild(this._createElement('td'));
+ }
+ row.appendChild(this._createElement('td', 'contextLineNum'));
+ row.appendChild(this._createElement('td'));
+
+ return row;
+ }
+
+ _createContextButton(
+ type: ContextButtonType,
+ section: HTMLElement,
+ contextGroups: GrDiffGroup[],
+ numLines: number
+ ) {
+ const context = PARTIAL_CONTEXT_AMOUNT;
+ const button = this._createElement('gr-button', 'showContext');
+ if (this.useNewContextControls) {
+ button.classList.add('contextControlButton');
+ }
+ button.setAttribute('link', 'true');
+ button.setAttribute('no-uppercase', 'true');
+
+ let text = '';
+ let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+ let requiresLoad = false;
+ if (type === GrDiffBuilder.ContextButtonType.ALL) {
+ if (this.useNewContextControls) {
+ text = `+${numLines} common line`;
+ } else {
+ text = `Show ${numLines} common line`;
+ const icon = this._createElement('iron-icon', 'showContext');
+ icon.setAttribute('icon', 'gr-icons:unfold-more');
+ button.appendChild(icon);
+ }
+ if (numLines > 1) {
+ text += 's';
+ }
+ requiresLoad = contextGroups.find(c => !!c.skip) !== undefined;
+ if (requiresLoad) {
+ // Expanding content would require load of more data
+ text += ' (too large)';
+ }
+ groups.push(...contextGroups);
+ } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
+ groups = hideInContextControl(contextGroups, context, numLines);
+ if (this.useNewContextControls) {
+ text = `+${context}`;
+ button.classList.add('aboveButton');
+ } else {
+ text = `+${context} above`;
+ }
+ } else if (type === GrDiffBuilder.ContextButtonType.BELOW) {
+ groups = hideInContextControl(contextGroups, 0, numLines - context);
+ if (this.useNewContextControls) {
+ text = `+${context}`;
+ button.classList.add('belowButton');
+ } else {
+ text = `+${context} below`;
+ }
+ }
+ const textSpan = this._createElement('span', 'showContext');
+ textSpan.textContent = text;
+ button.appendChild(textSpan);
+
+ if (requiresLoad) {
+ button.addEventListener('tap', e => {
+ e.stopPropagation();
+ const firstRange = groups[0].lineRange;
+ const lastRange = groups[groups.length - 1].lineRange;
+ const lineRange = {
+ left: {start: firstRange.left.start, end: lastRange.left.end},
+ right: {start: firstRange.right.start, end: lastRange.right.end},
+ };
+ button.dispatchEvent(
+ new CustomEvent<ContentLoadNeededEventDetail>('content-load-needed', {
+ detail: {
+ lineRange,
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ });
+ } else {
+ button.addEventListener('tap', e => {
+ const event = e as ContextEvent;
+ event.detail = {
+ groups,
+ section,
+ numLines,
+ };
+ // Let it bubble up the DOM tree.
+ });
+ }
+
+ return button;
+ }
+
+ _createLineEl(
+ line: GrDiffLine,
+ number: LineNumber,
+ type: GrDiffLineType,
+ side: Side
+ ) {
+ const td = this._createElement('td');
+ if (line.type === GrDiffLineType.BLANK) {
+ return td;
+ }
+ if (line.type === GrDiffLineType.BOTH || line.type === type) {
+ // Both td and button need a number of classes/attributes for various
+ // selectors to work.
+ this._decorateLineEl(td, number, side);
+ td.classList.add('lineNum');
+
+ if (this._prefs.show_file_comment_button === false && number === 'FILE') {
+ return td;
+ }
+
+ const button = this._createElement('button');
+ td.appendChild(button);
+ button.tabIndex = -1;
+ this._decorateLineEl(button, number, side);
+
+ button.classList.add('lineNumButton');
+
+ button.textContent = number === 'FILE' ? 'File' : number.toString();
+
+ // Add aria-labels for valid line numbers.
+ // For unified diff, this method will be called with number set to 0 for
+ // the empty line number column for added/removed lines. This should not
+ // be announced to the screenreader.
+ if (number > 0) {
+ if (line.type === GrDiffLineType.REMOVE) {
+ button.setAttribute('aria-label', `${number} removed`);
+ } else if (line.type === GrDiffLineType.ADD) {
+ button.setAttribute('aria-label', `${number} added`);
+ }
+ }
+ }
+
+ return td;
+ }
+
+ _decorateLineEl(el: HTMLElement, number: LineNumber, side: Side) {
+ el.classList.add(side);
+ el.dataset['value'] = number.toString();
+ }
+
+ _createTextEl(
+ lineNumberEl: HTMLElement | null,
+ line: GrDiffLine,
+ side?: Side
+ ) {
+ const td = this._createElement('td');
+ if (line.type !== GrDiffLineType.BLANK) {
+ td.classList.add('content');
+ }
+
+ // If intraline info is not available, the entire line will be
+ // considered as changed and marked as dark red / green color
+ if (!line.hasIntralineInfo) {
+ td.classList.add('no-intraline-info');
+ }
+ td.classList.add(line.type);
+
+ if (line.beforeNumber !== 'FILE') {
+ const lineLimit = !this._prefs.line_wrapping
+ ? this._prefs.line_length
+ : Infinity;
+ const contentText = this._formatText(
+ line.text,
+ this._prefs.tab_size,
+ lineLimit
+ );
+
+ if (side) {
+ contentText.setAttribute('data-side', side);
+ }
+
+ if (lineNumberEl) {
+ for (const layer of this.layers) {
+ if (typeof layer.annotate === 'function') {
+ layer.annotate(contentText, lineNumberEl, line);
+ }
+ }
+ } else {
+ console.error('The lineNumberEl is null, skipping layer annotations.');
+ }
+
+ td.appendChild(contentText);
+ } else {
+ td.classList.add('file');
+ }
+
+ return td;
+ }
+
+ /**
+ * Returns a 'div' element containing the supplied |text| as its innerText,
+ * with '\t' characters expanded to a width determined by |tabSize|, and the
+ * text wrapped at column |lineLimit|, which may be Infinity if no wrapping is
+ * desired.
+ *
+ * @param text The text to be formatted.
+ * @param tabSize The width of each tab stop.
+ * @param lineLimit The column after which to wrap lines.
+ */
+ _formatText(text: string, tabSize: number, lineLimit: number): HTMLElement {
+ const contentText = this._createElement('div', 'contentText');
+
+ let columnPos = 0;
+ let textOffset = 0;
+ for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
+ if (segment) {
+ // |segment| contains only normal characters. If |segment| doesn't fit
+ // entirely on the current line, append chunks of |segment| followed by
+ // line breaks.
+ let rowStart = 0;
+ let rowEnd = lineLimit - columnPos;
+ while (rowEnd < segment.length) {
+ contentText.appendChild(
+ document.createTextNode(segment.substring(rowStart, rowEnd))
+ );
+ contentText.appendChild(this._createElement('span', 'br'));
+ columnPos = 0;
+ rowStart = rowEnd;
+ rowEnd += lineLimit;
+ }
+ // Append the last part of |segment|, which fits on the current line.
+ contentText.appendChild(
+ document.createTextNode(segment.substring(rowStart))
+ );
+ columnPos += segment.length - rowStart;
+ textOffset += segment.length;
+ }
+ if (textOffset < text.length) {
+ // Handle the special character at |textOffset|.
+ if (text.startsWith('\t', textOffset)) {
+ // Append a single '\t' character.
+ let effectiveTabSize = tabSize - (columnPos % tabSize);
+ if (columnPos + effectiveTabSize > lineLimit) {
+ contentText.appendChild(this._createElement('span', 'br'));
+ columnPos = 0;
+ effectiveTabSize = tabSize;
+ }
+ contentText.appendChild(this._getTabWrapper(effectiveTabSize));
+ columnPos += effectiveTabSize;
+ textOffset++;
+ } else {
+ // Append a single surrogate pair.
+ if (columnPos >= lineLimit) {
+ contentText.appendChild(this._createElement('span', 'br'));
+ columnPos = 0;
+ }
+ contentText.appendChild(
+ document.createTextNode(text.substring(textOffset, textOffset + 2))
+ );
+ textOffset += 2;
+ columnPos += 1;
+ }
+ }
+ }
+ return contentText;
+ }
+
+ /**
+ * Returns a <span> element holding a '\t' character, that will visually
+ * occupy |tabSize| many columns.
+ *
+ * @param tabSize The effective size of this tab stop.
+ */
+ _getTabWrapper(tabSize: number): HTMLElement {
+ // Force this to be a number to prevent arbitrary injection.
+ const result = this._createElement('span', 'tab');
+ result.setAttribute(
+ 'style',
+ `tab-size: ${tabSize}; -moz-tab-size: ${tabSize};`
+ );
+ result.innerText = '\t';
+ return result;
+ }
+
+ _createElement(tagName: string, classStr?: string): HTMLElement {
+ const el = document.createElement(tagName);
+ // When Shady DOM is being used, these classes are added to account for
+ // Polymer's polyfill behavior. In order to guarantee sufficient
+ // specificity within the CSS rules, these are added to every element.
+ // Since the Polymer DOM utility functions (which would do this
+ // automatically) are not being used for performance reasons, this is
+ // done manually.
+ el.classList.add('style-scope', 'gr-diff');
+ if (classStr) {
+ for (const className of classStr.split(' ')) {
+ el.classList.add(className);
+ }
+ }
+ return el;
+ }
+
+ _handleLayerUpdate(start: LineNumber, end: LineNumber, side: Side) {
+ this._renderContentByRange(start, end, side);
+ }
+
+ /**
+ * Finds the next DIV.contentText element following the given element, and on
+ * the same side. Will only search within a group.
+ */
+ abstract _getNextContentOnSide(
+ content: HTMLElement,
+ side: Side
+ ): HTMLElement | null;
+
+ /**
+ * Gets configuration for creating move controls for chunks marked with
+ * dueToMove
+ */
+ abstract _getMoveControlsConfig(): {
+ numberOfCells: number;
+ movedOutIndex: number;
+ movedInIndex: number;
+ };
+
+ /**
+ * Determines whether the given group is either totally an addition or totally
+ * a removal.
+ */
+ _isTotal(group: GrDiffGroup): boolean {
+ return (
+ group.type === GrDiffGroupType.DELTA &&
+ (!group.adds.length || !group.removes.length) &&
+ !(!group.adds.length && !group.removes.length)
+ );
+ }
+
+ /**
+ * Set the blame information for the diff. For any already-rendered line,
+ * re-render its blame cell content.
+ */
+ setBlame(blame: BlameInfo[] | null) {
+ this._blameInfo = blame;
+ if (!blame) return;
+
+ // TODO(wyatta): make this loop asynchronous.
+ for (const commit of blame) {
+ for (const range of commit.ranges) {
+ for (let i = range.start; i <= range.end; i++) {
+ // TODO(wyatta): this query is expensive, but, when traversing a
+ // range, the lines are consecutive, and given the previous blame
+ // cell, the next one can be reached cheaply.
+ const el = this._getBlameByLineNum(i);
+ if (!el) {
+ continue;
+ }
+ // Remove the element's children (if any).
+ while (el.hasChildNodes()) {
+ el.removeChild(el.lastChild!);
+ }
+ const blame = this._getBlameForBaseLine(i, commit);
+ if (blame) el.appendChild(blame);
+ }
+ }
+ }
+ }
+
+ _buildMoveControls(group: GrDiffGroup) {
+ const movedIn = group.adds.length > 0;
+ const {
+ numberOfCells,
+ movedOutIndex,
+ movedInIndex,
+ } = this._getMoveControlsConfig();
+
+ let controlsClass;
+ let descriptionText;
+ let descriptionIndex;
+ if (movedIn) {
+ controlsClass = 'movedIn';
+ descriptionIndex = movedInIndex;
+ descriptionText = 'Moved in';
+ } else {
+ controlsClass = 'movedOut';
+ descriptionIndex = movedOutIndex;
+ descriptionText = 'Moved out';
+ }
+ const controls = document.createElement('tr');
+ const cells = [...Array(numberOfCells).keys()].map(() =>
+ document.createElement('td')
+ );
+ controls.classList.add('moveControls', controlsClass);
+ cells[descriptionIndex].classList.add('moveDescription');
+ cells[descriptionIndex].textContent = descriptionText;
+ cells.forEach(c => {
+ controls.appendChild(c);
+ });
+ return controls;
+ }
+
+ /**
+ * Find the blame cell for a given line number.
+ */
+ _getBlameByLineNum(lineNum: number): Element | null {
+ return this._outputEl.querySelector(
+ `td.blame[data-line-number="${lineNum}"]`
+ );
+ }
+
+ /**
+ * Given a base line number, return the commit containing that line in the
+ * current set of blame information. If no blame information has been
+ * provided, null is returned.
+ *
+ * @return The commit information.
+ */
+ _getBlameCommitForBaseLine(lineNum: LineNumber) {
+ if (!this._blameInfo) {
+ return null;
+ }
+
+ for (const blameCommit of this._blameInfo) {
+ for (const range of blameCommit.ranges) {
+ if (range.start <= lineNum && range.end >= lineNum) {
+ return blameCommit;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Given the number of a base line, get the content for the blame cell of that
+ * line. If there is no blame information for that line, returns null.
+ *
+ * @param commit Optionally provide the commit object, so that
+ * it does not need to be searched.
+ */
+ _getBlameForBaseLine(
+ lineNum: LineNumber,
+ commit: BlameInfo | null = this._getBlameCommitForBaseLine(lineNum)
+ ): HTMLElement | null {
+ if (!commit) {
+ return null;
+ }
+
+ const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+
+ const date = new Date(commit.time * 1000).toLocaleDateString();
+ const blameNode = this._createElement(
+ 'span',
+ isStartOfRange ? 'startOfRange' : ''
+ );
+
+ const shaNode = this._createElement('a', 'blameDate');
+ shaNode.innerText = `${date}`;
+ shaNode.setAttribute('href', `${getBaseUrl()}/q/${commit.id}`);
+ blameNode.appendChild(shaNode);
+
+ const shortName = commit.author.split(' ')[0];
+ const authorNode = this._createElement('span', 'blameAuthor');
+ authorNode.innerText = ` ${shortName}`;
+ blameNode.appendChild(authorNode);
+
+ const hoverCardFragment = this._createElement('span', 'blameHoverCard');
+ hoverCardFragment.innerText = `Commit ${commit.id}
+Author: ${commit.author}
+Date: ${date}
+
+${commit.commit_msg}`;
+ const hovercard = this._createElement('gr-hovercard');
+ hovercard.appendChild(hoverCardFragment);
+ blameNode.appendChild(hovercard);
+
+ return blameNode;
+ }
+
+ /**
+ * Create a blame cell for the given base line. Blame information will be
+ * included in the cell if available.
+ */
+ _createBlameCell(lineNumber: LineNumber): HTMLTableDataCellElement {
+ const blameTd = this._createElement(
+ 'td',
+ 'blame'
+ ) as HTMLTableDataCellElement;
+ blameTd.setAttribute('data-line-number', lineNumber.toString());
+ if (lineNumber) {
+ const content = this._getBlameForBaseLine(lineNumber);
+ if (content) {
+ blameTd.appendChild(content);
+ }
+ }
+ return blameTd;
+ }
+
+ /**
+ * Finds the line number element given the content element by walking up the
+ * DOM tree to the diff row and then querying for a .lineNum element on the
+ * requested side.
+ *
+ * TODO(brohlfs): Consolidate this with getLineEl... methods in html file.
+ */
+ _getLineNumberEl(content: HTMLElement, side: Side): HTMLElement | null {
+ let row: HTMLElement | null = content;
+ while (row && !row.classList.contains('diff-row')) row = row.parentElement;
+ return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
deleted file mode 100644
index 9d68ba3..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ /dev/null
@@ -1,539 +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-cursor-manager/gr-cursor-manager.js';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-diff-cursor_html.js';
-import {ScrollMode} from '../../../constants/constants.js';
-
-const DiffSides = {
- LEFT: 'left',
- RIGHT: 'right',
-};
-
-const DiffViewMode = {
- SIDE_BY_SIDE: 'SIDE_BY_SIDE',
- UNIFIED: 'UNIFIED_DIFF',
-};
-
-const LEFT_SIDE_CLASS = 'target-side-left';
-const RIGHT_SIDE_CLASS = 'target-side-right';
-
-/** @extends PolymerElement */
-class GrDiffCursor extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff-cursor'; }
-
- static get properties() {
- return {
- /**
- * Either DiffSides.LEFT or DiffSides.RIGHT.
- */
- side: {
- type: String,
- value: DiffSides.RIGHT,
- },
- /** @type {!HTMLElement|undefined} */
- diffRow: {
- type: Object,
- notify: true,
- observer: '_rowChanged',
- },
-
- /**
- * The diff views to cursor through and listen to.
- */
- diffs: {
- type: Array,
- value() { return []; },
- },
-
- /**
- * If set, the cursor will attempt to move to the line number (instead of
- * the first chunk) the next time the diff renders. It is set back to null
- * when used. It should be only used if you want the line to be focused
- * after initialization of the component and page should scroll
- * to that position. This parameter should be set at most for one gr-diff
- * element in the page.
- *
- * @type {?number}
- */
- initialLineNumber: {
- type: Number,
- value: null,
- },
-
- /**
- * The scroll behavior for the cursor. Values are 'never' and
- * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
- * the viewport.
- */
- _scrollMode: {
- type: String,
- value: ScrollMode.KEEP_VISIBLE,
- },
-
- _focusOnMove: {
- type: Boolean,
- value: true,
- },
-
- _listeningForScroll: Boolean,
-
- /**
- * gr-diff-view has gr-fixed-panel on top. The panel can
- * intersect a main element and partially hides a content of
- * the main element. To correctly calculates visibility of an
- * element, the cursor must know how much height occuped by a fixed
- * panel.
- * The scrollTopMargin defines margin occuped by fixed panel.
- */
- scrollTopMargin: {
- type: Number,
- value: 0,
- },
- };
- }
-
- static get observers() {
- return [
- '_updateSideClass(side)',
- '_diffsChanged(diffs.splices)',
- ];
- }
-
- constructor() {
- super();
- this._boundHandleWindowScroll = () => this._handleWindowScroll();
- this._boundHandleDiffRenderStart = () => this._handleDiffRenderStart();
- this._boundHandleDiffRenderContent = () => this._handleDiffRenderContent();
- this._boundHandleDiffLineSelected = e => this._handleDiffLineSelected(e);
- }
-
- /** @override */
- ready() {
- super.ready();
- afterNextRender(this, () => {
- /*
- This represents the diff cursor is ready for interaction coming from
- client components. It is more then Polymer "ready" lifecycle, as no
- "ready" events are automatically fired by Polymer, it means
- the cursor is completely interactable - in this case attached and
- painted on the page. We name it "ready" instead of "rendered" as the
- long-term goal is to make gr-diff-cursor a javascript class - not a DOM
- element with an actual lifecycle. This will be triggered only once
- per element.
- */
- this.dispatchEvent(new CustomEvent('ready', {
- composed: true, bubbles: false,
- }));
- });
- }
-
- /** @override */
- connectedCallback() {
- super.connectedCallback();
- // Catch when users are scrolling as the view loads.
- window.addEventListener('scroll', this._boundHandleWindowScroll);
- }
-
- /** @override */
- disconnectedCallback() {
- super.disconnectedCallback();
- window.removeEventListener('scroll', this._boundHandleWindowScroll);
- }
-
- moveLeft() {
- this.side = DiffSides.LEFT;
- if (this._isTargetBlank()) {
- this.moveUp();
- }
- }
-
- moveRight() {
- this.side = DiffSides.RIGHT;
- if (this._isTargetBlank()) {
- this.moveUp();
- }
- }
-
- moveDown() {
- if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
- this.$.cursorManager.next(this._rowHasSide.bind(this));
- } else {
- this.$.cursorManager.next();
- }
- }
-
- moveUp() {
- if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
- this.$.cursorManager.previous(this._rowHasSide.bind(this));
- } else {
- this.$.cursorManager.previous();
- }
- }
-
- moveToVisibleArea() {
- if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
- this.$.cursorManager.moveToVisibleArea(
- this._rowHasSide.bind(this));
- } else {
- this.$.cursorManager.moveToVisibleArea();
- }
- }
-
- moveToNextChunk(opt_clipToTop, opt_navigateToNextFile) {
- this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
- target => target.parentNode.scrollHeight, opt_clipToTop,
- opt_navigateToNextFile);
- this._fixSide();
- }
-
- moveToPreviousChunk() {
- this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
- this._fixSide();
- }
-
- moveToNextCommentThread() {
- this.$.cursorManager.next(this._rowHasThread.bind(this));
- this._fixSide();
- }
-
- moveToPreviousCommentThread() {
- this.$.cursorManager.previous(this._rowHasThread.bind(this));
- this._fixSide();
- }
-
- /**
- * @param {number} number
- * @param {string} side
- * @param {string=} opt_path
- */
- moveToLineNumber(number, side, opt_path) {
- const row = this._findRowByNumberAndFile(number, side, opt_path);
- if (row) {
- this.side = side;
- this.$.cursorManager.setCursor(row);
- }
- }
-
- /**
- * Get the line number element targeted by the cursor row and side.
- *
- * @return {?Element|undefined}
- */
- getTargetLineElement() {
- let lineElSelector = '.lineNum';
-
- if (!this.diffRow) {
- return;
- }
-
- if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
- lineElSelector += this.side === DiffSides.LEFT ? '.left' : '.right';
- }
-
- return this.diffRow.querySelector(lineElSelector);
- }
-
- getTargetDiffElement() {
- if (!this.diffRow) return null;
-
- const hostOwner = dom( (this.diffRow))
- .getOwnerRoot();
- if (hostOwner && hostOwner.host &&
- hostOwner.host.tagName === 'GR-DIFF') {
- return hostOwner.host;
- }
- return null;
- }
-
- moveToFirstChunk() {
- this.$.cursorManager.moveToStart();
- this.moveToNextChunk(true);
- }
-
- moveToLastChunk() {
- this.$.cursorManager.moveToEnd();
- this.moveToPreviousChunk();
- }
-
- /**
- * Move the cursor either to initialLineNumber or the first chunk and
- * reset scroll behavior.
- *
- * This may grab the focus from the app.
- *
- * If you do not want to move the cursor or grab focus, and just want to
- * reset the scroll behavior, use reInit() instead.
- */
- reInitCursor() {
- if (!this.diffRow) {
- // does not scroll during init unless requested
- this._scrollMode = this.initialLineNumber ?
- ScrollMode.KEEP_VISIBLE :
- ScrollMode.NEVER;
- if (this.initialLineNumber) {
- this.moveToLineNumber(this.initialLineNumber, this.side);
- this.initialLineNumber = null;
- } else {
- this.moveToFirstChunk();
- }
- }
- this.reInit();
- }
-
- reInit() {
- this._scrollMode = ScrollMode.KEEP_VISIBLE;
- }
-
- _handleWindowScroll() {
- if (this._preventAutoScrollOnManualScroll) {
- this._scrollMode = ScrollMode.NEVER;
- this._focusOnMove = false;
- this._preventAutoScrollOnManualScroll = false;
- }
- }
-
- reInitAndUpdateStops() {
- this.reInit();
- this._updateStops();
- }
-
- handleDiffUpdate() {
- this._updateStops();
- this.reInitCursor();
- }
-
- _handleDiffRenderStart() {
- this._preventAutoScrollOnManualScroll = true;
- }
-
- _handleDiffRenderContent() {
- this._updateStops();
- // When done rendering, turn focus on move and automatic scrolling back on
- this._focusOnMove = true;
- this._preventAutoScrollOnManualScroll = false;
- }
-
- _handleDiffLineSelected(event) {
- this.moveToLineNumber(
- event.detail.number, event.detail.side, event.detail.path);
- }
-
- createCommentInPlace() {
- const diffWithRangeSelected = this.diffs
- .find(diff => diff.isRangeSelected());
- if (diffWithRangeSelected) {
- diffWithRangeSelected.createRangeComment();
- } else {
- const line = this.getTargetLineElement();
- if (line) {
- this.getTargetDiffElement().addDraftAtLine(line);
- }
- }
- }
-
- /**
- * Get an object describing the location of the cursor. Such as
- * {leftSide: false, number: 123} for line 123 of the revision, or
- * {leftSide: true, number: 321} for line 321 of the base patch.
- * Returns null if an address is not available.
- *
- * @return {?Object}
- */
- getAddress() {
- if (!this.diffRow) { return null; }
-
- // Get the line-number cell targeted by the cursor. If the mode is unified
- // then prefer the revision cell if available.
- let cell;
- if (this._getViewMode() === DiffViewMode.UNIFIED) {
- cell = this.diffRow.querySelector('.lineNum.right');
- if (!cell) {
- cell = this.diffRow.querySelector('.lineNum.left');
- }
- } else {
- cell = this.diffRow.querySelector('.lineNum.' + this.side);
- }
- if (!cell) { return null; }
-
- const number = cell.getAttribute('data-value');
- if (!number || number === 'FILE') { return null; }
-
- return {
- leftSide: cell.matches('.left'),
- number: parseInt(number, 10),
- };
- }
-
- _getViewMode() {
- if (!this.diffRow) {
- return null;
- }
-
- if (this.diffRow.classList.contains('side-by-side')) {
- return DiffViewMode.SIDE_BY_SIDE;
- } else {
- return DiffViewMode.UNIFIED;
- }
- }
-
- _rowHasSide(row) {
- const selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
- ' + .content';
- return !!row.querySelector(selector);
- }
-
- _isFirstRowOfChunk(row) {
- const parentClassList = row.parentNode.classList;
- return parentClassList.contains('section') &&
- parentClassList.contains('delta') &&
- !row.previousSibling;
- }
-
- _rowHasThread(row) {
- return row.querySelector('.thread-group');
- }
-
- /**
- * If we jumped to a row where there is no content on the current side then
- * switch to the alternate side.
- */
- _fixSide() {
- if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
- this._isTargetBlank()) {
- this.side = this.side === DiffSides.LEFT ?
- DiffSides.RIGHT : DiffSides.LEFT;
- }
- }
-
- _isTargetBlank() {
- if (!this.diffRow) {
- return false;
- }
-
- const actions = this._getActionsForRow();
- return (this.side === DiffSides.LEFT && !actions.left) ||
- (this.side === DiffSides.RIGHT && !actions.right);
- }
-
- _rowChanged(newRow, oldRow) {
- if (oldRow) {
- oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
- }
- this._updateSideClass();
- }
-
- _updateSideClass() {
- if (!this.diffRow) {
- return;
- }
- this.toggleClass(LEFT_SIDE_CLASS, this.side === DiffSides.LEFT,
- this.diffRow);
- this.toggleClass(RIGHT_SIDE_CLASS, this.side === DiffSides.RIGHT,
- this.diffRow);
- }
-
- _isActionType(type) {
- return type !== 'blank' && type !== 'contextControl';
- }
-
- _getActionsForRow() {
- const actions = {left: false, right: false};
- if (this.diffRow) {
- actions.left = this._isActionType(
- this.diffRow.getAttribute('left-type'));
- actions.right = this._isActionType(
- this.diffRow.getAttribute('right-type'));
- }
- return actions;
- }
-
- _getStops() {
- return this.diffs.reduce(
- (stops, diff) => stops.concat(diff.getCursorStops()), []);
- }
-
- _updateStops() {
- this.$.cursorManager.stops = this._getStops();
- }
-
- /**
- * Setup and tear down on-render listeners for any diffs that are added or
- * removed from the cursor.
- *
- * @private
- */
- _diffsChanged(changeRecord) {
- if (!changeRecord) { return; }
-
- this._updateStops();
-
- let splice;
- let i;
- for (let spliceIdx = 0;
- changeRecord.indexSplices &&
- spliceIdx < changeRecord.indexSplices.length;
- spliceIdx++) {
- splice = changeRecord.indexSplices[spliceIdx];
-
- for (i = splice.index; i < splice.index + splice.addedCount; i++) {
- this.diffs[i].addEventListener(
- 'render-start', this._boundHandleDiffRenderStart);
- this.diffs[i].addEventListener(
- 'render-content', this._boundHandleDiffRenderContent);
- this.diffs[i].addEventListener(
- 'line-selected', this._boundHandleDiffLineSelected);
- }
-
- for (i = 0; i < splice.removed && splice.removed.length; i++) {
- splice.removed[i].removeEventListener(
- 'render-start', this._boundHandleDiffRenderStart);
- splice.removed[i].removeEventListener(
- 'render-content', this._boundHandleDiffRenderContent);
- splice.removed[i].removeEventListener(
- 'line-selected', this._boundHandleDiffLineSelected);
- }
- }
- }
-
- _findRowByNumberAndFile(targetNumber, side, opt_path) {
- let stops;
- if (opt_path) {
- const diff = this.diffs.filter(diff => diff.path === opt_path)[0];
- stops = diff.getCursorStops();
- } else {
- stops = this.$.cursorManager.stops;
- }
- let selector;
- for (let i = 0; i < stops.length; i++) {
- selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
- if (stops[i].querySelector(selector)) {
- return stops[i];
- }
- }
- }
-}
-
-customElements.define(GrDiffCursor.is, GrDiffCursor);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
new file mode 100644
index 0000000..4605267
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.ts
@@ -0,0 +1,596 @@
+/**
+ * @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-cursor-manager/gr-cursor-manager';
+import {
+ AbortStop,
+ CursorMoveResult,
+ GrCursorManager,
+ Stop,
+ isTargetable,
+} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-diff-cursor_html';
+import {ScrollMode, Side} from '../../../constants/constants';
+import {customElement, property, observe} from '@polymer/decorators';
+import {GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+import {PolymerDomWrapper} from '../../../types/types';
+import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {GrDiff} from '../gr-diff/gr-diff';
+
+const DiffViewMode = {
+ SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+ UNIFIED: 'UNIFIED_DIFF',
+};
+
+type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
+
+const LEFT_SIDE_CLASS = 'target-side-left';
+const RIGHT_SIDE_CLASS = 'target-side-right';
+
+// Time in which pressing n key again after the toast navigates to next file
+const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
+
+export interface GrDiffCursor {
+ $: {
+ cursorManager: GrCursorManager;
+ };
+}
+
+@customElement('gr-diff-cursor')
+export class GrDiffCursor extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ private _preventAutoScrollOnManualScroll = false;
+
+ private lastDisplayedNavigateToNextFileToast: number | null = null;
+
+ @property({type: String})
+ side = Side.RIGHT;
+
+ @property({type: Object, notify: true, observer: '_rowChanged'})
+ diffRow?: HTMLElement;
+
+ @property({type: Object})
+ diffs: GrDiff[] = [];
+
+ /**
+ * If set, the cursor will attempt to move to the line number (instead of
+ * the first chunk) the next time the diff renders. It is set back to null
+ * when used. It should be only used if you want the line to be focused
+ * after initialization of the component and page should scroll
+ * to that position. This parameter should be set at most for one gr-diff
+ * element in the page.
+ */
+ @property({type: Number})
+ initialLineNumber: number | null = null;
+
+ /**
+ * The scroll behavior for the cursor. Values are 'never' and
+ * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+ * the viewport.
+ */
+ @property({type: String})
+ _scrollMode = ScrollMode.KEEP_VISIBLE;
+
+ @property({type: Boolean})
+ _focusOnMove = true;
+
+ @property({type: Boolean})
+ _listeningForScroll = false;
+
+ /** @override */
+ ready() {
+ super.ready();
+ afterNextRender(this, () => {
+ /*
+ This represents the diff cursor is ready for interaction coming from
+ client components. It is more then Polymer "ready" lifecycle, as no
+ "ready" events are automatically fired by Polymer, it means
+ the cursor is completely interactable - in this case attached and
+ painted on the page. We name it "ready" instead of "rendered" as the
+ long-term goal is to make gr-diff-cursor a javascript class - not a DOM
+ element with an actual lifecycle. This will be triggered only once
+ per element.
+ */
+ this.dispatchEvent(
+ new CustomEvent('ready', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ });
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ // Catch when users are scrolling as the view loads.
+ window.addEventListener('scroll', this._boundHandleWindowScroll);
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ window.removeEventListener('scroll', this._boundHandleWindowScroll);
+ }
+
+ moveLeft() {
+ this.side = Side.LEFT;
+ if (this._isTargetBlank()) {
+ this.moveUp();
+ }
+ }
+
+ moveRight() {
+ this.side = Side.RIGHT;
+ if (this._isTargetBlank()) {
+ this.moveUp();
+ }
+ }
+
+ moveDown() {
+ if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+ this.$.cursorManager.next({
+ filter: (row: Element) => this._rowHasSide(row),
+ });
+ } else {
+ this.$.cursorManager.next();
+ }
+ }
+
+ moveUp() {
+ if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+ this.$.cursorManager.previous({
+ filter: (row: Element) => this._rowHasSide(row),
+ });
+ } else {
+ this.$.cursorManager.previous();
+ }
+ }
+
+ moveToVisibleArea() {
+ if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+ this.$.cursorManager.moveToVisibleArea((row: Element) =>
+ this._rowHasSide(row)
+ );
+ } else {
+ this.$.cursorManager.moveToVisibleArea();
+ }
+ }
+
+ moveToNextChunk(clipToTop?: boolean, navigateToNextFile?: boolean) {
+ const result = this.$.cursorManager.next({
+ filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+ getTargetHeight: target =>
+ (target?.parentNode as HTMLElement)?.scrollHeight || 0,
+ clipToTop,
+ });
+ /*
+ * If user presses n on the last diff chunk, show a toast informing user
+ * that pressing n again will navigate them to next unreviewed file.
+ * If click happens within the time limit, then navigate to next file
+ */
+ if (
+ navigateToNextFile &&
+ result === CursorMoveResult.CLIPPED &&
+ this.$.cursorManager.isAtEnd()
+ ) {
+ if (
+ this.lastDisplayedNavigateToNextFileToast &&
+ Date.now() - this.lastDisplayedNavigateToNextFileToast <=
+ NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
+ ) {
+ // reset for next file
+ this.lastDisplayedNavigateToNextFileToast = null;
+ this.dispatchEvent(
+ new CustomEvent('navigate-to-next-unreviewed-file', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ this.lastDisplayedNavigateToNextFileToast = Date.now();
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Press n again to navigate to next unreviewed file',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ this._fixSide();
+ }
+
+ moveToPreviousChunk() {
+ this.$.cursorManager.previous({
+ filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
+ });
+ this._fixSide();
+ }
+
+ moveToNextCommentThread() {
+ this.$.cursorManager.next({
+ filter: (row: HTMLElement) => this._rowHasThread(row),
+ });
+ this._fixSide();
+ }
+
+ moveToPreviousCommentThread() {
+ this.$.cursorManager.previous({
+ filter: (row: HTMLElement) => this._rowHasThread(row),
+ });
+ this._fixSide();
+ }
+
+ moveToLineNumber(number: number, side: Side, path?: string) {
+ const row = this._findRowByNumberAndFile(number, side, path);
+ if (row) {
+ this.side = side;
+ this.$.cursorManager.setCursor(row);
+ }
+ }
+
+ /**
+ * Get the line number element targeted by the cursor row and side.
+ */
+ getTargetLineElement(): HTMLElement | null {
+ let lineElSelector = '.lineNum';
+
+ if (!this.diffRow) {
+ return null;
+ }
+
+ if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+ lineElSelector += this.side === Side.LEFT ? '.left' : '.right';
+ }
+
+ return this.diffRow.querySelector(lineElSelector);
+ }
+
+ getTargetDiffElement(): GrDiff | null {
+ if (!this.diffRow) return null;
+
+ const hostOwner = (dom(this.diffRow) as PolymerDomWrapper).getOwnerRoot();
+ if (hostOwner?.host?.tagName === 'GR-DIFF') {
+ return hostOwner.host as GrDiff;
+ }
+ return null;
+ }
+
+ moveToFirstChunk() {
+ this.$.cursorManager.moveToStart();
+ this.moveToNextChunk(true);
+ }
+
+ moveToLastChunk() {
+ this.$.cursorManager.moveToEnd();
+ this.moveToPreviousChunk();
+ }
+
+ /**
+ * Move the cursor either to initialLineNumber or the first chunk and
+ * reset scroll behavior.
+ *
+ * This may grab the focus from the app.
+ *
+ * If you do not want to move the cursor or grab focus, and just want to
+ * reset the scroll behavior, use reInit() instead.
+ */
+ reInitCursor() {
+ if (!this.diffRow) {
+ // does not scroll during init unless requested
+ this._scrollMode = this.initialLineNumber
+ ? ScrollMode.KEEP_VISIBLE
+ : ScrollMode.NEVER;
+ if (this.initialLineNumber) {
+ this.moveToLineNumber(this.initialLineNumber, this.side);
+ this.initialLineNumber = null;
+ } else {
+ this.moveToFirstChunk();
+ }
+ }
+ this.reInit();
+ }
+
+ reInit() {
+ this._scrollMode = ScrollMode.KEEP_VISIBLE;
+ }
+
+ private _boundHandleWindowScroll = () => {
+ if (this._preventAutoScrollOnManualScroll) {
+ this._scrollMode = ScrollMode.NEVER;
+ this._focusOnMove = false;
+ this._preventAutoScrollOnManualScroll = false;
+ }
+ };
+
+ reInitAndUpdateStops() {
+ this.reInit();
+ this._updateStops();
+ }
+
+ handleDiffUpdate() {
+ this._updateStops();
+ this.reInitCursor();
+ }
+
+ private _boundHandleDiffRenderStart = () => {
+ this._preventAutoScrollOnManualScroll = true;
+ };
+
+ private _boundHandleDiffRenderContent = () => {
+ this._updateStops();
+ // When done rendering, turn focus on move and automatic scrolling back on
+ this._focusOnMove = true;
+ this._preventAutoScrollOnManualScroll = false;
+ };
+
+ private _boundHandleDiffLineSelected = (event: Event) => {
+ const customEvent = event as CustomEvent;
+ this.moveToLineNumber(
+ customEvent.detail.number,
+ customEvent.detail.side,
+ customEvent.detail.path
+ );
+ };
+
+ createCommentInPlace() {
+ const diffWithRangeSelected = this.diffs.find(diff =>
+ diff.isRangeSelected()
+ );
+ if (diffWithRangeSelected) {
+ diffWithRangeSelected.createRangeComment();
+ } else {
+ const line = this.getTargetLineElement();
+ const diff = this.getTargetDiffElement();
+ if (diff && line) {
+ diff.addDraftAtLine(line);
+ }
+ }
+ }
+
+ /**
+ * Get an object describing the location of the cursor. Such as
+ * {leftSide: false, number: 123} for line 123 of the revision, or
+ * {leftSide: true, number: 321} for line 321 of the base patch.
+ * Returns null if an address is not available.
+ *
+ * @return
+ */
+ getAddress() {
+ if (!this.diffRow) {
+ return null;
+ }
+
+ // Get the line-number cell targeted by the cursor. If the mode is unified
+ // then prefer the revision cell if available.
+ let cell;
+ if (this._getViewMode() === DiffViewMode.UNIFIED) {
+ cell = this.diffRow.querySelector('.lineNum.right');
+ if (!cell) {
+ cell = this.diffRow.querySelector('.lineNum.left');
+ }
+ } else {
+ cell = this.diffRow.querySelector('.lineNum.' + this.side);
+ }
+ if (!cell) {
+ return null;
+ }
+
+ const number = cell.getAttribute('data-value');
+ if (!number || number === 'FILE') {
+ return null;
+ }
+
+ return {
+ leftSide: cell.matches('.left'),
+ number: Number(number),
+ };
+ }
+
+ _getViewMode() {
+ if (!this.diffRow) {
+ return null;
+ }
+
+ if (this.diffRow.classList.contains('side-by-side')) {
+ return DiffViewMode.SIDE_BY_SIDE;
+ } else {
+ return DiffViewMode.UNIFIED;
+ }
+ }
+
+ _rowHasSide(row: Element) {
+ const selector =
+ (this.side === Side.LEFT ? '.left' : '.right') + ' + .content';
+ return !!row.querySelector(selector);
+ }
+
+ _isFirstRowOfChunk(row: HTMLElement) {
+ const parentClassList = (row.parentNode as HTMLElement).classList;
+ const isInChunk =
+ parentClassList.contains('section') && parentClassList.contains('delta');
+ const previousRow = row.previousSibling as HTMLElement;
+ const firstContentRow =
+ !previousRow || previousRow.classList.contains('moveControls');
+ return isInChunk && firstContentRow;
+ }
+
+ _rowHasThread(row: HTMLElement): boolean {
+ return !!row.querySelector('.thread-group');
+ }
+
+ /**
+ * If we jumped to a row where there is no content on the current side then
+ * switch to the alternate side.
+ */
+ _fixSide() {
+ if (
+ this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
+ this._isTargetBlank()
+ ) {
+ this.side = this.side === Side.LEFT ? Side.RIGHT : Side.LEFT;
+ }
+ }
+
+ _isTargetBlank() {
+ if (!this.diffRow) {
+ return false;
+ }
+
+ const actions = this._getActionsForRow();
+ return (
+ (this.side === Side.LEFT && !actions.left) ||
+ (this.side === Side.RIGHT && !actions.right)
+ );
+ }
+
+ _rowChanged(_: HTMLElement, oldRow: HTMLElement) {
+ if (oldRow) {
+ oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+ }
+ this._updateSideClass();
+ }
+
+ @observe('side')
+ _updateSideClass() {
+ if (!this.diffRow) {
+ return;
+ }
+ this.toggleClass(LEFT_SIDE_CLASS, this.side === Side.LEFT, this.diffRow);
+ this.toggleClass(RIGHT_SIDE_CLASS, this.side === Side.RIGHT, this.diffRow);
+ }
+
+ _isActionType(type: GrDiffRowType) {
+ return (
+ type !== GrDiffLineType.BLANK && type !== GrDiffGroupType.CONTEXT_CONTROL
+ );
+ }
+
+ _getActionsForRow() {
+ const actions = {left: false, right: false};
+ if (this.diffRow) {
+ actions.left = this._isActionType(
+ this.diffRow.getAttribute('left-type') as GrDiffRowType
+ );
+ actions.right = this._isActionType(
+ this.diffRow.getAttribute('right-type') as GrDiffRowType
+ );
+ }
+ return actions;
+ }
+
+ _updateStops() {
+ this.$.cursorManager.stops = this.diffs.reduce(
+ (stops: Stop[], diff) => stops.concat(diff.getCursorStops()),
+ []
+ );
+ }
+
+ /**
+ * Setup and tear down on-render listeners for any diffs that are added or
+ * removed from the cursor.
+ */
+ @observe('diffs.splices')
+ _diffsChanged(changeRecord: PolymerSpliceChange<GrDiff[]>) {
+ if (!changeRecord) {
+ return;
+ }
+
+ this._updateStops();
+
+ let splice;
+ let i;
+ for (
+ let spliceIdx = 0;
+ changeRecord.indexSplices && spliceIdx < changeRecord.indexSplices.length;
+ spliceIdx++
+ ) {
+ splice = changeRecord.indexSplices[spliceIdx];
+
+ // Removals must come before additions, because the gr-diff instances
+ // might be the same.
+ for (i = 0; i < splice?.removed.length; i++) {
+ splice.removed[i].removeEventListener(
+ 'render-start',
+ this._boundHandleDiffRenderStart
+ );
+ splice.removed[i].removeEventListener(
+ 'render-content',
+ this._boundHandleDiffRenderContent
+ );
+ splice.removed[i].removeEventListener(
+ 'line-selected',
+ this._boundHandleDiffLineSelected
+ );
+ }
+
+ for (i = splice.index; i < splice.index + splice.addedCount; i++) {
+ this.diffs[i].addEventListener(
+ 'render-start',
+ this._boundHandleDiffRenderStart
+ );
+ this.diffs[i].addEventListener(
+ 'render-content',
+ this._boundHandleDiffRenderContent
+ );
+ this.diffs[i].addEventListener(
+ 'line-selected',
+ this._boundHandleDiffLineSelected
+ );
+ }
+ }
+ }
+
+ _findRowByNumberAndFile(
+ targetNumber: number,
+ side: Side,
+ path?: string
+ ): HTMLElement | undefined {
+ let stops: Array<HTMLElement | AbortStop>;
+ if (path) {
+ const diff = this.diffs.filter(diff => diff.path === path)[0];
+ stops = diff.getCursorStops();
+ } else {
+ stops = this.$.cursorManager.stops;
+ }
+ // Sadly needed for type narrowing to understand that the result is always
+ // targetable.
+ const targetableStops: HTMLElement[] = stops.filter(isTargetable);
+ const selector = `.lineNum.${side}[data-value="${targetNumber}"]`;
+ return targetableStops.find(stop => stop.querySelector(selector));
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-cursor': GrDiffCursor;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
index 712a93d..1539a22 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
@@ -23,6 +23,5 @@
cursor-target-class="target-row"
focus-on-move="[[_focusOnMove]]"
target="{{diffRow}}"
- scroll-top-margin="[[scrollTopMargin]]"
></gr-cursor-manager>
`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
index 4ca75eb..9cff938 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -19,9 +19,9 @@
import '../gr-diff/gr-diff.js';
import './gr-diff-cursor.js';
import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {listenOnce} from '../../../test/test-utils.js';
+import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
const basicFixture = fixtureFromTemplate(html`
<gr-diff></gr-diff>
@@ -34,6 +34,7 @@
suite('gr-diff-cursor tests', () => {
let cursorElement;
let diffElement;
+ let diff;
setup(done => {
const fixtureElems = basicFixture.instantiate();
@@ -59,9 +60,10 @@
};
diffElement.addEventListener('render', setupDone);
+ diff = getMockDiffResponse();
restAPI.getDiffPreferences().then(prefs => {
diffElement.prefs = prefs;
- diffElement.diff = getMockDiffResponse();
+ diffElement.diff = diff;
});
});
@@ -85,7 +87,7 @@
});
test('moveToLastChunk', () => {
- const chunks = Array.from(dom(diffElement.root).querySelectorAll(
+ const chunks = Array.from(diffElement.root.querySelectorAll(
'.section.delta'));
assert.isAbove(chunks.length, 1);
assert.equal(chunks.indexOf(cursorElement.diffRow.parentElement), 0);
@@ -99,14 +101,14 @@
test('cursor scroll behavior', () => {
assert.equal(cursorElement._scrollMode, 'keep-visible');
- cursorElement._handleDiffRenderStart();
+ diffElement.dispatchEvent(new Event('render-start'));
assert.isTrue(cursorElement._focusOnMove);
- cursorElement._handleWindowScroll();
+ window.dispatchEvent(new Event('scroll'));
assert.equal(cursorElement._scrollMode, 'never');
assert.isFalse(cursorElement._focusOnMove);
- cursorElement._handleDiffRenderContent();
+ diffElement.dispatchEvent(new Event('render-content'));
assert.isTrue(cursorElement._focusOnMove);
cursorElement.reInitCursor();
@@ -116,7 +118,7 @@
test('moves to selected line', () => {
const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
- cursorElement._handleDiffLineSelected(
+ diffElement.dispatchEvent(
new CustomEvent('line-selected', {
detail: {number: '123', side: 'right', path: 'some/file'},
}));
@@ -201,7 +203,7 @@
});
test('chunk skip functionality', () => {
- const chunks = dom(diffElement.root).querySelectorAll(
+ const chunks = diffElement.root.querySelectorAll(
'.section.delta');
const indexOfChunk = function(chunk) {
return Array.prototype.indexOf.call(chunks, chunk);
@@ -224,18 +226,86 @@
assert.equal(cursorElement.side, 'left');
});
+ suite('moved chunks (dueToMove=true)', () => {
+ setup(done => {
+ const renderHandler = function() {
+ diffElement.removeEventListener('render', renderHandler);
+ cursorElement.reInitCursor();
+ done();
+ };
+ diffElement.addEventListener('render', renderHandler);
+ diffElement.diff = {...diff, content: [
+ {
+ ab: [
+ 'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ',
+ ],
+ },
+ {
+ b: [
+ 'Nullam neque, ligula ac, id blandit.',
+ 'Sagittis tincidunt torquent, tempor nunc amet.',
+ 'At rhoncus id.',
+ ],
+ due_to_move: true,
+ },
+ {
+ ab: [
+ 'Sem nascetur, erat ut, non in.',
+ ],
+ },
+ {
+ a: [
+ 'Nullam neque, ligula ac, id blandit.',
+ 'Sagittis tincidunt torquent, tempor nunc amet.',
+ 'At rhoncus id.',
+ ],
+ due_to_move: true,
+ },
+ {
+ ab: [
+ 'Arcu eget, rhoncus amet cursus, ipsum elementum.',
+ ],
+ },
+ ]};
+ });
+
+ test('chunk skip functionality', () => {
+ const chunks = diffElement.root.querySelectorAll(
+ '.section.delta');
+ const indexOfChunk = function(chunk) {
+ return Array.prototype.indexOf.call(chunks, chunk);
+ };
+
+ // We should be initialized to the first chunk (b)
+ let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+ assert.equal(currentIndex, 0);
+ assert.equal(cursorElement.side, 'right');
+
+ // Move to the next chunk.
+ cursorElement.moveToNextChunk();
+
+ // Since the next chunk only has content on the left side (a). we should have been
+ // automatically moved over.
+ const previousIndex = currentIndex;
+ currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+ assert.equal(currentIndex, previousIndex + 1);
+ assert.equal(cursorElement.side, 'left');
+ });
+ });
+
test('navigate to next unreviewed file via moveToNextChunk', () => {
- const cursor = cursorElement.shadowRoot.querySelector('#cursorManager');
- cursor.index = cursor.stops.length - 1;
- const dispatchEventStub = sinon.stub(cursor, 'dispatchEvent');
+ const cursorManager =
+ cursorElement.shadowRoot.querySelector('#cursorManager');
+ cursorManager.index = cursorManager.stops.length - 1;
+ const dispatchEventStub = sinon.stub(cursorElement, 'dispatchEvent');
cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
/* opt_navigateToNextFile = */true);
assert.isTrue(dispatchEventStub.called);
- assert.equal(dispatchEventStub.getCall(0).args[0].type, 'show-alert');
+ assert.equal(dispatchEventStub.getCall(1).args[0].type, 'show-alert');
cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
/* opt_navigateToNextFile = */true);
- assert.equal(dispatchEventStub.getCall(1).args[0].type,
+ assert.equal(dispatchEventStub.getCall(2).args[0].type,
'navigate-to-next-unreviewed-file');
});
@@ -387,7 +457,7 @@
test('_findRowByNumberAndFile', () => {
// Get the first ab row after the first chunk.
- const row = dom(diffElement.root).querySelectorAll('tr')[8];
+ const row = diffElement.root.querySelectorAll('tr')[8];
// It should be line 8 on the right, but line 5 on the left.
assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
@@ -421,5 +491,78 @@
someEmptyDiv.appendChild(cursorElement);
});
});
+
+ suite('multi diff', () => {
+ const multiDiffFixture = fixtureFromTemplate(html`
+ <gr-diff></gr-diff>
+ <gr-diff></gr-diff>
+ <gr-diff></gr-diff>
+ <gr-diff-cursor></gr-diff-cursor>
+ <gr-rest-api-interface></gr-rest-api-interface>
+ `);
+
+ let diffElements;
+
+ setup(async () => {
+ const fixtureElems = multiDiffFixture.instantiate();
+ diffElements = fixtureElems.slice(0, 3);
+ cursorElement = fixtureElems[3];
+ const restAPI = fixtureElems[4];
+
+ // Register the diff with the cursor.
+ cursorElement.push('diffs', ...diffElements);
+
+ await restAPI.getDiffPreferences().then(prefs => {
+ for (const el of diffElements) {
+ el.prefs = prefs;
+ }
+ });
+ });
+
+ function getTargetDiffIndex() {
+ // Mocha has a bug where when `assert.equals` fails, it will try to
+ // JSON.stringify the operands, which fails when they are cyclic structures
+ // like GrDiffElement. The failure is difficult to attribute to a specific
+ // assertion because of the async nature assertion errors are handled and
+ // can cause the test simply timing out, causing a lot of debugging headache.
+ // Working with indices circumvents the problem.
+ return diffElements.indexOf(cursorElement.getTargetDiffElement());
+ }
+
+ test('do not skip loading diffs', async () => {
+ const diffRenderedPromises =
+ diffElements.map(diffEl => listenOnce(diffEl, 'render'));
+
+ diffElements[0].diff = getMockDiffResponse();
+ diffElements[2].diff = getMockDiffResponse();
+ await Promise.all([diffRenderedPromises[0], diffRenderedPromises[2]]);
+
+ const lastLine = diffElements[0].diff.meta_b.lines;
+
+ // Goto second last line of the first diff
+ cursorElement.moveToLineNumber(lastLine - 1, 'right');
+ assert.equal(
+ cursorElement.getTargetLineElement().textContent, lastLine - 1);
+
+ // Can move down until we reach the loading file
+ cursorElement.moveDown();
+ assert.equal(getTargetDiffIndex(), 0);
+ assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+
+ // Cannot move down while still loading the diff we would switch to
+ cursorElement.moveDown();
+ assert.equal(getTargetDiffIndex(), 0);
+ assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+
+ // Diff 1 finishing to load
+ diffElements[1].diff = getMockDiffResponse();
+ await diffRenderedPromises[1];
+
+ // Now we can go down
+ cursorElement.moveDown();
+ assert.equal(getTargetDiffIndex(), 1);
+ assert.equal(cursorElement.getTargetLineElement().textContent, 'File');
+ });
+ });
});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
deleted file mode 100644
index c86760a..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
+++ /dev/null
@@ -1,278 +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 {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {sanitizeDOMValue} from '@polymer/polymer/lib/utils/settings.js';
-
-// TODO(wyatta): refactor this to be <MARK> rather than <HL>.
-const ANNOTATION_TAG = 'HL';
-
-// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
-const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
-
-export const GrAnnotation = {
-
- /**
- * The DOM API textContent.length calculation is broken when the text
- * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
- *
- * @param {!Text} node text node.
- * @return {number} The length of the text.
- */
- getLength(node) {
- return this.getStringLength(node.textContent);
- },
-
- getStringLength(str) {
- return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
- },
-
- /**
- * Annotates the [offset, offset+length) text segment in the parent with the
- * element definition provided as arguments.
- *
- * @param {!Element} parent the node whose contents will be annotated.
- * @param {number} offset the 0-based offset from which the annotation will
- * start.
- * @param {number} length of the annotated text.
- * @param {GrAnnotation.ElementSpec} elementSpec the spec to create the
- * annotating element.
- */
- annotateWithElement(parent, offset, length, {tagName, attributes = {}}) {
- let childNodes;
-
- if (parent instanceof Element) {
- childNodes = Array.from(parent.childNodes);
- } else if (parent instanceof Text) {
- childNodes = [parent];
- parent = parent.parentNode;
- } else {
- return;
- }
-
- const nestedNodes = [];
- for (let node of childNodes) {
- const initialNodeLength = this.getLength(node);
- // If the current node is completely before the offset.
- if (offset > 0 && initialNodeLength <= offset) {
- offset -= initialNodeLength;
- continue;
- }
-
- if (offset > 0) {
- node = this.splitNode(node, offset);
- offset = 0;
- }
- if (this.getLength(node) > length) {
- this.splitNode(node, length);
- }
- nestedNodes.push(node);
-
- length -= this.getLength(node);
- if (!length) break;
- }
-
- const wrapper = document.createElement(tagName);
- const sanitizer = sanitizeDOMValue;
- for (const [name, value] of Object.entries(attributes)) {
- wrapper.setAttribute(
- name, sanitizer ?
- sanitizer(value, name, 'attribute', wrapper) :
- value);
- }
- for (const inner of nestedNodes) {
- parent.replaceChild(wrapper, inner);
- wrapper.appendChild(inner);
- }
- },
-
- /**
- * Surrounds the element's text at specified range in an ANNOTATION_TAG
- * element. If the element has child elements, the range is split and
- * applied as deeply as possible.
- */
- annotateElement(parent, offset, length, cssClass) {
- const nodes = [].slice.apply(parent.childNodes);
- let nodeLength;
- let subLength;
-
- for (const node of nodes) {
- nodeLength = this.getLength(node);
-
- // If the current node is completely before the offset.
- if (nodeLength <= offset) {
- offset -= nodeLength;
- continue;
- }
-
- // Sublength is the annotation length for the current node.
- subLength = Math.min(length, nodeLength - offset);
-
- if (node instanceof Text) {
- this._annotateText(node, offset, subLength, cssClass);
- } else if (node instanceof HTMLElement) {
- this.annotateElement(node, offset, subLength, cssClass);
- }
-
- // If there is still more to annotate, then shift the indices, otherwise
- // work is done, so break the loop.
- if (subLength < length) {
- length -= subLength;
- offset = 0;
- } else {
- break;
- }
- }
- },
-
- /**
- * Wraps node in annotation tag with cssClass, replacing the node in DOM.
- *
- * @return {!Element} Wrapped node.
- */
- wrapInHighlight(node, cssClass) {
- let hl;
- if (node.tagName === ANNOTATION_TAG) {
- hl = node;
- hl.classList.add(cssClass);
- } else {
- hl = document.createElement(ANNOTATION_TAG);
- hl.className = cssClass;
- dom(node.parentElement).replaceChild(hl, node);
- dom(hl).appendChild(node);
- }
- return hl;
- },
-
- /**
- * Splits Text Node and wraps it in hl with cssClass.
- * Wraps trailing part after split, tailing one if opt_firstPart is true.
- *
- * @param {!Node} node
- * @param {number} offset
- * @param {string} cssClass
- * @param {boolean=} opt_firstPart
- */
- splitAndWrapInHighlight(node, offset, cssClass, opt_firstPart) {
- if (this.getLength(node) === offset || offset === 0) {
- return this.wrapInHighlight(node, cssClass);
- } else {
- if (opt_firstPart) {
- this.splitNode(node, offset);
- // Node points to first part of the Text, second one is sibling.
- } else {
- node = this.splitNode(node, offset);
- }
- return this.wrapInHighlight(node, cssClass);
- }
- },
-
- /**
- * Splits Node at offset.
- * If Node is Element, it's cloned and the node at offset is split too.
- *
- * @param {!Node} node
- * @param {number} offset
- * @return {!Node} Trailing Node.
- */
- splitNode(element, offset) {
- if (element instanceof Text) {
- return this.splitTextNode(element, offset);
- }
- const tail = element.cloneNode(false);
- element.parentElement.insertBefore(tail, element.nextSibling);
- // Skip nodes before offset.
- let node = element.firstChild;
- while (node &&
- this.getLength(node) <= offset ||
- this.getLength(node) === 0) {
- offset -= this.getLength(node);
- node = node.nextSibling;
- }
- if (this.getLength(node) > offset) {
- tail.appendChild(this.splitNode(node, offset));
- }
- while (node.nextSibling) {
- tail.appendChild(node.nextSibling);
- }
- return tail;
- },
-
- /**
- * Node.prototype.splitText Unicode-valid alternative.
- *
- * DOM Api for splitText() is broken for Unicode:
- * https://mathiasbynens.be/notes/javascript-unicode
- *
- * @param {!Text} node
- * @param {number} offset
- * @return {!Text} Trailing Text Node.
- */
- splitTextNode(node, offset) {
- if (node.textContent.match(REGEX_ASTRAL_SYMBOL)) {
- // TODO (viktard): Polyfill Array.from for IE10.
- const head = Array.from(node.textContent);
- const tail = head.splice(offset);
- const parent = node.parentNode;
-
- // Split the content of the original node.
- node.textContent = head.join('');
-
- const tailNode = document.createTextNode(tail.join(''));
- if (parent) {
- parent.insertBefore(tailNode, node.nextSibling);
- }
- return tailNode;
- } else {
- return node.splitText(offset);
- }
- },
-
- _annotateText(node, offset, length, cssClass) {
- const nodeLength = this.getLength(node);
-
- // There are four cases:
- // 1) Entire node is highlighted.
- // 2) Highlight is at the start.
- // 3) Highlight is at the end.
- // 4) Highlight is in the middle.
-
- if (offset === 0 && nodeLength === length) {
- // Case 1.
- this.wrapInHighlight(node, cssClass);
- } else if (offset === 0) {
- // Case 2.
- this.splitAndWrapInHighlight(node, length, cssClass, true);
- } else if (offset + length === nodeLength) {
- // Case 3
- this.splitAndWrapInHighlight(node, offset, cssClass, false);
- } else {
- // Case 4
- this.splitAndWrapInHighlight(this.splitTextNode(node, offset), length,
- cssClass, true);
- }
- },
-};
-
-/**
- * Data used to construct an element.
- *
- * @typedef {{
- * tagName: string,
- * attributes: (!Object<string, *>|undefined)
- * }}
- */
-GrAnnotation.ElementSpec;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
new file mode 100644
index 0000000..7420dc8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
@@ -0,0 +1,287 @@
+/**
+ * @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 {getSanitizeDOMValue} from '@polymer/polymer/lib/utils/settings';
+
+// TODO(wyatta): refactor this to be <MARK> rather than <HL>.
+const ANNOTATION_TAG = 'HL';
+
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+export const GrAnnotation = {
+ /**
+ * The DOM API textContent.length calculation is broken when the text
+ * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+ *
+ */
+ getLength(node: Node) {
+ return this.getStringLength(node.textContent || '');
+ },
+
+ getStringLength(str: string) {
+ return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
+ },
+
+ /**
+ * Annotates the [offset, offset+length) text segment in the parent with the
+ * element definition provided as arguments.
+ *
+ * @param parent the node whose contents will be annotated.
+ * If parent is Text then parent.parentNode must not be null
+ * @param offset the 0-based offset from which the annotation will
+ * start.
+ * @param length of the annotated text.
+ * @param elementSpec the spec to create the
+ * annotating element.
+ */
+ annotateWithElement(
+ parent: Node,
+ offset: number,
+ length: number,
+ elSpec: ElementSpec
+ ) {
+ const tagName = elSpec.tagName;
+ const attributes = elSpec.attributes || {};
+ let childNodes: Node[];
+
+ if (parent instanceof Element) {
+ childNodes = Array.from(parent.childNodes);
+ } else if (parent instanceof Text) {
+ childNodes = [parent];
+ parent = parent.parentNode!;
+ } else {
+ return;
+ }
+
+ const nestedNodes: Node[] = [];
+ for (let node of childNodes) {
+ const initialNodeLength = this.getLength(node);
+ // If the current node is completely before the offset.
+ if (offset > 0 && initialNodeLength <= offset) {
+ offset -= initialNodeLength;
+ continue;
+ }
+
+ if (offset > 0) {
+ node = this.splitNode(node, offset);
+ offset = 0;
+ }
+ if (this.getLength(node) > length) {
+ this.splitNode(node, length);
+ }
+ nestedNodes.push(node);
+
+ length -= this.getLength(node);
+ if (!length) break;
+ }
+
+ const wrapper = document.createElement(tagName);
+ const sanitizer = getSanitizeDOMValue();
+ for (let [name, value] of Object.entries(attributes)) {
+ if (!value) continue;
+ if (sanitizer) {
+ value = sanitizer(value, name, 'attribute', wrapper) as string;
+ }
+ wrapper.setAttribute(name, value);
+ }
+ for (const inner of nestedNodes) {
+ parent.replaceChild(wrapper, inner);
+ wrapper.appendChild(inner);
+ }
+ },
+
+ /**
+ * Surrounds the element's text at specified range in an ANNOTATION_TAG
+ * element. If the element has child elements, the range is split and
+ * applied as deeply as possible.
+ */
+ annotateElement(
+ parent: HTMLElement,
+ offset: number,
+ length: number,
+ cssClass: string
+ ) {
+ const nodes: Array<HTMLElement | Text> = [].slice.apply(parent.childNodes);
+ let nodeLength;
+ let subLength;
+
+ for (const node of nodes) {
+ nodeLength = this.getLength(node);
+
+ // If the current node is completely before the offset.
+ if (nodeLength <= offset) {
+ offset -= nodeLength;
+ continue;
+ }
+
+ // Sublength is the annotation length for the current node.
+ subLength = Math.min(length, nodeLength - offset);
+
+ if (node instanceof Text) {
+ this._annotateText(node, offset, subLength, cssClass);
+ } else if (node instanceof HTMLElement) {
+ this.annotateElement(node, offset, subLength, cssClass);
+ }
+
+ // If there is still more to annotate, then shift the indices, otherwise
+ // work is done, so break the loop.
+ if (subLength < length) {
+ length -= subLength;
+ offset = 0;
+ } else {
+ break;
+ }
+ }
+ },
+
+ /**
+ * Wraps node in annotation tag with cssClass, replacing the node in DOM.
+ */
+ wrapInHighlight(node: Element | Text, cssClass: string) {
+ let hl;
+ if (!(node instanceof Text) && node.tagName === ANNOTATION_TAG) {
+ hl = node;
+ hl.classList.add(cssClass);
+ } else {
+ hl = document.createElement(ANNOTATION_TAG);
+ hl.className = cssClass;
+ if (node.parentElement) node.parentElement.replaceChild(hl, node);
+ hl.appendChild(node);
+ }
+ return hl;
+ },
+
+ /**
+ * Splits Text Node and wraps it in hl with cssClass.
+ * Wraps trailing part after split, tailing one if firstPart is true.
+ */
+ splitAndWrapInHighlight(
+ node: Text,
+ offset: number,
+ cssClass: string,
+ firstPart?: boolean
+ ) {
+ if (this.getLength(node) === offset || offset === 0) {
+ return this.wrapInHighlight(node, cssClass);
+ } else {
+ if (firstPart) {
+ this.splitNode(node, offset);
+ // Node points to first part of the Text, second one is sibling.
+ } else {
+ // if node is Text then splitNode will return a Text
+ node = this.splitNode(node, offset) as Text;
+ }
+ return this.wrapInHighlight(node, cssClass);
+ }
+ },
+
+ /**
+ * Splits Node at offset.
+ * If Node is Element, it's cloned and the node at offset is split too.
+ */
+ splitNode(element: Node, offset: number) {
+ if (element instanceof Text) {
+ return this.splitTextNode(element, offset);
+ }
+ const tail = element.cloneNode(false);
+
+ if (element.parentElement)
+ element.parentElement.insertBefore(tail, element.nextSibling);
+ // Skip nodes before offset.
+ let node = element.firstChild;
+ while (
+ node &&
+ (this.getLength(node) <= offset || this.getLength(node) === 0)
+ ) {
+ offset -= this.getLength(node);
+ node = node.nextSibling;
+ }
+ if (node && this.getLength(node) > offset) {
+ tail.appendChild(this.splitNode(node, offset));
+ }
+ while (node && node.nextSibling) {
+ tail.appendChild(node.nextSibling);
+ }
+ return tail;
+ },
+
+ /**
+ * Node.prototype.splitText Unicode-valid alternative.
+ *
+ * DOM Api for splitText() is broken for Unicode:
+ * https://mathiasbynens.be/notes/javascript-unicode
+ *
+ * @return Trailing Text Node.
+ */
+ splitTextNode(node: Text, offset: number) {
+ if (node.textContent?.match(REGEX_ASTRAL_SYMBOL)) {
+ // TODO (viktard): Polyfill Array.from for IE10.
+ const head = Array.from(node.textContent);
+ const tail = head.splice(offset);
+ const parent = node.parentNode;
+
+ // Split the content of the original node.
+ node.textContent = head.join('');
+
+ const tailNode = document.createTextNode(tail.join(''));
+ if (parent) {
+ parent.insertBefore(tailNode, node.nextSibling);
+ }
+ return tailNode;
+ } else {
+ return node.splitText(offset);
+ }
+ },
+
+ _annotateText(node: Text, offset: number, length: number, cssClass: string) {
+ const nodeLength = this.getLength(node);
+
+ // There are four cases:
+ // 1) Entire node is highlighted.
+ // 2) Highlight is at the start.
+ // 3) Highlight is at the end.
+ // 4) Highlight is in the middle.
+
+ if (offset === 0 && nodeLength === length) {
+ // Case 1.
+ this.wrapInHighlight(node, cssClass);
+ } else if (offset === 0) {
+ // Case 2.
+ this.splitAndWrapInHighlight(node, length, cssClass, true);
+ } else if (offset + length === nodeLength) {
+ // Case 3
+ this.splitAndWrapInHighlight(node, offset, cssClass, false);
+ } else {
+ // Case 4
+ this.splitAndWrapInHighlight(
+ this.splitTextNode(node, offset),
+ length,
+ cssClass,
+ true
+ );
+ }
+ },
+};
+
+/**
+ * Data used to construct an element.
+ *
+ */
+export interface ElementSpec {
+ tagName: string;
+ attributes?: {[attributeName: string]: string | undefined};
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
deleted file mode 100644
index b82d4b8..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ /dev/null
@@ -1,534 +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 '../../../styles/shared-styles.js';
-import '../gr-selection-action-box/gr-selection-action-box.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-diff-highlight_html.js';
-import {GrAnnotation} from './gr-annotation.js';
-import {GrRangeNormalizer} from './gr-range-normalizer.js';
-import {strToClassName} from '../../../utils/dom-util.js';
-
-/**
- * @extends PolymerElement
- */
-class GrDiffHighlight extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff-highlight'; }
-
- static get properties() {
- return {
- /** @type {!Array<!Gerrit.HoveredRange>} */
- commentRanges: {
- type: Array,
- notify: true,
- },
- loggedIn: Boolean,
- /**
- * querySelector can return null, so needs to be nullable.
- *
- * @type {?HTMLElement}
- * */
- _cachedDiffBuilder: Object,
-
- /**
- * Which range is currently selected by the user.
- * Stored in order to add a range-based comment
- * later.
- * undefined if no range is selected.
- *
- * @type {{side: string, range: Gerrit.Range}|undefined}
- */
- selectedRange: {
- type: Object,
- notify: true,
- },
- };
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('comment-thread-mouseleave',
- e => this._handleCommentThreadMouseleave(e));
- this.addEventListener('comment-thread-mouseenter',
- e => this._handleCommentThreadMouseenter(e));
- this.addEventListener('create-comment-requested',
- e => this._handleRangeCommentRequest(e));
- }
-
- get diffBuilder() {
- if (!this._cachedDiffBuilder) {
- this._cachedDiffBuilder =
- dom(this).querySelector('gr-diff-builder');
- }
- return this._cachedDiffBuilder;
- }
-
- /**
- * Determines side/line/range for a DOM selection and shows a tooltip.
- *
- * With native shadow DOM, gr-diff-highlight cannot access a selection that
- * references the DOM elements making up the diff because they are in the
- * shadow DOM the gr-diff element. For this reason, we listen to the
- * selectionchange event and retrieve the selection in gr-diff, and then
- * call this method to process the Selection.
- *
- * @param {Selection} selection A DOM Selection living in the shadow DOM of
- * the diff element.
- * @param {boolean} isMouseUp If true, this is called due to a mouseup
- * event, in which case we might want to immediately create a comment,
- * because isMouseUp === true combined with an existing selection must
- * mean that this is the end of a double-click.
- */
- handleSelectionChange(selection, isMouseUp) {
- // Debounce is not just nice for waiting until the selection has settled,
- // it is also vital for being able to click on the action box before it is
- // removed.
- // If you wait longer than 50 ms, then you don't properly catch a very
- // quick 'c' press after the selection change. If you wait less than 10
- // ms, then you will have about 50 _handleSelection calls when doing a
- // simple drag for select.
- this.debounce(
- 'selectionChange', () => this._handleSelection(selection, isMouseUp),
- 10);
- }
-
- _getThreadEl(e) {
- const path = dom(e).path || [];
- for (const pathEl of path) {
- if (pathEl.classList.contains('comment-thread')) return pathEl;
- }
- return null;
- }
-
- _toggleRangeElHighlight(threadEl, highlightRange = false) {
- // We don't want to re-create the line just for highlighting the range which
- // is creating annoying bugs: @see Issue 12934
- // As gr-ranged-comment-layer now does not notify the layer re-render and
- // lack of access to the thread or the lineEl from the ranged-comment-layer,
- // need to update range class for styles here.
- let curNode = threadEl.assignedSlot;
- while (curNode) {
- if (curNode.nodeName === 'TABLE') break;
- curNode = curNode.parentElement;
- }
- if (curNode && curNode.querySelectorAll) {
- if (highlightRange) {
- const rangeNodes = curNode
- .querySelectorAll(`.range.${strToClassName(threadEl.rootId)}`);
- rangeNodes.forEach(rangeNode => {
- rangeNode.classList.add('rangeHighlight');
- rangeNode.classList.remove('range');
- });
- } else {
- const rangeNodes = curNode.querySelectorAll(
- `.rangeHighlight.${strToClassName(threadEl.rootId)}`
- );
- rangeNodes.forEach(rangeNode => {
- rangeNode.classList.remove('rangeHighlight');
- rangeNode.classList.add('range');
- });
- }
- }
- }
-
- _handleCommentThreadMouseenter(e) {
- const threadEl = this._getThreadEl(e);
- const index = this._indexForThreadEl(threadEl);
-
- if (index !== undefined) {
- this.set(['commentRanges', index, 'hovering'], true);
- }
-
- this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
- }
-
- _handleCommentThreadMouseleave(e) {
- const threadEl = this._getThreadEl(e);
- const index = this._indexForThreadEl(threadEl);
-
- if (index !== undefined) {
- this.set(['commentRanges', index, 'hovering'], false);
- }
-
- this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
- }
-
- _indexForThreadEl(threadEl) {
- const side = threadEl.getAttribute('comment-side');
- const range = JSON.parse(threadEl.getAttribute('range'));
-
- if (!range) return undefined;
-
- return this._indexOfCommentRange(side, range);
- }
-
- _indexOfCommentRange(side, range) {
- function rangesEqual(a, b) {
- if (!a && !b) {
- return true;
- }
- if (!a || !b) {
- return false;
- }
- return a.start_line === b.start_line &&
- a.start_character === b.start_character &&
- a.end_line === b.end_line &&
- a.end_character === b.end_character;
- }
-
- return this.commentRanges.findIndex(commentRange =>
- commentRange.side === side && rangesEqual(commentRange.range, range));
- }
-
- /**
- * Get current normalized selection.
- * Merges multiple ranges, accounts for triple click, accounts for
- * syntax highligh, convert native DOM Range objects to Gerrit concepts
- * (line, side, etc).
- *
- * @param {Selection} selection
- * @return {({
- * start: {
- * node: Node,
- * side: string,
- * line: Number,
- * column: Number
- * },
- * end: {
- * node: Node,
- * side: string,
- * line: Number,
- * column: Number
- * }
- * })|null|!Object}
- */
- _getNormalizedRange(selection) {
- const rangeCount = selection.rangeCount;
- if (rangeCount === 0) {
- return null;
- } else if (rangeCount === 1) {
- return this._normalizeRange(selection.getRangeAt(0));
- } else {
- const startRange = this._normalizeRange(selection.getRangeAt(0));
- const endRange = this._normalizeRange(
- selection.getRangeAt(rangeCount - 1));
- return {
- start: startRange.start,
- end: endRange.end,
- };
- }
- }
-
- /**
- * Normalize a specific DOM Range.
- *
- * @return {!Object} fixed normalized range
- */
- _normalizeRange(domRange) {
- const range = GrRangeNormalizer.normalize(domRange);
- return this._fixTripleClickSelection({
- start: this._normalizeSelectionSide(
- range.startContainer, range.startOffset),
- end: this._normalizeSelectionSide(
- range.endContainer, range.endOffset),
- }, domRange);
- }
-
- /**
- * Adjust triple click selection for the whole line.
- * A triple click always results in:
- * - start.column == end.column == 0
- * - end.line == start.line + 1
- *
- * @param {!Object} range Normalized range, ie column/line numbers
- * @param {!Range} domRange DOM Range object
- * @return {!Object} fixed normalized range
- */
- _fixTripleClickSelection(range, domRange) {
- if (!range.start) {
- // Selection outside of current diff.
- return range;
- }
- const start = range.start;
- const end = range.end;
- // Happens when triple click in side-by-side mode with other side empty.
- const endsAtOtherEmptySide = !end &&
- domRange.endOffset === 0 &&
- domRange.endContainer.nodeName === 'TD' &&
- (domRange.endContainer.classList.contains('left') ||
- domRange.endContainer.classList.contains('right'));
- const endsAtBeginningOfNextLine = end &&
- start.column === 0 &&
- end.column === 0 &&
- end.line === start.line + 1;
- const content = domRange.cloneContents().querySelector('.contentText');
- const lineLength = content && this._getLength(content) || 0;
- if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
- // Move the selection to the end of the previous line.
- range.end = {
- node: start.node,
- column: lineLength,
- side: start.side,
- line: start.line,
- };
- }
- return range;
- }
-
- /**
- * Convert DOM Range selection to concrete numbers (line, column, side).
- * Moves range end if it's not inside td.content.
- * Returns null if selection end is not valid (outside of diff).
- *
- * @param {Node} node td.content child
- * @param {number} offset offset within node
- * @return {({
- * node: Node,
- * side: string,
- * line: Number,
- * column: Number
- * }|undefined)}
- */
- _normalizeSelectionSide(node, offset) {
- let column;
- if (!this.contains(node)) {
- return;
- }
- const lineEl = this.diffBuilder.getLineElByChild(node);
- if (!lineEl) {
- return;
- }
- const side = this.diffBuilder.getSideByLineEl(lineEl);
- if (!side) {
- return;
- }
- const line = this.diffBuilder.getLineNumberByChild(lineEl);
- if (!line) {
- return;
- }
- const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
- const contentText = contentTd.querySelector('.contentText');
- if (!contentTd.contains(node)) {
- node = contentText;
- column = 0;
- } else {
- const thread = contentTd.querySelector('.comment-thread');
- if (thread && thread.contains(node)) {
- column = this._getLength(contentText);
- node = contentText;
- } else {
- column = this._convertOffsetToColumn(node, offset);
- }
- }
-
- return {
- node,
- side,
- line,
- column,
- };
- }
-
- /**
- * The only line in which add a comment tooltip is cut off is the first
- * line. Even if there is a collapsed section, The first visible line is
- * in the position where the second line would have been, if not for the
- * collapsed section, so don't need to worry about this case for
- * positioning the tooltip.
- */
- _positionActionBox(actionBox, startLine, range) {
- if (startLine > 1) {
- actionBox.placeAbove(range);
- return;
- }
- actionBox.positionBelow = true;
- actionBox.placeBelow(range);
- }
-
- _isRangeValid(range) {
- if (!range || !range.start || !range.end) {
- return false;
- }
- const start = range.start;
- const end = range.end;
- return !(start.side !== end.side ||
- end.line < start.line ||
- (start.line === end.line && start.column === end.column));
- }
-
- _handleSelection(selection, isMouseUp) {
- const normalizedRange = this._getNormalizedRange(selection);
- if (!this._isRangeValid(normalizedRange)) {
- this._removeActionBox();
- return;
- }
- const domRange = selection.getRangeAt(0);
- const start = normalizedRange.start;
- const end = normalizedRange.end;
-
- // TODO (viktard): Drop empty first and last lines from selection.
-
- // If the selection is from the end of one line to the start of the next
- // line, then this must have been a double-click, or you have started
- // dragging. Showing the action box is bad in the former case and not very
- // useful in the latter, so never do that.
- // If this was a mouse-up event, we create a comment immediately if
- // the selection is from the end of a line to the start of the next line.
- // In a perfect world we would only do this for double-click, but it is
- // extremely rare that a user would drag from the end of one line to the
- // start of the next and release the mouse, so we don't bother.
- // TODO(brohlfs): This does not work, if the double-click is before a new
- // diff chunk (start will be equal to end), and neither before an "expand
- // the diff context" block (end line will match the first line of the new
- // section and thus be greater than start line + 1).
- if (start.line === end.line - 1 && end.column === 0) {
- // Rather than trying to find the line contents (for comparing
- // start.column with the content length), we just check if the selection
- // is empty to see that it's at the end of a line.
- const content = domRange.cloneContents().querySelector('.contentText');
- if (isMouseUp && this._getLength(content) === 0) {
- this._fireCreateRangeComment(start.side, {
- start_line: start.line,
- start_character: 0,
- end_line: start.line,
- end_character: start.column,
- });
- }
- return;
- }
-
- let actionBox = this.shadowRoot.querySelector('gr-selection-action-box');
- if (!actionBox) {
- actionBox = document.createElement('gr-selection-action-box');
- const root = dom(this.root);
- root.insertBefore(actionBox, root.firstElementChild);
- }
- this.selectedRange = {
- range: {
- start_line: start.line,
- start_character: start.column,
- end_line: end.line,
- end_character: end.column,
- },
- side: start.side,
- };
- if (start.line === end.line) {
- this._positionActionBox(actionBox, start.line, domRange);
- } else if (start.node instanceof Text) {
- if (start.column) {
- this._positionActionBox(actionBox, start.line,
- start.node.splitText(start.column));
- }
- start.node.parentElement.normalize(); // Undo splitText from above.
- } else if (start.node.classList.contains('content') &&
- start.node.firstChild) {
- this._positionActionBox(actionBox, start.line, start.node.firstChild);
- } else {
- this._positionActionBox(actionBox, start.line, start.node);
- }
- }
-
- _fireCreateRangeComment(side, range) {
- this.dispatchEvent(new CustomEvent('create-range-comment', {
- detail: {side, range},
- composed: true, bubbles: true,
- }));
- this._removeActionBox();
- }
-
- _handleRangeCommentRequest(e) {
- e.stopPropagation();
- if (!this.selectedRange) {
- throw Error('Selected Range is needed for new range comment!');
- }
- const {side, range} = this.selectedRange;
- this._fireCreateRangeComment(side, range);
- }
-
- _removeActionBox() {
- this.selectedRange = undefined;
- const actionBox = this.shadowRoot
- .querySelector('gr-selection-action-box');
- if (actionBox) {
- dom(this.root).removeChild(actionBox);
- }
- }
-
- _convertOffsetToColumn(el, offset) {
- if (el instanceof Element && el.classList.contains('content')) {
- return offset;
- }
- while (el.previousSibling ||
- !el.parentElement.classList.contains('content')) {
- if (el.previousSibling) {
- el = el.previousSibling;
- offset += this._getLength(el);
- } else {
- el = el.parentElement;
- }
- }
- return offset;
- }
-
- /**
- * Traverse Element from right to left, call callback for each node.
- * Stops if callback returns true.
- *
- * @param {!Element} startNode
- * @param {function(Node):boolean} callback
- * @param {Object=} opt_flags If flags.left is true, traverse left.
- */
- _traverseContentSiblings(startNode, callback, opt_flags) {
- const travelLeft = opt_flags && opt_flags.left;
- let node = startNode;
- while (node) {
- if (node instanceof Element &&
- node.tagName !== 'HL' &&
- node.tagName !== 'SPAN') {
- break;
- }
- const nextNode = travelLeft ? node.previousSibling : node.nextSibling;
- if (callback(node)) {
- break;
- }
- node = nextNode;
- }
- }
-
- /**
- * Get length of a node. If the node is a content node, then only give the
- * length of its .contentText child.
- *
- * @param {?Element} node this is sometimes passed as null.
- * @return {number}
- */
- _getLength(node) {
- if (node instanceof Element && node.classList.contains('content')) {
- return this._getLength(node.querySelector('.contentText'));
- } else {
- return GrAnnotation.getLength(node);
- }
- }
-}
-
-customElements.define(GrDiffHighlight.is, GrDiffHighlight);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
new file mode 100644
index 0000000..08a567b
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.ts
@@ -0,0 +1,547 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../gr-selection-action-box/gr-selection-action-box';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-diff-highlight_html';
+import {GrAnnotation} from './gr-annotation';
+import {normalize} from './gr-range-normalizer';
+import {strToClassName} from '../../../utils/dom-util';
+import {customElement, property} from '@polymer/decorators';
+import {Side} from '../../../constants/constants';
+import {CommentRange} from '../../../types/common';
+import {GrSelectionActionBox} from '../gr-selection-action-box/gr-selection-action-box';
+import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+import {FILE} from '../gr-diff/gr-diff-line';
+
+interface SidedRange {
+ side: Side;
+ range: CommentRange;
+}
+
+interface NormalizedPosition {
+ node: Node | null;
+ side: Side;
+ line: number;
+ column: number;
+}
+
+interface NormalizedRange {
+ start: NormalizedPosition | null;
+ end: NormalizedPosition | null;
+}
+
+// TODO(TS): Replace by GrCommentThread once that is converted.
+interface CommentThreadElement extends HTMLElement {
+ rootId: string;
+}
+
+@customElement('gr-diff-highlight')
+export class GrDiffHighlight extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Array, notify: true})
+ commentRanges: SidedRange[] = [];
+
+ @property({type: Boolean})
+ loggedIn?: boolean;
+
+ @property({type: Object})
+ _cachedDiffBuilder?: GrDiffBuilderElement;
+
+ @property({type: Object, notify: true})
+ selectedRange?: SidedRange;
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('comment-thread-mouseleave', e =>
+ this._handleCommentThreadMouseleave(e)
+ );
+ this.addEventListener('comment-thread-mouseenter', e =>
+ this._handleCommentThreadMouseenter(e)
+ );
+ this.addEventListener('create-comment-requested', e =>
+ this._handleRangeCommentRequest(e)
+ );
+ }
+
+ get diffBuilder() {
+ if (!this._cachedDiffBuilder) {
+ this._cachedDiffBuilder = this.querySelector(
+ 'gr-diff-builder'
+ ) as GrDiffBuilderElement;
+ }
+ return this._cachedDiffBuilder;
+ }
+
+ /**
+ * Determines side/line/range for a DOM selection and shows a tooltip.
+ *
+ * With native shadow DOM, gr-diff-highlight cannot access a selection that
+ * references the DOM elements making up the diff because they are in the
+ * shadow DOM the gr-diff element. For this reason, we listen to the
+ * selectionchange event and retrieve the selection in gr-diff, and then
+ * call this method to process the Selection.
+ *
+ * @param selection A DOM Selection living in the shadow DOM of
+ * the diff element.
+ * @param isMouseUp If true, this is called due to a mouseup
+ * event, in which case we might want to immediately create a comment,
+ * because isMouseUp === true combined with an existing selection must
+ * mean that this is the end of a double-click.
+ */
+ handleSelectionChange(selection: Selection | null, isMouseUp: boolean) {
+ if (selection === null) return;
+ // Debounce is not just nice for waiting until the selection has settled,
+ // it is also vital for being able to click on the action box before it is
+ // removed.
+ // If you wait longer than 50 ms, then you don't properly catch a very
+ // quick 'c' press after the selection change. If you wait less than 10
+ // ms, then you will have about 50 _handleSelection calls when doing a
+ // simple drag for select.
+ this.debounce(
+ 'selectionChange',
+ () => this._handleSelection(selection, isMouseUp),
+ 10
+ );
+ }
+
+ _getThreadEl(e: Event): CommentThreadElement | null {
+ const path = (dom(e) as EventApi).path || [];
+ for (const pathEl of path) {
+ if (
+ pathEl instanceof HTMLElement &&
+ pathEl.classList.contains('comment-thread')
+ ) {
+ return pathEl as CommentThreadElement;
+ }
+ }
+ return null;
+ }
+
+ _toggleRangeElHighlight(
+ threadEl: CommentThreadElement,
+ highlightRange = false
+ ) {
+ // We don't want to re-create the line just for highlighting the range which
+ // is creating annoying bugs: @see Issue 12934
+ // As gr-ranged-comment-layer now does not notify the layer re-render and
+ // lack of access to the thread or the lineEl from the ranged-comment-layer,
+ // need to update range class for styles here.
+ let curNode: HTMLElement | null = threadEl.assignedSlot;
+ while (curNode) {
+ if (curNode.nodeName === 'TABLE') break;
+ curNode = curNode.parentElement;
+ }
+ if (curNode?.querySelectorAll) {
+ if (highlightRange) {
+ const rangeNodes = curNode.querySelectorAll(
+ `.range.${strToClassName(threadEl.rootId)}`
+ );
+ rangeNodes.forEach(rangeNode => {
+ rangeNode.classList.add('rangeHighlight');
+ rangeNode.classList.remove('range');
+ });
+ } else {
+ const rangeNodes = curNode.querySelectorAll(
+ `.rangeHighlight.${strToClassName(threadEl.rootId)}`
+ );
+ rangeNodes.forEach(rangeNode => {
+ rangeNode.classList.remove('rangeHighlight');
+ rangeNode.classList.add('range');
+ });
+ }
+ }
+ }
+
+ _handleCommentThreadMouseenter(e: Event) {
+ const threadEl = this._getThreadEl(e)!;
+ const index = this._indexForThreadEl(threadEl);
+
+ if (index !== undefined) {
+ this.set(['commentRanges', index, 'hovering'], true);
+ }
+
+ this._toggleRangeElHighlight(threadEl, /* highlightRange= */ true);
+ }
+
+ _handleCommentThreadMouseleave(e: Event) {
+ const threadEl = this._getThreadEl(e)!;
+ const index = this._indexForThreadEl(threadEl);
+
+ if (index !== undefined) {
+ this.set(['commentRanges', index, 'hovering'], false);
+ }
+
+ this._toggleRangeElHighlight(threadEl, /* highlightRange= */ false);
+ }
+
+ _indexForThreadEl(threadEl: HTMLElement) {
+ const side = threadEl.getAttribute('comment-side') as Side;
+ const rangeString = threadEl.getAttribute('range');
+ if (!rangeString) return undefined;
+ const range = JSON.parse(rangeString) as CommentRange;
+
+ if (!range) return undefined;
+
+ return this._indexOfCommentRange(side, range);
+ }
+
+ _indexOfCommentRange(side: Side, range: CommentRange) {
+ function rangesEqual(a: CommentRange, b: CommentRange) {
+ if (!a && !b) {
+ return true;
+ }
+ if (!a || !b) {
+ return false;
+ }
+ return (
+ a.start_line === b.start_line &&
+ a.start_character === b.start_character &&
+ a.end_line === b.end_line &&
+ a.end_character === b.end_character
+ );
+ }
+
+ return this.commentRanges.findIndex(
+ commentRange =>
+ commentRange.side === side && rangesEqual(commentRange.range, range)
+ );
+ }
+
+ /**
+ * Get current normalized selection.
+ * Merges multiple ranges, accounts for triple click, accounts for
+ * syntax highligh, convert native DOM Range objects to Gerrit concepts
+ * (line, side, etc).
+ */
+ _getNormalizedRange(selection: Selection) {
+ const rangeCount = selection.rangeCount;
+ if (rangeCount === 0) {
+ return null;
+ } else if (rangeCount === 1) {
+ return this._normalizeRange(selection.getRangeAt(0));
+ } else {
+ const startRange = this._normalizeRange(selection.getRangeAt(0));
+ const endRange = this._normalizeRange(
+ selection.getRangeAt(rangeCount - 1)
+ );
+ return {
+ start: startRange.start,
+ end: endRange.end,
+ };
+ }
+ }
+
+ /**
+ * Normalize a specific DOM Range.
+ *
+ * @return fixed normalized range
+ */
+ _normalizeRange(domRange: Range): NormalizedRange {
+ const range = normalize(domRange);
+ return this._fixTripleClickSelection(
+ {
+ start: this._normalizeSelectionSide(
+ range.startContainer,
+ range.startOffset
+ ),
+ end: this._normalizeSelectionSide(range.endContainer, range.endOffset),
+ },
+ domRange
+ );
+ }
+
+ /**
+ * Adjust triple click selection for the whole line.
+ * A triple click always results in:
+ * - start.column == end.column == 0
+ * - end.line == start.line + 1
+ *
+ * @param range Normalized range, ie column/line numbers
+ * @param domRange DOM Range object
+ * @return fixed normalized range
+ */
+ _fixTripleClickSelection(range: NormalizedRange, domRange: Range) {
+ if (!range.start) {
+ // Selection outside of current diff.
+ return range;
+ }
+ const start = range.start;
+ const end = range.end;
+ // Happens when triple click in side-by-side mode with other side empty.
+ const endsAtOtherEmptySide =
+ !end &&
+ domRange.endOffset === 0 &&
+ domRange.endContainer instanceof HTMLElement &&
+ domRange.endContainer.nodeName === 'TD' &&
+ (domRange.endContainer.classList.contains('left') ||
+ domRange.endContainer.classList.contains('right'));
+ const endsAtBeginningOfNextLine =
+ end &&
+ start.column === 0 &&
+ end.column === 0 &&
+ end.line === start.line + 1;
+ const content = domRange.cloneContents().querySelector('.contentText');
+ const lineLength = (content && this._getLength(content)) || 0;
+ if (lineLength && (endsAtBeginningOfNextLine || endsAtOtherEmptySide)) {
+ // Move the selection to the end of the previous line.
+ range.end = {
+ node: start.node,
+ column: lineLength,
+ side: start.side,
+ line: start.line,
+ };
+ }
+ return range;
+ }
+
+ /**
+ * Convert DOM Range selection to concrete numbers (line, column, side).
+ * Moves range end if it's not inside td.content.
+ * Returns null if selection end is not valid (outside of diff).
+ *
+ * @param node td.content child
+ * @param offset offset within node
+ */
+ _normalizeSelectionSide(
+ node: Node | null,
+ offset: number
+ ): NormalizedPosition | null {
+ let column;
+ if (!node || !this.contains(node)) return null;
+ const lineEl = this.diffBuilder.getLineElByChild(node);
+ if (!lineEl) return null;
+ const side = this.diffBuilder.getSideByLineEl(lineEl);
+ if (!side) return null;
+ const line = this.diffBuilder.getLineNumberByChild(lineEl);
+ if (!line || line === FILE) return null;
+ const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
+ if (!contentTd) return null;
+ const contentText = contentTd.querySelector('.contentText');
+ if (!contentTd.contains(node)) {
+ node = contentText;
+ column = 0;
+ } else {
+ const thread = contentTd.querySelector('.comment-thread');
+ if (thread?.contains(node)) {
+ column = this._getLength(contentText);
+ node = contentText;
+ } else {
+ column = this._convertOffsetToColumn(node, offset);
+ }
+ }
+
+ return {
+ node,
+ side,
+ line,
+ column,
+ };
+ }
+
+ /**
+ * The only line in which add a comment tooltip is cut off is the first
+ * line. Even if there is a collapsed section, The first visible line is
+ * in the position where the second line would have been, if not for the
+ * collapsed section, so don't need to worry about this case for
+ * positioning the tooltip.
+ */
+ _positionActionBox(
+ actionBox: GrSelectionActionBox,
+ startLine: number,
+ range: Text | Element | Range
+ ) {
+ if (startLine > 1) {
+ actionBox.placeAbove(range);
+ return;
+ }
+ actionBox.positionBelow = true;
+ actionBox.placeBelow(range);
+ }
+
+ _isRangeValid(range: NormalizedRange | null) {
+ if (!range || !range.start || !range.start.node || !range.end) {
+ return false;
+ }
+ const start = range.start;
+ const end = range.end;
+ return !(
+ start.side !== end.side ||
+ end.line < start.line ||
+ (start.line === end.line && start.column === end.column)
+ );
+ }
+
+ _handleSelection(selection: Selection, isMouseUp: boolean) {
+ const normalizedRange = this._getNormalizedRange(selection);
+ if (!this._isRangeValid(normalizedRange)) {
+ this._removeActionBox();
+ return;
+ }
+ const domRange = selection.getRangeAt(0);
+ const start = normalizedRange!.start!;
+ const end = normalizedRange!.end!;
+
+ // TODO (viktard): Drop empty first and last lines from selection.
+
+ // If the selection is from the end of one line to the start of the next
+ // line, then this must have been a double-click, or you have started
+ // dragging. Showing the action box is bad in the former case and not very
+ // useful in the latter, so never do that.
+ // If this was a mouse-up event, we create a comment immediately if
+ // the selection is from the end of a line to the start of the next line.
+ // In a perfect world we would only do this for double-click, but it is
+ // extremely rare that a user would drag from the end of one line to the
+ // start of the next and release the mouse, so we don't bother.
+ // TODO(brohlfs): This does not work, if the double-click is before a new
+ // diff chunk (start will be equal to end), and neither before an "expand
+ // the diff context" block (end line will match the first line of the new
+ // section and thus be greater than start line + 1).
+ if (start.line === end.line - 1 && end.column === 0) {
+ // Rather than trying to find the line contents (for comparing
+ // start.column with the content length), we just check if the selection
+ // is empty to see that it's at the end of a line.
+ const content = domRange.cloneContents().querySelector('.contentText');
+ if (isMouseUp && this._getLength(content) === 0) {
+ this._fireCreateRangeComment(start.side, {
+ start_line: start.line,
+ start_character: 0,
+ end_line: start.line,
+ end_character: start.column,
+ });
+ }
+ return;
+ }
+
+ let actionBox = this.shadowRoot!.querySelector(
+ 'gr-selection-action-box'
+ ) as GrSelectionActionBox | null;
+ if (!actionBox) {
+ actionBox = document.createElement('gr-selection-action-box');
+ this.root!.insertBefore(actionBox, this.root!.firstElementChild);
+ }
+ this.selectedRange = {
+ range: {
+ start_line: start.line,
+ start_character: start.column,
+ end_line: end.line,
+ end_character: end.column,
+ },
+ side: start.side,
+ };
+ if (start.line === end.line) {
+ this._positionActionBox(actionBox, start.line, domRange);
+ } else if (start.node instanceof Text) {
+ if (start.column) {
+ this._positionActionBox(
+ actionBox,
+ start.line,
+ start.node.splitText(start.column)
+ );
+ }
+ start.node.parentElement!.normalize(); // Undo splitText from above.
+ } else if (
+ start.node instanceof HTMLElement &&
+ start.node.classList.contains('content') &&
+ (start.node.firstChild instanceof Element ||
+ start.node.firstChild instanceof Text)
+ ) {
+ this._positionActionBox(actionBox, start.line, start.node.firstChild);
+ } else if (start.node instanceof Element || start.node instanceof Text) {
+ this._positionActionBox(actionBox, start.line, start.node);
+ } else {
+ console.warn('Failed to position comment action box.');
+ this._removeActionBox();
+ }
+ }
+
+ _fireCreateRangeComment(side: Side, range: CommentRange) {
+ this.dispatchEvent(
+ new CustomEvent('create-range-comment', {
+ detail: {side, range},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._removeActionBox();
+ }
+
+ _handleRangeCommentRequest(e: Event) {
+ e.stopPropagation();
+ if (!this.selectedRange) {
+ throw Error('Selected Range is needed for new range comment!');
+ }
+ const {side, range} = this.selectedRange;
+ this._fireCreateRangeComment(side, range);
+ }
+
+ _removeActionBox() {
+ this.selectedRange = undefined;
+ const actionBox = this.shadowRoot!.querySelector('gr-selection-action-box');
+ if (actionBox) {
+ this.root!.removeChild(actionBox);
+ }
+ }
+
+ _convertOffsetToColumn(el: Node, offset: number) {
+ if (el instanceof Element && el.classList.contains('content')) {
+ return offset;
+ }
+ while (
+ el.previousSibling ||
+ !el.parentElement?.classList.contains('content')
+ ) {
+ if (el.previousSibling) {
+ el = el.previousSibling;
+ offset += this._getLength(el);
+ } else {
+ el = el.parentElement!;
+ }
+ }
+ return offset;
+ }
+
+ /**
+ * Get length of a node. If the node is a content node, then only give the
+ * length of its .contentText child.
+ *
+ * @param node this is sometimes passed as null.
+ */
+ _getLength(node: Node | null): number {
+ if (node === null) return 0;
+ if (node instanceof Element && node.classList.contains('content')) {
+ return this._getLength(node.querySelector('.contentText')!);
+ } else {
+ return GrAnnotation.getLength(node);
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-highlight': GrDiffHighlight;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
index 957d039..39d5c2a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-diff-highlight.js';
-import {GrRangeNormalizer} from './gr-range-normalizer.js';
+import {_getTextOffset} from './gr-range-normalizer.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
// Splitting long lines in html into shorter rows breaks tests:
@@ -565,11 +565,11 @@
test('GrRangeNormalizer._getTextOffset computes text offset', () => {
let content = stubContent(140, 'left');
let child = content.lastChild.lastChild;
- let result = GrRangeNormalizer._getTextOffset(content, child);
+ let result = _getTextOffset(content, child);
assert.equal(result, 75);
content = stubContent(146, 'right');
child = content.lastChild;
- result = GrRangeNormalizer._getTextOffset(content, child);
+ result = _getTextOffset(content, child);
assert.equal(result, 0);
});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
deleted file mode 100644
index 5d04bd7..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
+++ /dev/null
@@ -1,104 +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.
- */
-
-// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
-const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
-
-export const GrRangeNormalizer = {
- /**
- * Remap DOM range to whole lines of a diff if necessary. If the start or
- * end containers are DOM elements that are singular pieces of syntax
- * highlighting, the containers are remapped to the .contentText divs that
- * contain the entire line of code.
- *
- * @param {!Object} range - the standard DOM selector range.
- * @return {!Object} A modified version of the range that correctly accounts
- * for syntax highlighting.
- */
- normalize(range) {
- const startContainer = this._getContentTextParent(range.startContainer);
- const startOffset = range.startOffset +
- this._getTextOffset(startContainer, range.startContainer);
- const endContainer = this._getContentTextParent(range.endContainer);
- const endOffset = range.endOffset + this._getTextOffset(endContainer,
- range.endContainer);
- return {
- startContainer,
- startOffset,
- endContainer,
- endOffset,
- };
- },
-
- _getContentTextParent(target) {
- let element = target;
- if (element.nodeName === '#text') {
- element = element.parentElement;
- }
- while (element && !element.classList.contains('contentText')) {
- if (element.parentElement === null) {
- return target;
- }
- element = element.parentElement;
- }
- return element;
- },
-
- /**
- * Gets the character offset of the child within the parent.
- * Performs a synchronous in-order traversal from top to bottom of the node
- * element, counting the length of the syntax until child is found.
- *
- * @param {!Element} node The root DOM element to be searched through.
- * @param {!Element} child The child element being searched for.
- * @return {number}
- */
- _getTextOffset(node, child) {
- let count = 0;
- let stack = [node];
- while (stack.length) {
- const n = stack.pop();
- if (n === child) {
- break;
- }
- if (n && n.childNodes && n.childNodes.length !== 0) {
- const arr = [];
- for (const childNode of n.childNodes) {
- arr.push(childNode);
- }
- arr.reverse();
- stack = stack.concat(arr);
- } else {
- count += this._getLength(n);
- }
- }
- return count;
- },
-
- /**
- * The DOM API textContent.length calculation is broken when the text
- * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
- *
- * @param {text} node A text node.
- * @return {number} The length of the text.
- */
- _getLength(node) {
- return node ?
- node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length :
- 0;
- },
-};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.ts
new file mode 100644
index 0000000..469c24a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.ts
@@ -0,0 +1,114 @@
+/**
+ * @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.
+ */
+
+// Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
+const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+
+export interface NormalizedRange {
+ endContainer: Node;
+ endOffset: number;
+ startContainer: Node;
+ startOffset: number;
+}
+
+/**
+ * Remap DOM range to whole lines of a diff if necessary. If the start or
+ * end containers are DOM elements that are singular pieces of syntax
+ * highlighting, the containers are remapped to the .contentText divs that
+ * contain the entire line of code.
+ *
+ * @param range - the standard DOM selector range.
+ * @return A modified version of the range that correctly accounts
+ * for syntax highlighting.
+ */
+export function normalize(range: Range): NormalizedRange {
+ const startContainer = _getContentTextParent(range.startContainer);
+ const startOffset =
+ range.startOffset + _getTextOffset(startContainer, range.startContainer);
+ const endContainer = _getContentTextParent(range.endContainer);
+ const endOffset =
+ range.endOffset + _getTextOffset(endContainer, range.endContainer);
+ return {
+ startContainer,
+ startOffset,
+ endContainer,
+ endOffset,
+ };
+}
+
+function _getContentTextParent(target: Node): Node {
+ if (!target.parentElement) return target;
+
+ let element: Element | null;
+ if (target instanceof Element) {
+ element = target;
+ } else {
+ element = target.parentElement;
+ }
+
+ while (element && !element.classList.contains('contentText')) {
+ if (element.parentElement === null) {
+ return target;
+ }
+ element = element.parentElement;
+ }
+ return element ? element : target;
+}
+
+/**
+ * Gets the character offset of the child within the parent.
+ * Performs a synchronous in-order traversal from top to bottom of the node
+ * element, counting the length of the syntax until child is found.
+ *
+ * @param node The root DOM element to be searched through.
+ * @param child The child element being searched for.
+ */
+// TODO(TS): Only export for test.
+export function _getTextOffset(node: Node | null, child: Node): number {
+ let count = 0;
+ let stack = [node];
+ while (stack.length) {
+ const n = stack.pop();
+ if (n === child) {
+ break;
+ }
+ if (n?.childNodes && n.childNodes.length !== 0) {
+ const arr = [];
+ for (const childNode of n.childNodes) {
+ arr.push(childNode);
+ }
+ arr.reverse();
+ stack = stack.concat(arr);
+ } else {
+ count += _getLength(n);
+ }
+ }
+ return count;
+}
+
+/**
+ * The DOM API textContent.length calculation is broken when the text
+ * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
+ *
+ * @param node A text node.
+ * @return The length of the text.
+ */
+function _getLength(node?: Node | null) {
+ return node && node.textContent
+ ? node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length
+ : 0;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
deleted file mode 100644
index 6afa9df..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.js
+++ /dev/null
@@ -1,1170 +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 '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-comment-thread/gr-comment-thread.js';
-import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import '../gr-diff/gr-diff.js';
-import '../gr-syntax-layer/gr-syntax-layer.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-diff-host_html.js';
-import {GrDiffBuilder} from '../gr-diff-builder/gr-diff-builder.js';
-import {parseDate} from '../../../utils/date-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {DiffSide, rangesEqual} from '../gr-diff/gr-diff-utils.js';
-import {appContext} from '../../../services/app-context.js';
-import {getParentIndex, isMergeParent} from '../../../utils/patch-set-util.js';
-
-const MSG_EMPTY_BLAME = 'No blame information for this diff.';
-
-const EVENT_AGAINST_PARENT = 'diff-against-parent';
-const EVENT_ZERO_REBASE = 'rebase-percent-zero';
-const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
-
-const DiffViewMode = {
- SIDE_BY_SIDE: 'SIDE_BY_SIDE',
- UNIFIED: 'UNIFIED_DIFF',
-};
-
-/** @enum {string} */
-const TimingLabel = {
- TOTAL: 'Diff Total Render',
- CONTENT: 'Diff Content Render',
- SYNTAX: 'Diff Syntax Render',
-};
-
-// Disable syntax highlighting if the overall diff is too large.
-const SYNTAX_MAX_DIFF_LENGTH = 20000;
-
-// If any line of the diff is more than the character limit, then disable
-// syntax highlighting for the entire file.
-const SYNTAX_MAX_LINE_LENGTH = 500;
-
-// 120 lines is good enough threshold for full-sized window viewport
-const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
-
-const WHITESPACE_IGNORE_NONE = 'IGNORE_NONE';
-
-/**
- * @param {Object} diff
- * @return {boolean}
- */
-function isImageDiff(diff) {
- if (!diff) { return false; }
-
- const isA = diff.meta_a &&
- diff.meta_a.content_type.startsWith('image/');
- const isB = diff.meta_b &&
- diff.meta_b.content_type.startsWith('image/');
-
- return !!(diff.binary && (isA || isB));
-}
-
-/**
- * Wrapper around gr-diff.
- *
- * Webcomponent fetching diffs and related data from restAPI and passing them
- * to the presentational gr-diff for rendering.
- *
- * @extends PolymerElement
- */
-class GrDiffHost extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff-host'; }
- /**
- * Fired when the user selects a line.
- *
- * @event line-selected
- */
-
- /**
- * Fired if being logged in is required.
- *
- * @event show-auth-required
- */
-
- /**
- * Fired when a comment is saved or discarded
- *
- * @event diff-comments-modified
- */
-
- static get properties() {
- return {
- changeNum: String,
- noAutoRender: {
- type: Boolean,
- value: false,
- },
- /** @type {?} */
- patchRange: Object,
- /** @type {!Gerrit.FileRange} */
- file: Object,
- // TODO: deprecate path since that info is included in file
- path: String,
- prefs: {
- type: Object,
- },
- projectName: String,
- displayLine: {
- type: Boolean,
- value: false,
- },
- isImageDiff: {
- type: Boolean,
- computed: '_computeIsImageDiff(diff)',
- notify: true,
- },
- commitRange: Object,
- filesWeblinks: {
- type: Object,
- value() {
- return {};
- },
- notify: true,
- },
- hidden: {
- type: Boolean,
- reflectToAttribute: true,
- },
- noRenderOnPrefsChange: {
- type: Boolean,
- value: false,
- },
- comments: {
- type: Object,
- observer: '_commentsChanged',
- },
- lineWrapping: {
- type: Boolean,
- value: false,
- },
- viewMode: {
- type: String,
- value: DiffViewMode.SIDE_BY_SIDE,
- },
-
- /**
- * Special line number which should not be collapsed into a shared region.
- *
- * @type {{
- * number: number,
- * leftSide: {boolean}
- * }|null}
- */
- lineOfInterest: Object,
-
- /**
- * If the diff fails to load, show the failure message in the diff rather
- * than bubbling the error up to the whole page. This is useful for when
- * loading inline diffs because one diff failing need not mark the whole
- * page with a failure.
- */
- showLoadFailure: Boolean,
-
- isBlameLoaded: {
- type: Boolean,
- notify: true,
- computed: '_computeIsBlameLoaded(_blame)',
- },
-
- _loggedIn: {
- type: Boolean,
- value: false,
- },
-
- _loading: {
- type: Boolean,
- value: false,
- },
-
- /** @type {?string} */
- _errorMessage: {
- type: String,
- value: null,
- },
-
- /** @type {?Object} */
- _baseImage: Object,
- /** @type {?Object} */
- _revisionImage: Object,
- /**
- * This is a DiffInfo object.
- */
- diff: {
- type: Object,
- notify: true,
- },
-
- _fetchDiffPromise: {
- type: Object,
- value: null,
- },
-
- /** @type {?Object} */
- _blame: {
- type: Object,
- value: null,
- },
-
- /**
- * @type {!Array<!Gerrit.CoverageRange>}
- */
- _coverageRanges: {
- type: Array,
- value: () => [],
- },
-
- _loadedWhitespaceLevel: String,
-
- _parentIndex: {
- type: Number,
- computed: '_computeParentIndex(patchRange.*)',
- },
-
- _syntaxHighlightingEnabled: {
- type: Boolean,
- computed:
- '_isSyntaxHighlightingEnabled(prefs.*, diff)',
- },
-
- _layers: {
- type: Array,
- value: [],
- },
- };
- }
-
- static get observers() {
- return [
- '_whitespaceChanged(prefs.ignore_whitespace, _loadedWhitespaceLevel,' +
- ' noRenderOnPrefsChange)',
- '_syntaxHighlightingChanged(noRenderOnPrefsChange, prefs.*)',
- ];
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener(
- // These are named inconsistently for a reason:
- // The create-comment event is fired to indicate that we should
- // create a comment.
- // The comment-* events are just notifying that the comments did already
- // change in some way, and that we should update any models we may want
- // to keep in sync.
- 'create-comment',
- e => this._handleCreateComment(e));
- this.addEventListener('comment-discard',
- e => this._handleCommentDiscard(e));
- this.addEventListener('comment-update',
- e => this._handleCommentUpdate(e));
- this.addEventListener('comment-save',
- e => this._handleCommentSave(e));
- this.addEventListener('render-start',
- () => this._handleRenderStart());
- this.addEventListener('render-content',
- () => this._handleRenderContent());
- this.addEventListener('normalize-range',
- event => this._handleNormalizeRange(event));
- this.addEventListener('diff-context-expanded',
- event => this._handleDiffContextExpanded(event));
- }
-
- /** @override */
- ready() {
- super.ready();
- if (this._canReload()) {
- this.reload();
- }
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- });
- }
-
- /** @override */
- detached() {
- super.detached();
- this.clear();
- }
-
- /**
- * @param {boolean=} shouldReportMetric indicate a new Diff Page. This is a
- * signal to report metrics event that started on location change.
- * @return {!Promise}
- **/
- reload(shouldReportMetric) {
- this.clear();
- this._loading = true;
- this._errorMessage = null;
- const whitespaceLevel = this._getIgnoreWhitespace();
-
- const layers = [this.$.syntaxLayer];
- // Get layers from plugins (if any).
- for (const pluginLayer of this.$.jsAPI.getDiffLayers(
- this.path, this.changeNum, this.patchNum)) {
- layers.push(pluginLayer);
- }
- this._layers = layers;
-
- if (shouldReportMetric) {
- // We listen on render viewport only on DiffPage (on paramsChanged)
- this._listenToViewportRender();
- }
-
- this._coverageRanges = [];
- this._getCoverageData();
- const diffRequest = this._getDiff()
- .then(diff => {
- this._loadedWhitespaceLevel = whitespaceLevel;
- this._reportDiff(diff);
- return diff;
- })
- .catch(e => {
- this._handleGetDiffError(e);
- return null;
- });
-
- const assetRequest = diffRequest.then(diff => {
- // If the diff is null, then it's failed to load.
- if (!diff) { return null; }
-
- return this._loadDiffAssets(diff);
- });
-
- // Not waiting for coverage ranges intentionally as
- // plugin loading should not block the content rendering
- return Promise.all([diffRequest, assetRequest])
- .then(results => {
- const diff = results[0];
- if (!diff) {
- return Promise.resolve();
- }
- this.filesWeblinks = this._getFilesWeblinks(diff);
- return new Promise(resolve => {
- const callback = event => {
- const needsSyntaxHighlighting = event.detail &&
- event.detail.contentRendered;
- if (needsSyntaxHighlighting) {
- this.reporting.time(TimingLabel.SYNTAX);
- this.$.syntaxLayer.process().finally(() => {
- this.reporting.timeEnd(TimingLabel.SYNTAX);
- this.reporting.timeEnd(TimingLabel.TOTAL);
- resolve();
- });
- } else {
- this.reporting.timeEnd(TimingLabel.TOTAL);
- resolve();
- }
- this.removeEventListener('render', callback);
- if (shouldReportMetric) {
- // We report diffViewContentDisplayed only on reload caused
- // by params changed - expected only on Diff Page.
- this.reporting.diffViewContentDisplayed();
- }
- };
- this.addEventListener('render', callback);
- this.diff = diff;
- });
- })
- .catch(err => {
- console.warn('Error encountered loading diff:', err);
- })
- .then(() => { this._loading = false; });
- }
-
- clear() {
- this.$.jsAPI.disposeDiffLayers(this.path);
- this._layers = [];
- }
-
- _getCoverageData() {
- const {changeNum, path, patchRange: {basePatchNum, patchNum}} = this;
- this.$.jsAPI.getCoverageAnnotationApi().
- then(coverageAnnotationApi => {
- if (!coverageAnnotationApi) return;
- const provider = coverageAnnotationApi.getCoverageProvider();
- return provider(changeNum, path, basePatchNum, patchNum)
- .then(coverageRanges => {
- if (!coverageRanges ||
- changeNum !== this.changeNum ||
- path !== this.path ||
- basePatchNum !== this.patchRange.basePatchNum ||
- patchNum !== this.patchRange.patchNum) {
- return;
- }
-
- const existingCoverageRanges = this._coverageRanges;
- this._coverageRanges = coverageRanges;
-
- // Notify with existing coverage ranges
- // in case there is some existing coverage data that needs to be removed
- existingCoverageRanges.forEach(range => {
- coverageAnnotationApi.notify(
- path,
- range.code_range.start_line,
- range.code_range.end_line,
- range.side);
- });
-
- // Notify with new coverage data
- coverageRanges.forEach(range => {
- coverageAnnotationApi.notify(
- path,
- range.code_range.start_line,
- range.code_range.end_line,
- range.side);
- });
- });
- })
- .catch(err => {
- console.warn('Loading coverage ranges failed: ', err);
- });
- }
-
- _getFilesWeblinks(diff) {
- if (!this.commitRange) {
- return {};
- }
- return {
- meta_a: GerritNav.getFileWebLinks(
- this.projectName, this.commitRange.baseCommit, this.path,
- {weblinks: diff && diff.meta_a && diff.meta_a.web_links}),
- meta_b: GerritNav.getFileWebLinks(
- this.projectName, this.commitRange.commit, this.path,
- {weblinks: diff && diff.meta_b && diff.meta_b.web_links}),
- };
- }
-
- /** Cancel any remaining diff builder rendering work. */
- cancel() {
- this.$.diff.cancel();
- this.$.syntaxLayer.cancel();
- }
-
- /** @return {!Array<!HTMLElement>} */
- getCursorStops() {
- return this.$.diff.getCursorStops();
- }
-
- /** @return {boolean} */
- isRangeSelected() {
- return this.$.diff.isRangeSelected();
- }
-
- createRangeComment() {
- return this.$.diff.createRangeComment();
- }
-
- toggleLeftDiff() {
- this.$.diff.toggleLeftDiff();
- }
-
- /**
- * Load and display blame information for the base of the diff.
- *
- * @return {Promise} A promise that resolves when blame finishes rendering.
- */
- loadBlame() {
- return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
- this.path, true)
- .then(blame => {
- if (!blame.length) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: MSG_EMPTY_BLAME},
- composed: true, bubbles: true,
- }));
- return Promise.reject(MSG_EMPTY_BLAME);
- }
-
- this._blame = blame;
- });
- }
-
- /** Unload blame information for the diff. */
- clearBlame() {
- this._blame = null;
- }
-
- /**
- * The thread elements in this diff, in no particular order.
- *
- * @return {!Array<!HTMLElement>}
- */
- getThreadEls() {
- return Array.from(
- dom(this.$.diff).querySelectorAll('.comment-thread'));
- }
-
- /** @param {HTMLElement} el */
- addDraftAtLine(el) {
- this.$.diff.addDraftAtLine(el);
- }
-
- clearDiffContent() {
- this.$.diff.clearDiffContent();
- }
-
- expandAllContext() {
- this.$.diff.expandAllContext();
- }
-
- /** @return {!Promise} */
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- /** @return {boolean}} */
- _canReload() {
- return !!this.changeNum && !!this.patchRange && !!this.path &&
- !this.noAutoRender;
- }
-
- // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
- prefetchDiff() {
- if (!!this.changeNum && !!this.patchRange && !!this.path
- && this._fetchDiffPromise === null) {
- this._fetchDiffPromise = this._getDiff();
- }
- }
-
- /** @return {!Promise<!Object>} */
- _getDiff() {
- if (this._fetchDiffPromise !== null) {
- const fetchDiffPromise = this._fetchDiffPromise;
- this._fetchDiffPromise = null;
- return fetchDiffPromise;
- }
- // Wrap the diff request in a new promise so that the error handler
- // rejects the promise, allowing the error to be handled in the .catch.
- return new Promise((resolve, reject) => {
- this.$.restAPI.getDiff(
- this.changeNum,
- this.patchRange.basePatchNum,
- this.patchRange.patchNum,
- this.path,
- this._getIgnoreWhitespace(),
- reject)
- .then(resolve);
- });
- }
-
- _handleGetDiffError(response) {
- // Loading the diff may respond with 409 if the file is too large. In this
- // case, use a toast error..
- if (response.status === 409) {
- this.dispatchEvent(new CustomEvent('server-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- return;
- }
-
- if (this.showLoadFailure) {
- this._errorMessage = [
- 'Encountered error when loading the diff:',
- response.status,
- response.statusText,
- ].join(' ');
- return;
- }
-
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- }
-
- /**
- * Report info about the diff response.
- */
- _reportDiff(diff) {
- if (!diff || !diff.content) {
- return;
- }
-
- // Count the delta lines stemming from normal deltas, and from
- // due_to_rebase deltas.
- let nonRebaseDelta = 0;
- let rebaseDelta = 0;
- diff.content.forEach(chunk => {
- if (chunk.ab) { return; }
- const deltaSize = Math.max(
- chunk.a ? chunk.a.length : 0, chunk.b ? chunk.b.length : 0);
- if (chunk.due_to_rebase) {
- rebaseDelta += deltaSize;
- } else {
- nonRebaseDelta += deltaSize;
- }
- });
-
- // Find the percent of the delta from due_to_rebase chunks rounded to two
- // digits. Diffs with no delta are considered 0%.
- const totalDelta = rebaseDelta + nonRebaseDelta;
- const percentRebaseDelta = !totalDelta ? 0 :
- Math.round(100 * rebaseDelta / totalDelta);
-
- // Report the due_to_rebase percentage in the "diff" category when
- // applicable.
- if (this.patchRange.basePatchNum === 'PARENT') {
- this.reporting.reportInteraction(EVENT_AGAINST_PARENT);
- } else if (percentRebaseDelta === 0) {
- this.reporting.reportInteraction(EVENT_ZERO_REBASE);
- } else {
- this.reporting.reportInteraction(EVENT_NONZERO_REBASE,
- {percentRebaseDelta});
- }
- }
-
- /**
- * @param {Object} diff
- * @return {!Promise}
- */
- _loadDiffAssets(diff) {
- if (isImageDiff(diff)) {
- return this._getImages(diff).then(images => {
- this._baseImage = images.baseImage;
- this._revisionImage = images.revisionImage;
- });
- } else {
- this._baseImage = null;
- this._revisionImage = null;
- return Promise.resolve();
- }
- }
-
- /**
- * @param {Object} diff
- * @return {boolean}
- */
- _computeIsImageDiff(diff) {
- return isImageDiff(diff);
- }
-
- _commentsChanged(newComments) {
- const allComments = [];
- for (const side of [GrDiffBuilder.Side.LEFT, GrDiffBuilder.Side.RIGHT]) {
- // This is needed by the threading.
- for (const comment of newComments[side]) {
- comment.__commentSide = side;
- }
- allComments.push(...newComments[side]);
- }
- // Currently, the only way this is ever changed here is when the initial
- // comments are loaded, so it's okay performance wise to clear the threads
- // and recreate them. If this changes in future, we might want to reuse
- // some DOM nodes here.
- this._clearThreads();
- const threads = this._createThreads(allComments);
- for (const thread of threads) {
- const threadEl = this._createThreadElement(thread);
- this._attachThreadElement(threadEl);
- }
- }
-
- _sortComments(comments) {
- return comments.slice(0).sort((a, b) => {
- if (b.__draft && !a.__draft ) { return -1; }
- if (a.__draft && !b.__draft ) { return 1; }
- return parseDate(a.updated) - parseDate(b.updated);
- });
- }
-
- /**
- * @param {!Array<!Object>} comments
- * @return {!Array<!Object>} Threads for the given comments.
- */
- _createThreads(comments) {
- const sortedComments = this._sortComments(comments);
- const threads = [];
- for (const comment of sortedComments) {
- // If the comment is in reply to another comment, find that comment's
- // thread and append to it.
- if (comment.in_reply_to) {
- const thread = threads.find(thread =>
- thread.comments.some(c => c.id === comment.in_reply_to));
- if (thread) {
- thread.comments.push(comment);
- continue;
- }
- }
-
- // Otherwise, this comment starts its own thread.
- const newThread = {
- start_datetime: comment.updated,
- comments: [comment],
- commentSide: comment.__commentSide,
- patchNum: comment.patch_set,
- rootId: comment.id || comment.__draftID,
- lineNum: comment.line,
- isOnParent: comment.side === 'PARENT',
- };
- if (comment.range) {
- newThread.range = Object.assign({}, comment.range);
- }
- threads.push(newThread);
- }
- return threads;
- }
-
- /**
- * @param {Object} blame
- * @return {boolean}
- */
- _computeIsBlameLoaded(blame) {
- return !!blame;
- }
-
- /**
- * @param {Object} diff
- * @return {!Promise}
- */
- _getImages(diff) {
- return this.$.restAPI.getImagesForDiff(this.changeNum, diff,
- this.patchRange);
- }
-
- /** @param {CustomEvent} e */
- _handleCreateComment(e) {
- const {lineNum, side, patchNum, isOnParent, range} = e.detail;
- const threadEl = this._getOrCreateThread(patchNum, lineNum, side, range,
- isOnParent);
- threadEl.addOrEditDraft(lineNum, range);
-
- this.reporting.recordDraftInteraction();
- }
-
- /**
- * Gets or creates a comment thread at a given location.
- * May provide a range, to get/create a range comment.
- *
- * @param {string} patchNum
- * @param {?number} lineNum
- * @param {string} commentSide
- * @param {Gerrit.Range|undefined} range
- * @param {boolean} isOnParent
- * @return {!Object}
- */
- _getOrCreateThread(patchNum, lineNum, commentSide, range, isOnParent) {
- let threadEl = this._getThreadEl(lineNum, commentSide, range);
- if (!threadEl) {
- threadEl = this._createThreadElement({
- comments: [],
- commentSide,
- patchNum,
- lineNum,
- range,
- isOnParent,
- });
- this._attachThreadElement(threadEl);
- }
- return threadEl;
- }
-
- _attachThreadElement(threadEl) {
- dom(this.$.diff).appendChild(threadEl);
- }
-
- _clearThreads() {
- for (const threadEl of this.getThreadEls()) {
- const parent = dom(threadEl).parentNode;
- dom(parent).removeChild(threadEl);
- }
- }
-
- _createThreadElement(thread) {
- const threadEl = document.createElement('gr-comment-thread');
- threadEl.className = 'comment-thread';
- threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
- threadEl.comments = thread.comments;
- threadEl.commentSide = thread.commentSide;
- threadEl.isOnParent = !!thread.isOnParent;
- threadEl.parentIndex = this._parentIndex;
- // Use path before renmaing when comment added on the left when comparing
- // two patch sets (not against base)
- if (this.file && this.file.basePath
- && thread.commentSide === GrDiffBuilder.Side.LEFT
- && !thread.isOnParent) {
- threadEl.path = this.file.basePath;
- } else {
- threadEl.path = this.path;
- }
- threadEl.changeNum = this.changeNum;
- threadEl.patchNum = thread.patchNum;
- threadEl.showPatchset = false;
- threadEl.lineNum = thread.lineNum;
- const rootIdChangedListener = changeEvent => {
- thread.rootId = changeEvent.detail.value;
- };
- threadEl.addEventListener('root-id-changed', rootIdChangedListener);
- threadEl.projectName = this.projectName;
- threadEl.range = thread.range;
- const threadDiscardListener = e => {
- const threadEl = /** @type {!Node} */ (e.currentTarget);
-
- const parent = dom(threadEl).parentNode;
- dom(parent).removeChild(threadEl);
-
- threadEl.removeEventListener('root-id-changed', rootIdChangedListener);
- threadEl.removeEventListener('thread-discard', threadDiscardListener);
- };
- threadEl.addEventListener('thread-discard', threadDiscardListener);
- return threadEl;
- }
-
- /**
- * Gets a comment thread element at a given location.
- * May provide a range, to get a range comment.
- *
- * @param {?number} lineNum
- * @param {string} commentSide
- * @param {!Gerrit.Range=} range
- * @return {?Node}
- */
- _getThreadEl(lineNum, commentSide, range = undefined) {
- let line;
- if (commentSide === GrDiffBuilder.Side.LEFT) {
- line = {beforeNumber: lineNum};
- } else if (commentSide === GrDiffBuilder.Side.RIGHT) {
- line = {afterNumber: lineNum};
- } else {
- throw new Error(`Unknown side: ${commentSide}`);
- }
- function matchesRange(threadEl) {
- const threadRange = /** @type {!Gerrit.Range} */(
- JSON.parse(threadEl.getAttribute('range')));
- return rangesEqual(threadRange, range);
- }
-
- const filteredThreadEls = this._filterThreadElsForLocation(
- this.getThreadEls(), line, commentSide).filter(matchesRange);
- return filteredThreadEls.length ? filteredThreadEls[0] : null;
- }
-
- /**
- * @param {!Array<!HTMLElement>} threadEls
- * @param {!{beforeNumber: (number|string|undefined|null),
- * afterNumber: (number|string|undefined|null)}}
- * lineInfo
- * @param {!DiffSide=} side The side (LEFT, RIGHT) for
- * which to return the threads.
- * @return {!Array<!HTMLElement>} The thread elements matching the given
- * location.
- */
- _filterThreadElsForLocation(threadEls, lineInfo, side) {
- function matchesLeftLine(threadEl) {
- return threadEl.getAttribute('comment-side') ==
- DiffSide.LEFT &&
- threadEl.getAttribute('line-num') == lineInfo.beforeNumber;
- }
- function matchesRightLine(threadEl) {
- return threadEl.getAttribute('comment-side') ==
- DiffSide.RIGHT &&
- threadEl.getAttribute('line-num') == lineInfo.afterNumber;
- }
- function matchesFileComment(threadEl) {
- return threadEl.getAttribute('comment-side') == side &&
- // line/range comments have 1-based line set, if line is falsy it's
- // a file comment
- !threadEl.getAttribute('line-num');
- }
-
- // Select the appropriate matchers for the desired side and line
- // If side is BOTH, we want both the left and right matcher.
- const matchers = [];
- if (side !== DiffSide.RIGHT) {
- matchers.push(matchesLeftLine);
- }
- if (side !== DiffSide.LEFT) {
- matchers.push(matchesRightLine);
- }
- if (lineInfo.afterNumber === 'FILE' ||
- lineInfo.beforeNumber === 'FILE') {
- matchers.push(matchesFileComment);
- }
- return threadEls.filter(threadEl =>
- matchers.some(matcher => matcher(threadEl)));
- }
-
- _getIgnoreWhitespace() {
- if (!this.prefs || !this.prefs.ignore_whitespace) {
- return WHITESPACE_IGNORE_NONE;
- }
- return this.prefs.ignore_whitespace;
- }
-
- _whitespaceChanged(
- preferredWhitespaceLevel, loadedWhitespaceLevel,
- noRenderOnPrefsChange) {
- // Polymer 2: check for undefined
- if ([
- preferredWhitespaceLevel,
- loadedWhitespaceLevel,
- noRenderOnPrefsChange,
- ].includes(undefined)) {
- return;
- }
-
- this._fetchDiffPromise = null;
- if (preferredWhitespaceLevel !== loadedWhitespaceLevel &&
- !noRenderOnPrefsChange) {
- this.reload();
- }
- }
-
- _syntaxHighlightingChanged(noRenderOnPrefsChange, prefsChangeRecord) {
- // Polymer 2: check for undefined
- if ([
- noRenderOnPrefsChange,
- prefsChangeRecord,
- ].includes(undefined)) {
- return;
- }
-
- if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') {
- return;
- }
-
- if (!noRenderOnPrefsChange) {
- this.reload();
- }
- }
-
- /**
- * @param {Object} patchRangeRecord
- * @return {number|null}
- */
- _computeParentIndex(patchRangeRecord) {
- return isMergeParent(patchRangeRecord.base.basePatchNum) ?
- getParentIndex(patchRangeRecord.base.basePatchNum) : null;
- }
-
- _handleCommentSave(e) {
- const comment = e.detail.comment;
- const side = e.detail.comment.__commentSide;
- const idx = this._findDraftIndex(comment, side);
- this.set(['comments', side, idx], comment);
- this._handleCommentSaveOrDiscard();
- }
-
- _handleCommentDiscard(e) {
- const comment = e.detail.comment;
- this._removeComment(comment);
- this._handleCommentSaveOrDiscard();
- }
-
- /**
- * Closure annotation for Polymer.prototype.push is off. Submitted PR:
- * https://github.com/Polymer/polymer/pull/4776
- * but for not suppressing annotations.
- *
- * @suppress {checkTypes}
- */
- _handleCommentUpdate(e) {
- const comment = e.detail.comment;
- const side = e.detail.comment.__commentSide;
- let idx = this._findCommentIndex(comment, side);
- if (idx === -1) {
- idx = this._findDraftIndex(comment, side);
- }
- if (idx !== -1) { // Update draft or comment.
- this.set(['comments', side, idx], comment);
- } else { // Create new draft.
- this.push(['comments', side], comment);
- }
- }
-
- _handleCommentSaveOrDiscard() {
- this.dispatchEvent(new CustomEvent(
- 'diff-comments-modified', {bubbles: true, composed: true}));
- }
-
- _removeComment(comment) {
- const side = comment.__commentSide;
- this._removeCommentFromSide(comment, side);
- }
-
- _removeCommentFromSide(comment, side) {
- let idx = this._findCommentIndex(comment, side);
- if (idx === -1) {
- idx = this._findDraftIndex(comment, side);
- }
- if (idx !== -1) {
- this.splice('comments.' + side, idx, 1);
- }
- }
-
- /** @return {number} */
- _findCommentIndex(comment, side) {
- if (!comment.id || !this.comments[side]) {
- return -1;
- }
- return this.comments[side].findIndex(item => item.id === comment.id);
- }
-
- /** @return {number} */
- _findDraftIndex(comment, side) {
- if (!comment.__draftID || !this.comments[side]) {
- return -1;
- }
- return this.comments[side].findIndex(
- item => item.__draftID === comment.__draftID);
- }
-
- _isSyntaxHighlightingEnabled(preferenceChangeRecord, diff) {
- if (!preferenceChangeRecord ||
- !preferenceChangeRecord.base ||
- !preferenceChangeRecord.base.syntax_highlighting ||
- !diff) {
- return false;
- }
- return !this._anyLineTooLong(diff) &&
- this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH;
- }
-
- /**
- * @return {boolean} whether any of the lines in diff are longer
- * than SYNTAX_MAX_LINE_LENGTH.
- */
- _anyLineTooLong(diff) {
- if (!diff) return false;
- return diff.content.some(section => {
- const lines = section.ab ?
- section.ab :
- (section.a || []).concat(section.b || []);
- return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
- });
- }
-
- _listenToViewportRender() {
- const renderUpdateListener = start => {
- if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
- this.reporting.diffViewDisplayed();
- this.$.syntaxLayer.removeListener(renderUpdateListener);
- }
- };
-
- this.$.syntaxLayer.addListener(renderUpdateListener);
- }
-
- _handleRenderStart() {
- this.reporting.time(TimingLabel.TOTAL);
- this.reporting.time(TimingLabel.CONTENT);
- }
-
- _handleRenderContent() {
- this.reporting.timeEnd(TimingLabel.CONTENT);
- }
-
- _handleNormalizeRange(event) {
- this.reporting.reportInteraction('normalize-range',
- {
- side: event.detail.side,
- lineNum: event.detail.lineNum,
- });
- }
-
- _handleDiffContextExpanded(event) {
- this.reporting.reportInteraction(
- 'diff-context-expanded', {numLines: event.detail.numLines}
- );
- }
-
- /**
- * Find the last chunk for the given side.
- *
- * @param {!Object} diff
- * @param {boolean} leftSide true if checking the base of the diff,
- * false if testing the revision.
- * @return {Object|null} returns the chunk object or null if there was
- * no chunk for that side.
- */
- _lastChunkForSide(diff, leftSide) {
- if (!diff.content.length) { return null; }
-
- let chunkIndex = diff.content.length;
- let chunk;
-
- // Walk backwards until we find a chunk for the given side.
- do {
- chunkIndex--;
- chunk = diff.content[chunkIndex];
- } while (
- // We haven't reached the beginning.
- chunkIndex >= 0 &&
-
- // The chunk doesn't have both sides.
- !chunk.ab &&
-
- // The chunk doesn't have the given side.
- ((leftSide && (!chunk.a || !chunk.a.length)) ||
- (!leftSide && (!chunk.b || !chunk.b.length))));
-
- // If we reached the beginning of the diff and failed to find a chunk
- // with the given side, return null.
- if (chunkIndex === -1) { return null; }
-
- return chunk;
- }
-
- /**
- * Check whether the specified side of the diff has a trailing newline.
- *
- * @param {!Object} diff
- * @param {boolean} leftSide true if checking the base of the diff,
- * false if testing the revision.
- * @return {boolean|null} Return true if the side has a trailing newline.
- * Return false if it doesn't. Return null if not applicable (for
- * example, if the diff has no content on the specified side).
- */
- _hasTrailingNewlines(diff, leftSide) {
- const chunk = this._lastChunkForSide(diff, leftSide);
- if (!chunk) { return null; }
- let lines;
- if (chunk.ab) {
- lines = chunk.ab;
- } else {
- lines = leftSide ? chunk.a : chunk.b;
- }
- return lines[lines.length - 1] === '';
- }
-
- _showNewlineWarningLeft(diff) {
- return this._hasTrailingNewlines(diff, true) === false;
- }
-
- _showNewlineWarningRight(diff) {
- return this._hasTrailingNewlines(diff, false) === false;
- }
-}
-
-customElements.define(GrDiffHost.is, GrDiffHost);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
new file mode 100644
index 0000000..da7d900
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host.ts
@@ -0,0 +1,1226 @@
+/**
+ * @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 '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-comment-thread/gr-comment-thread';
+import '../../shared/gr-js-api-interface/gr-js-api-interface';
+import '../gr-diff/gr-diff';
+import '../gr-syntax-layer/gr-syntax-layer';
+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-diff-host_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {rangesEqual} from '../gr-diff/gr-diff-utils';
+import {appContext} from '../../../services/app-context';
+import {
+ getParentIndex,
+ isMergeParent,
+ isNumber,
+} from '../../../utils/patch-set-util';
+import {
+ Comment,
+ isDraft,
+ sortComments,
+ UIComment,
+} from '../../../utils/comment-util';
+import {TwoSidesComments} from '../gr-comment-api/gr-comment-api';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+ CommitRange,
+ CoverageRange,
+ DiffLayer,
+ DiffLayerListener,
+} from '../../../types/types';
+import {
+ Base64ImageFile,
+ BlameInfo,
+ ChangeInfo,
+ CommentRange,
+ DiffInfo,
+ DiffPreferencesInfo,
+ NumericChangeId,
+ PatchRange,
+ PatchSetNum,
+ RepoName,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {JsApiService} from '../../shared/gr-js-api-interface/gr-js-api-types';
+import {GrDiff, LineOfInterest} from '../gr-diff/gr-diff';
+import {GrSyntaxLayer} from '../gr-syntax-layer/gr-syntax-layer';
+import {
+ DiffViewMode,
+ IgnoreWhitespaceType,
+ Side,
+} from '../../../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
+import {LineNumber} from '../gr-diff/gr-diff-line';
+import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread';
+import {PatchSetFile} from '../../../types/types';
+import {KnownExperimentId} from '../../../services/flags/flags';
+
+const MSG_EMPTY_BLAME = 'No blame information for this diff.';
+
+const EVENT_AGAINST_PARENT = 'diff-against-parent';
+const EVENT_ZERO_REBASE = 'rebase-percent-zero';
+const EVENT_NONZERO_REBASE = 'rebase-percent-nonzero';
+
+const TimingLabel = {
+ TOTAL: 'Diff Total Render',
+ CONTENT: 'Diff Content Render',
+ SYNTAX: 'Diff Syntax Render',
+};
+
+// Disable syntax highlighting if the overall diff is too large.
+const SYNTAX_MAX_DIFF_LENGTH = 20000;
+
+// If any line of the diff is more than the character limit, then disable
+// syntax highlighting for the entire file.
+const SYNTAX_MAX_LINE_LENGTH = 500;
+
+// 120 lines is good enough threshold for full-sized window viewport
+const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
+
+function isImageDiff(diff?: DiffInfo) {
+ if (!diff) return false;
+
+ const isA = diff.meta_a && diff.meta_a.content_type.startsWith('image/');
+ const isB = diff.meta_b && diff.meta_b.content_type.startsWith('image/');
+
+ return !!(diff.binary && (isA || isB));
+}
+
+interface LineInfo {
+ beforeNumber?: LineNumber;
+ afterNumber?: LineNumber;
+}
+
+// TODO(TS): Consolidate this with the CommentThread interface of comment-api.
+// What is being used here is just a local object for collecting all the data
+// that is needed to create a GrCommentThread component, see
+// _createThreadElement().
+interface CommentThread {
+ comments: UIComment[];
+ // In the context of a diff each thread must have a side!
+ commentSide: Side;
+ patchNum?: PatchSetNum;
+ lineNum?: LineNumber;
+ isOnParent?: boolean;
+ range?: CommentRange;
+}
+
+export interface GrDiffHost {
+ $: {
+ restAPI: RestApiService & Element;
+ jsAPI: JsApiService & Element;
+ syntaxLayer: GrSyntaxLayer & Element;
+ diff: GrDiff;
+ };
+}
+
+/**
+ * Wrapper around gr-diff.
+ *
+ * Webcomponent fetching diffs and related data from restAPI and passing them
+ * to the presentational gr-diff for rendering. <gr-diff-host> is a Gerrit
+ * specific component, while <gr-diff> is a re-usable component.
+ */
+@customElement('gr-diff-host')
+export class GrDiffHost extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the user selects a line.
+ *
+ * @event line-selected
+ */
+
+ /**
+ * Fired if being logged in is required.
+ *
+ * @event show-auth-required
+ */
+
+ /**
+ * Fired when a comment is saved or discarded
+ *
+ * @event diff-comments-modified
+ */
+
+ @property({type: Number})
+ changeNum?: NumericChangeId;
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Boolean})
+ noAutoRender = false;
+
+ @property({type: Object})
+ patchRange?: PatchRange;
+
+ @property({type: Object})
+ file?: PatchSetFile;
+
+ @property({type: String})
+ path?: string;
+
+ @property({type: Object})
+ prefs?: DiffPreferencesInfo;
+
+ @property({type: String})
+ projectName?: RepoName;
+
+ @property({type: Boolean})
+ displayLine = false;
+
+ @property({
+ type: Boolean,
+ computed: '_computeIsImageDiff(diff)',
+ notify: true,
+ })
+ isImageDiff?: boolean;
+
+ @property({type: Object})
+ commitRange?: CommitRange;
+
+ @property({type: Object, notify: true})
+ filesWeblinks: FilesWebLinks | {} = {};
+
+ @property({type: Boolean, reflectToAttribute: true})
+ hidden = false;
+
+ @property({type: Boolean})
+ noRenderOnPrefsChange = false;
+
+ @property({type: Object, observer: '_commentsChanged'})
+ comments?: TwoSidesComments;
+
+ @property({type: Boolean})
+ lineWrapping = false;
+
+ @property({type: String})
+ viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+ @property({type: Object})
+ lineOfInterest?: LineOfInterest;
+
+ @property({type: Boolean})
+ showLoadFailure?: boolean;
+
+ @property({
+ type: Boolean,
+ notify: true,
+ computed: '_computeIsBlameLoaded(_blame)',
+ })
+ isBlameLoaded?: boolean;
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: String})
+ _errorMessage: string | null = null;
+
+ @property({type: Object})
+ _baseImage: Base64ImageFile | null = null;
+
+ @property({type: Object})
+ _revisionImage: Base64ImageFile | null = null;
+
+ @property({type: Object, notify: true})
+ diff?: DiffInfo;
+
+ @property({type: Object})
+ _fetchDiffPromise: Promise<DiffInfo> | null = null;
+
+ @property({type: Object})
+ _blame: BlameInfo[] | null = null;
+
+ @property({type: Array})
+ _coverageRanges: CoverageRange[] = [];
+
+ @property({type: String})
+ _loadedWhitespaceLevel?: IgnoreWhitespaceType;
+
+ @property({type: Number, computed: '_computeParentIndex(patchRange.*)'})
+ _parentIndex: number | null = null;
+
+ @property({
+ type: Boolean,
+ computed: '_isSyntaxHighlightingEnabled(prefs.*, diff)',
+ })
+ _syntaxHighlightingEnabled?: boolean;
+
+ @property({type: Array})
+ _layers: DiffLayer[] = [];
+
+ private readonly reporting = appContext.reportingService;
+
+ private readonly flags = appContext.flagsService;
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener(
+ // These are named inconsistently for a reason:
+ // The create-comment event is fired to indicate that we should
+ // create a comment.
+ // The comment-* events are just notifying that the comments did already
+ // change in some way, and that we should update any models we may want
+ // to keep in sync.
+ 'create-comment',
+ e => this._handleCreateComment(e)
+ );
+ this.addEventListener('comment-discard', e =>
+ this._handleCommentDiscard(e)
+ );
+ this.addEventListener('comment-update', e => this._handleCommentUpdate(e));
+ this.addEventListener('comment-save', e => this._handleCommentSave(e));
+ this.addEventListener('render-start', () => this._handleRenderStart());
+ this.addEventListener('render-content', () => this._handleRenderContent());
+ this.addEventListener('normalize-range', event =>
+ this._handleNormalizeRange(event)
+ );
+ this.addEventListener('diff-context-expanded', event =>
+ this._handleDiffContextExpanded(event)
+ );
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ if (this._canReload()) {
+ this.reload();
+ }
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ });
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.clear();
+ }
+
+ /**
+ * @param shouldReportMetric indicate a new Diff Page. This is a
+ * signal to report metrics event that started on location change.
+ * @return
+ */
+ async reload(shouldReportMetric?: boolean) {
+ this.clear();
+ if (!this.path) throw new Error('Missing required "path" property.');
+ if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+ this.diff = undefined;
+ this._errorMessage = null;
+ const whitespaceLevel = this._getIgnoreWhitespace();
+
+ this._layers = this._getLayers(this.path, this.changeNum);
+
+ if (shouldReportMetric) {
+ // We listen on render viewport only on DiffPage (on paramsChanged)
+ this._listenToViewportRender();
+ }
+
+ this._coverageRanges = [];
+ this._getCoverageData();
+
+ try {
+ const diff = await this._getDiff();
+ this._loadedWhitespaceLevel = whitespaceLevel;
+ this._reportDiff(diff);
+
+ await this._loadDiffAssets(diff);
+
+ // Not waiting for coverage ranges intentionally as
+ // plugin loading should not block the content rendering
+
+ this.filesWeblinks = this._getFilesWeblinks(diff);
+ this.diff = diff;
+ const event = await this._onRenderOnce();
+ if (shouldReportMetric) {
+ // We report diffViewContentDisplayed only on reload caused
+ // by params changed - expected only on Diff Page.
+ this.reporting.diffViewContentDisplayed();
+ }
+ const needsSyntaxHighlighting = !!event.detail?.contentRendered;
+ if (needsSyntaxHighlighting) {
+ this.reporting.time(TimingLabel.SYNTAX);
+ try {
+ await this.$.syntaxLayer.process();
+ } finally {
+ this.reporting.timeEnd(TimingLabel.SYNTAX);
+ }
+ }
+ } catch (e) {
+ if (e instanceof Response) {
+ this._handleGetDiffError(e);
+ } else {
+ console.warn('Error encountered loading diff:', e);
+ }
+ } finally {
+ this.reporting.timeEnd(TimingLabel.TOTAL);
+ }
+ }
+
+ private _getLayers(path: string, changeNum: NumericChangeId): DiffLayer[] {
+ // Get layers from plugins (if any).
+ return [this.$.syntaxLayer, ...this.$.jsAPI.getDiffLayers(path, changeNum)];
+ }
+
+ private _onRenderOnce(): Promise<CustomEvent> {
+ return new Promise<CustomEvent>(resolve => {
+ const callback = (event: CustomEvent) => {
+ this.removeEventListener('render', callback);
+ resolve(event);
+ };
+ this.addEventListener('render', callback);
+ });
+ }
+
+ clear() {
+ if (this.path) this.$.jsAPI.disposeDiffLayers(this.path);
+ this._layers = [];
+ }
+
+ _getCoverageData() {
+ if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+ if (!this.change) throw new Error('Missing required "change" prop.');
+ if (!this.path) throw new Error('Missing required "path" prop.');
+ if (!this.patchRange) throw new Error('Missing required "patchRange".');
+ const changeNum = this.changeNum;
+ const change = this.change;
+ const path = this.path;
+ // Coverage providers do not provide data for EDIT and PARENT patch sets.
+
+ const toNumberOnly = (patchNum: PatchSetNum) =>
+ isNumber(patchNum) ? patchNum : undefined;
+
+ const basePatchNum = toNumberOnly(this.patchRange.basePatchNum);
+ const patchNum = toNumberOnly(this.patchRange.patchNum);
+ this.$.jsAPI
+ .getCoverageAnnotationApis()
+ .then(coverageAnnotationApis => {
+ coverageAnnotationApis.forEach(coverageAnnotationApi => {
+ const provider = coverageAnnotationApi.getCoverageProvider();
+ if (!provider) return;
+ provider(changeNum, path, basePatchNum, patchNum, change)
+ .then(coverageRanges => {
+ if (!this.patchRange) throw new Error('Missing "patchRange".');
+ if (
+ !coverageRanges ||
+ changeNum !== this.changeNum ||
+ change !== this.change ||
+ path !== this.path ||
+ basePatchNum !== toNumberOnly(this.patchRange.basePatchNum) ||
+ patchNum !== toNumberOnly(this.patchRange.patchNum)
+ ) {
+ return;
+ }
+
+ const existingCoverageRanges = this._coverageRanges;
+ this._coverageRanges = coverageRanges;
+
+ // Notify with existing coverage ranges in case there is some
+ // existing coverage data that needs to be removed
+ existingCoverageRanges.forEach(range => {
+ coverageAnnotationApi.notify(
+ path,
+ range.code_range.start_line,
+ range.code_range.end_line,
+ range.side
+ );
+ });
+
+ // Notify with new coverage data
+ coverageRanges.forEach(range => {
+ coverageAnnotationApi.notify(
+ path,
+ range.code_range.start_line,
+ range.code_range.end_line,
+ range.side
+ );
+ });
+ })
+ .catch(err => {
+ console.warn('Applying coverage from provider failed: ', err);
+ });
+ });
+ })
+ .catch(err => {
+ console.warn('Loading coverage ranges failed: ', err);
+ });
+ }
+
+ _getFilesWeblinks(diff: DiffInfo) {
+ if (!this.projectName || !this.commitRange || !this.path) return {};
+ return {
+ meta_a: GerritNav.getFileWebLinks(
+ this.projectName,
+ this.commitRange.baseCommit,
+ this.path,
+ {weblinks: diff && diff.meta_a && diff.meta_a.web_links}
+ ),
+ meta_b: GerritNav.getFileWebLinks(
+ this.projectName,
+ this.commitRange.commit,
+ this.path,
+ {weblinks: diff && diff.meta_b && diff.meta_b.web_links}
+ ),
+ };
+ }
+
+ /** Cancel any remaining diff builder rendering work. */
+ cancel() {
+ this.$.diff.cancel();
+ this.$.syntaxLayer.cancel();
+ }
+
+ getCursorStops() {
+ return this.$.diff.getCursorStops();
+ }
+
+ isRangeSelected() {
+ return this.$.diff.isRangeSelected();
+ }
+
+ createRangeComment() {
+ return this.$.diff.createRangeComment();
+ }
+
+ toggleLeftDiff() {
+ this.$.diff.toggleLeftDiff();
+ }
+
+ /**
+ * Load and display blame information for the base of the diff.
+ */
+ loadBlame(): Promise<BlameInfo[]> {
+ if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+ if (!this.patchRange) throw new Error('Missing required "patchRange".');
+ if (!this.path) throw new Error('Missing required "path" property.');
+ return this.$.restAPI
+ .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true)
+ .then(blame => {
+ if (!blame || !blame.length) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: MSG_EMPTY_BLAME},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return Promise.reject(MSG_EMPTY_BLAME);
+ }
+
+ this._blame = blame;
+ return blame;
+ });
+ }
+
+ clearBlame() {
+ this._blame = null;
+ }
+
+ getThreadEls(): GrCommentThread[] {
+ return Array.from(this.$.diff.querySelectorAll('.comment-thread'));
+ }
+
+ addDraftAtLine(el: Element) {
+ this.$.diff.addDraftAtLine(el);
+ }
+
+ clearDiffContent() {
+ this.$.diff.clearDiffContent();
+ }
+
+ expandAllContext() {
+ this.$.diff.expandAllContext();
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _canReload() {
+ return (
+ !!this.changeNum && !!this.patchRange && !!this.path && !this.noAutoRender
+ );
+ }
+
+ // TODO(milutin): Use rest-api with fetchCacheURL instead of this.
+ prefetchDiff() {
+ if (
+ !!this.changeNum &&
+ !!this.patchRange &&
+ !!this.path &&
+ this._fetchDiffPromise === null
+ ) {
+ this._fetchDiffPromise = this._getDiff();
+ }
+ }
+
+ _getDiff(): Promise<DiffInfo> {
+ if (this._fetchDiffPromise !== null) {
+ const fetchDiffPromise = this._fetchDiffPromise;
+ this._fetchDiffPromise = null;
+ return fetchDiffPromise;
+ }
+ // Wrap the diff request in a new promise so that the error handler
+ // rejects the promise, allowing the error to be handled in the .catch.
+ return new Promise((resolve, reject) => {
+ if (!this.changeNum) throw new Error('Missing required "changeNum".');
+ if (!this.patchRange) throw new Error('Missing required "patchRange".');
+ if (!this.path) throw new Error('Missing required "path" property.');
+ this.$.restAPI
+ .getDiff(
+ this.changeNum,
+ this.patchRange.basePatchNum,
+ this.patchRange.patchNum,
+ this.path,
+ this._getIgnoreWhitespace(),
+ reject
+ )
+ .then(resolve);
+ });
+ }
+
+ _handleGetDiffError(response: Response) {
+ // Loading the diff may respond with 409 if the file is too large. In this
+ // case, use a toast error..
+ if (response.status === 409) {
+ this.dispatchEvent(
+ new CustomEvent('server-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+
+ if (this.showLoadFailure) {
+ this._errorMessage = [
+ 'Encountered error when loading the diff:',
+ response.status,
+ response.statusText,
+ ].join(' ');
+ return;
+ }
+
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ /**
+ * Report info about the diff response.
+ */
+ _reportDiff(diff?: DiffInfo) {
+ if (!diff || !diff.content) return;
+
+ // Count the delta lines stemming from normal deltas, and from
+ // due_to_rebase deltas.
+ let nonRebaseDelta = 0;
+ let rebaseDelta = 0;
+ diff.content.forEach(chunk => {
+ if (chunk.ab) {
+ return;
+ }
+ const deltaSize = Math.max(
+ chunk.a ? chunk.a.length : 0,
+ chunk.b ? chunk.b.length : 0
+ );
+ if (chunk.due_to_rebase) {
+ rebaseDelta += deltaSize;
+ } else {
+ nonRebaseDelta += deltaSize;
+ }
+ });
+
+ // Find the percent of the delta from due_to_rebase chunks rounded to two
+ // digits. Diffs with no delta are considered 0%.
+ const totalDelta = rebaseDelta + nonRebaseDelta;
+ const percentRebaseDelta = !totalDelta
+ ? 0
+ : Math.round((100 * rebaseDelta) / totalDelta);
+
+ // Report the due_to_rebase percentage in the "diff" category when
+ // applicable.
+ if (!this.patchRange) throw new Error('Missing required "patchRange".');
+ if (this.patchRange.basePatchNum === 'PARENT') {
+ this.reporting.reportInteraction(EVENT_AGAINST_PARENT);
+ } else if (percentRebaseDelta === 0) {
+ this.reporting.reportInteraction(EVENT_ZERO_REBASE);
+ } else {
+ this.reporting.reportInteraction(EVENT_NONZERO_REBASE, {
+ percentRebaseDelta,
+ });
+ }
+ }
+
+ _loadDiffAssets(diff?: DiffInfo) {
+ if (isImageDiff(diff)) {
+ // diff! is justified, because isImageDiff() returns false otherwise
+ return this._getImages(diff!).then(images => {
+ this._baseImage = images.baseImage;
+ this._revisionImage = images.revisionImage;
+ });
+ } else {
+ this._baseImage = null;
+ this._revisionImage = null;
+ return Promise.resolve();
+ }
+ }
+
+ _computeIsImageDiff(diff?: DiffInfo) {
+ return isImageDiff(diff);
+ }
+
+ _commentsChanged(newComments: TwoSidesComments) {
+ const allComments = [];
+ for (const side of [Side.LEFT, Side.RIGHT]) {
+ // This is needed by the threading.
+ for (const comment of newComments[side]) {
+ comment.__commentSide = side;
+ }
+ allComments.push(...newComments[side]);
+ }
+ // Currently, the only way this is ever changed here is when the initial
+ // comments are loaded, so it's okay performance wise to clear the threads
+ // and recreate them. If this changes in future, we might want to reuse
+ // some DOM nodes here.
+ this._clearThreads();
+ const threads = this._createThreads(allComments);
+ for (const thread of threads) {
+ const threadEl = this._createThreadElement(thread);
+ this._attachThreadElement(threadEl);
+ }
+ }
+
+ _createThreads(comments: UIComment[]): CommentThread[] {
+ const sortedComments = sortComments(comments);
+ const threads = [];
+ for (const comment of sortedComments) {
+ // If the comment is in reply to another comment, find that comment's
+ // thread and append to it.
+ if (comment.in_reply_to) {
+ const thread = threads.find(thread =>
+ thread.comments.some(c => c.id === comment.in_reply_to)
+ );
+ if (thread) {
+ thread.comments.push(comment);
+ continue;
+ }
+ }
+
+ // Otherwise, this comment starts its own thread.
+ if (!comment.__commentSide) throw new Error('Missing "__commentSide".');
+ const newThread: CommentThread = {
+ comments: [comment],
+ commentSide: comment.__commentSide,
+ patchNum: comment.patch_set,
+ lineNum: comment.line,
+ isOnParent: comment.side === 'PARENT',
+ };
+ if (comment.range) {
+ newThread.range = {...comment.range};
+ }
+ threads.push(newThread);
+ }
+ return threads;
+ }
+
+ _computeIsBlameLoaded(blame: BlameInfo[] | null) {
+ return !!blame;
+ }
+
+ _getImages(diff: DiffInfo) {
+ if (!this.changeNum) throw new Error('Missing required "changeNum" prop.');
+ if (!this.patchRange) throw new Error('Missing required "patchRange".');
+ return this.$.restAPI.getImagesForDiff(
+ this.changeNum,
+ diff,
+ this.patchRange
+ );
+ }
+
+ _handleCreateComment(e: CustomEvent) {
+ const {lineNum, side, patchNum, isOnParent, range} = e.detail;
+ const threadEl = this._getOrCreateThread(
+ patchNum,
+ lineNum,
+ side,
+ range,
+ isOnParent
+ );
+ threadEl.addOrEditDraft(lineNum, range);
+
+ this.reporting.recordDraftInteraction();
+ }
+
+ /**
+ * Gets or creates a comment thread at a given location.
+ * May provide a range, to get/create a range comment.
+ */
+ _getOrCreateThread(
+ patchNum: PatchSetNum,
+ lineNum: LineNumber | undefined,
+ commentSide: Side,
+ range?: CommentRange,
+ isOnParent?: boolean
+ ): GrCommentThread {
+ let threadEl = this._getThreadEl(lineNum, commentSide, range);
+ if (!threadEl) {
+ threadEl = this._createThreadElement({
+ comments: [],
+ commentSide,
+ patchNum,
+ lineNum,
+ range,
+ isOnParent,
+ });
+ this._attachThreadElement(threadEl);
+ }
+ return threadEl;
+ }
+
+ _attachThreadElement(threadEl: Element) {
+ this.$.diff.appendChild(threadEl);
+ }
+
+ _clearThreads() {
+ for (const threadEl of this.getThreadEls()) {
+ const parent = threadEl.parentNode;
+ if (parent) parent.removeChild(threadEl);
+ }
+ }
+
+ _createThreadElement(thread: CommentThread) {
+ const threadEl = document.createElement('gr-comment-thread');
+ threadEl.className = 'comment-thread';
+ threadEl.setAttribute('slot', `${thread.commentSide}-${thread.lineNum}`);
+ threadEl.comments = thread.comments;
+ threadEl.commentSide = thread.commentSide;
+ threadEl.isOnParent = !!thread.isOnParent;
+ threadEl.parentIndex = this._parentIndex;
+ // Use path before renmaing when comment added on the left when comparing
+ // two patch sets (not against base)
+ if (
+ this.file &&
+ this.file.basePath &&
+ thread.commentSide === Side.LEFT &&
+ !thread.isOnParent
+ ) {
+ threadEl.path = this.file.basePath;
+ } else {
+ threadEl.path = this.path;
+ }
+ threadEl.changeNum = this.changeNum;
+ threadEl.patchNum = thread.patchNum;
+ threadEl.showPatchset = false;
+ // GrCommentThread does not understand 'FILE', but requires undefined.
+ threadEl.lineNum = thread.lineNum !== 'FILE' ? thread.lineNum : undefined;
+ threadEl.projectName = this.projectName;
+ threadEl.range = thread.range;
+ const threadDiscardListener = (e: Event) => {
+ const threadEl = e.currentTarget as Element;
+ const parent = threadEl.parentNode;
+ if (parent) parent.removeChild(threadEl);
+ threadEl.removeEventListener('thread-discard', threadDiscardListener);
+ };
+ threadEl.addEventListener('thread-discard', threadDiscardListener);
+ return threadEl;
+ }
+
+ /**
+ * Gets a comment thread element at a given location.
+ * May provide a range, to get a range comment.
+ */
+ _getThreadEl(
+ lineNum: LineNumber | undefined,
+ commentSide: Side,
+ range?: CommentRange
+ ): GrCommentThread | null {
+ let line: LineInfo;
+ if (commentSide === Side.LEFT) {
+ line = {beforeNumber: lineNum};
+ } else if (commentSide === Side.RIGHT) {
+ line = {afterNumber: lineNum};
+ } else {
+ throw new Error(`Unknown side: ${commentSide}`);
+ }
+ function matchesRange(threadEl: GrCommentThread) {
+ const rangeAtt = threadEl.getAttribute('range');
+ const threadRange = rangeAtt
+ ? (JSON.parse(rangeAtt) as CommentRange)
+ : undefined;
+ return rangesEqual(threadRange, range);
+ }
+
+ const filteredThreadEls = this._filterThreadElsForLocation(
+ this.getThreadEls(),
+ line,
+ commentSide
+ ).filter(matchesRange);
+ return filteredThreadEls.length ? filteredThreadEls[0] : null;
+ }
+
+ _filterThreadElsForLocation(
+ threadEls: GrCommentThread[],
+ lineInfo: LineInfo,
+ side: Side
+ ) {
+ function matchesLeftLine(threadEl: GrCommentThread) {
+ return (
+ threadEl.getAttribute('comment-side') === Side.LEFT &&
+ threadEl.getAttribute('line-num') === String(lineInfo.beforeNumber)
+ );
+ }
+ function matchesRightLine(threadEl: GrCommentThread) {
+ return (
+ threadEl.getAttribute('comment-side') === Side.RIGHT &&
+ threadEl.getAttribute('line-num') === String(lineInfo.afterNumber)
+ );
+ }
+ function matchesFileComment(threadEl: GrCommentThread) {
+ return (
+ threadEl.getAttribute('comment-side') === side &&
+ // line/range comments have 1-based line set, if line is falsy it's
+ // a file comment
+ !threadEl.getAttribute('line-num')
+ );
+ }
+
+ // Select the appropriate matchers for the desired side and line
+ const matchers: ((thread: GrCommentThread) => boolean)[] = [];
+ if (side === Side.LEFT) {
+ matchers.push(matchesLeftLine);
+ }
+ if (side === Side.RIGHT) {
+ matchers.push(matchesRightLine);
+ }
+ if (lineInfo.afterNumber === 'FILE' || lineInfo.beforeNumber === 'FILE') {
+ matchers.push(matchesFileComment);
+ }
+ return threadEls.filter(threadEl =>
+ matchers.some(matcher => matcher(threadEl))
+ );
+ }
+
+ _getIgnoreWhitespace(): IgnoreWhitespaceType {
+ if (!this.prefs || !this.prefs.ignore_whitespace) {
+ return IgnoreWhitespaceType.IGNORE_NONE;
+ }
+ return this.prefs.ignore_whitespace;
+ }
+
+ @observe(
+ 'prefs.ignore_whitespace',
+ '_loadedWhitespaceLevel',
+ 'noRenderOnPrefsChange'
+ )
+ _whitespaceChanged(
+ preferredWhitespaceLevel?: IgnoreWhitespaceType,
+ loadedWhitespaceLevel?: IgnoreWhitespaceType,
+ noRenderOnPrefsChange?: boolean
+ ) {
+ if (preferredWhitespaceLevel === undefined) return;
+ if (loadedWhitespaceLevel === undefined) return;
+ if (noRenderOnPrefsChange === undefined) return;
+
+ this._fetchDiffPromise = null;
+ if (
+ preferredWhitespaceLevel !== loadedWhitespaceLevel &&
+ !noRenderOnPrefsChange
+ ) {
+ this.reload();
+ }
+ }
+
+ @observe('noRenderOnPrefsChange', 'prefs.*')
+ _syntaxHighlightingChanged(
+ noRenderOnPrefsChange?: boolean,
+ prefsChangeRecord?: PolymerDeepPropertyChange<
+ DiffPreferencesInfo,
+ DiffPreferencesInfo
+ >
+ ) {
+ if (noRenderOnPrefsChange === undefined) return;
+ if (prefsChangeRecord === undefined) return;
+ if (prefsChangeRecord.path !== 'prefs.syntax_highlighting') return;
+
+ if (!noRenderOnPrefsChange) this.reload();
+ }
+
+ _computeParentIndex(
+ patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+ ) {
+ if (!patchRangeRecord.base) return null;
+ return isMergeParent(patchRangeRecord.base.basePatchNum)
+ ? getParentIndex(patchRangeRecord.base.basePatchNum)
+ : null;
+ }
+
+ _handleCommentSave(e: CustomEvent) {
+ const comment = e.detail.comment;
+ const side = e.detail.comment.__commentSide;
+ const idx = this._findDraftIndex(comment, side);
+ this.set(['comments', side, idx], comment);
+ this._handleCommentSaveOrDiscard();
+ }
+
+ _handleCommentDiscard(e: CustomEvent) {
+ const comment = e.detail.comment;
+ this._removeComment(comment);
+ this._handleCommentSaveOrDiscard();
+ }
+
+ _handleCommentUpdate(e: CustomEvent) {
+ const comment = e.detail.comment;
+ const side = e.detail.comment.__commentSide;
+ let idx = this._findCommentIndex(comment, side);
+ if (idx === -1) {
+ idx = this._findDraftIndex(comment, side);
+ }
+ if (idx !== -1) {
+ // Update draft or comment.
+ this.set(['comments', side, idx], comment);
+ } else {
+ // Create new draft.
+ this.push(['comments', side], comment);
+ }
+ }
+
+ _handleCommentSaveOrDiscard() {
+ this.dispatchEvent(
+ new CustomEvent('diff-comments-modified', {bubbles: true, composed: true})
+ );
+ }
+
+ _removeComment(comment: UIComment) {
+ const side = comment.__commentSide;
+ if (!side) throw new Error('Missing required "side" in comment.');
+ this._removeCommentFromSide(comment, side);
+ }
+
+ _removeCommentFromSide(comment: Comment, side: Side) {
+ let idx = this._findCommentIndex(comment, side);
+ if (idx === -1) {
+ idx = this._findDraftIndex(comment, side);
+ }
+ if (idx !== -1) {
+ this.splice('comments.' + side, idx, 1);
+ }
+ }
+
+ _findCommentIndex(comment: Comment, side: Side) {
+ if (!comment.id || !this.comments || !this.comments[side]) {
+ return -1;
+ }
+ return this.comments[side].findIndex(item => item.id === comment.id);
+ }
+
+ _findDraftIndex(comment: Comment, side: Side) {
+ if (
+ !isDraft(comment) ||
+ !comment.__draftID ||
+ !this.comments ||
+ !this.comments[side]
+ ) {
+ return -1;
+ }
+ return this.comments[side].findIndex(
+ item => isDraft(item) && item.__draftID === comment.__draftID
+ );
+ }
+
+ _isSyntaxHighlightingEnabled(
+ preferenceChangeRecord?: PolymerDeepPropertyChange<
+ DiffPreferencesInfo,
+ DiffPreferencesInfo
+ >,
+ diff?: DiffInfo
+ ) {
+ if (
+ !preferenceChangeRecord ||
+ !preferenceChangeRecord.base ||
+ !preferenceChangeRecord.base.syntax_highlighting ||
+ !diff
+ ) {
+ return false;
+ }
+ return (
+ !this._anyLineTooLong(diff) &&
+ this.$.diff.getDiffLength(diff) <= SYNTAX_MAX_DIFF_LENGTH
+ );
+ }
+
+ /**
+ * @return whether any of the lines in diff are longer
+ * than SYNTAX_MAX_LINE_LENGTH.
+ */
+ _anyLineTooLong(diff?: DiffInfo) {
+ if (!diff) return false;
+ return diff.content.some(section => {
+ const lines = section.ab
+ ? section.ab
+ : (section.a || []).concat(section.b || []);
+ return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+ });
+ }
+
+ _listenToViewportRender() {
+ const renderUpdateListener: DiffLayerListener = start => {
+ if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
+ this.reporting.diffViewDisplayed();
+ this.$.syntaxLayer.removeListener(renderUpdateListener);
+ }
+ };
+
+ this.$.syntaxLayer.addListener(renderUpdateListener);
+ }
+
+ _handleRenderStart() {
+ this.reporting.time(TimingLabel.TOTAL);
+ this.reporting.time(TimingLabel.CONTENT);
+ }
+
+ _handleRenderContent() {
+ this.reporting.timeEnd(TimingLabel.CONTENT);
+ }
+
+ _handleNormalizeRange(event: CustomEvent) {
+ this.reporting.reportInteraction('normalize-range', {
+ side: event.detail.side,
+ lineNum: event.detail.lineNum,
+ });
+ }
+
+ _handleDiffContextExpanded(event: CustomEvent) {
+ this.reporting.reportInteraction('diff-context-expanded', {
+ numLines: event.detail.numLines,
+ });
+ }
+
+ /**
+ * Find the last chunk for the given side.
+ *
+ * @param leftSide true if checking the base of the diff,
+ * false if testing the revision.
+ * @return returns the chunk object or null if there was
+ * no chunk for that side.
+ */
+ _lastChunkForSide(diff: DiffInfo | undefined, leftSide: boolean) {
+ if (!diff?.content.length) {
+ return null;
+ }
+
+ let chunkIndex = diff.content.length;
+ let chunk;
+
+ // Walk backwards until we find a chunk for the given side.
+ do {
+ chunkIndex--;
+ chunk = diff.content[chunkIndex];
+ } while (
+ // We haven't reached the beginning.
+ chunkIndex >= 0 &&
+ // The chunk doesn't have both sides.
+ !chunk.ab &&
+ // The chunk doesn't have the given side.
+ ((leftSide && (!chunk.a || !chunk.a.length)) ||
+ (!leftSide && (!chunk.b || !chunk.b.length)))
+ );
+
+ // If we reached the beginning of the diff and failed to find a chunk
+ // with the given side, return null.
+ if (chunkIndex === -1) {
+ return null;
+ }
+
+ return chunk;
+ }
+
+ /**
+ * Check whether the specified side of the diff has a trailing newline.
+ *
+ * @param leftSide true if checking the base of the diff,
+ * false if testing the revision.
+ * @return Return true if the side has a trailing newline.
+ * Return false if it doesn't. Return null if not applicable (for
+ * example, if the diff has no content on the specified side).
+ */
+ _hasTrailingNewlines(diff: DiffInfo | undefined, leftSide: boolean) {
+ const chunk = this._lastChunkForSide(diff, leftSide);
+ if (!chunk) return null;
+ let lines;
+ if (chunk.ab) {
+ lines = chunk.ab;
+ } else {
+ lines = leftSide ? chunk.a : chunk.b;
+ }
+ if (!lines) return null;
+ return lines[lines.length - 1] === '';
+ }
+
+ _showNewlineWarningLeft(diff?: DiffInfo) {
+ return this._hasTrailingNewlines(diff, true) === false;
+ }
+
+ _showNewlineWarningRight(diff?: DiffInfo) {
+ return this._hasTrailingNewlines(diff, false) === false;
+ }
+
+ _useNewContextControls() {
+ return this.flags.isEnabled(KnownExperimentId.NEW_CONTEXT_CONTROLS);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-host': GrDiffHost;
+ }
+}
+
+// TODO(TS): Be more specific than CustomEvent, which has detail:any.
+declare global {
+ interface HTMLElementEventMap {
+ render: CustomEvent;
+ 'normalize-range': CustomEvent;
+ 'diff-context-expanded': CustomEvent;
+ 'create-comment': CustomEvent;
+ 'comment-discard': CustomEvent;
+ 'comment-update': CustomEvent;
+ 'comment-save': CustomEvent;
+ 'root-id-changed': CustomEvent;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
index 38b0d6d..9921dd6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_html.ts
@@ -24,17 +24,14 @@
patch-range="[[patchRange]]"
path="[[path]]"
prefs="[[prefs]]"
- project-name="[[projectName]]"
display-line="[[displayLine]]"
is-image-diff="[[isImageDiff]]"
- commit-range="[[commitRange]]"
hidden$="[[hidden]]"
no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
line-wrapping="[[lineWrapping]]"
view-mode="[[viewMode]]"
line-of-interest="[[lineOfInterest]]"
logged-in="[[_loggedIn]]"
- loading="[[_loading]]"
error-message="[[_errorMessage]]"
base-image="[[_baseImage]]"
revision-image="[[_revisionImage]]"
@@ -44,6 +41,7 @@
diff="[[diff]]"
show-newline-warning-left="[[_showNewlineWarningLeft(diff)]]"
show-newline-warning-right="[[_showNewlineWarningRight(diff)]]"
+ use-new-context-controls="[[_useNewContextControls()]]"
>
</gr-diff>
<gr-syntax-layer
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
index d0e6b62..1dd2737 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-host/gr-diff-host_test.js
@@ -20,7 +20,9 @@
import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {DiffSide} from '../gr-diff/gr-diff-utils.js';
+import {sortComments} from '../../../utils/comment-util.js';
+import {Side} from '../../../constants/constants.js';
+import {createChange} from '../../../test/test-data-generators.js';
const basicFixture = fixtureFromElement('gr-diff-host');
@@ -35,6 +37,8 @@
async getLoggedIn() { return getLoggedIn; },
});
element = basicFixture.instantiate();
+ element.changeNum = 123;
+ element.path = 'some/path';
sinon.stub(element.reporting, 'time');
sinon.stub(element.reporting, 'timeEnd');
});
@@ -46,9 +50,12 @@
getDiffLayers() { return pluginLayers; },
});
element = basicFixture.instantiate();
+ element.changeNum = 123;
+ element.path = 'some/path';
});
test('plugin layers requested', () => {
element.patchRange = {};
+ element.change = createChange();
element.reload();
assert(element.$.jsAPI.getDiffLayers.called);
});
@@ -171,7 +178,6 @@
],
};
- element._removeComment({});
// Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
// to believe that one object deepEquals another even when they do :-/.
assert.equal(JSON.stringify(element.comments), JSON.stringify({
@@ -248,12 +254,20 @@
});
test('thread-discard handling', () => {
- const threads = [
- {comments: [{id: 4711}]},
- {comments: [{id: 42}]},
- ];
+ const threads = element._createThreads([
+ {
+ id: 4711,
+ __commentSide: 'left',
+ updated: '2015-12-20 15:01:20.396000000',
+ },
+ {
+ id: 42,
+ __commentSide: 'left',
+ updated: '2017-12-20 15:01:20.396000000',
+ },
+ ]);
element._parentIndex = 1;
- element.changeNum = '2';
+ element.changeNum = 2;
element.path = 'some/path';
element.projectName = 'Some project';
const threadEls = threads.map(
@@ -267,10 +281,10 @@
return threadEl;
});
assert.equal(threadEls.length, 2);
- assert.equal(threadEls[0].rootId, 4711);
- assert.equal(threadEls[1].rootId, 42);
+ assert.equal(threadEls[0].comments[0].id, 4711);
+ assert.equal(threadEls[1].comments[0].id, 42);
for (const threadEl of threadEls) {
- dom(element).appendChild(threadEl);
+ element.appendChild(threadEl);
}
threadEls[0].dispatchEvent(
@@ -278,7 +292,7 @@
const attachedThreads = element.queryAllEffectiveChildren(
'gr-comment-thread');
assert.equal(attachedThreads.length, 1);
- assert.equal(attachedThreads[0].rootId, 42);
+ assert.equal(attachedThreads[0].comments[0].id, 42);
});
suite('render reporting', () => {
@@ -299,7 +313,7 @@
'Diff Content Render'));
});
- test('ends total and syntax timer after syntax layer processing', done => {
+ test('ends total and syntax timer after syntax layer', async () => {
sinon.stub(element.reporting, 'diffViewContentDisplayed');
let notifySyntaxProcessed;
sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
@@ -309,44 +323,41 @@
sinon.stub(element.$.restAPI, 'getDiff').returns(
Promise.resolve({content: []}));
element.patchRange = {};
+ element.change = createChange();
element.$.restAPI.getDiffPreferences().then(prefs => {
element.prefs = prefs;
return element.reload(true);
});
// Multiple cascading microtasks are scheduled.
- setTimeout(() => {
- notifySyntaxProcessed();
- // Assert after the notification task is processed.
- Promise.resolve().then(() => {
- assert.isTrue(element.reporting.timeEnd.calledWithExactly(
- 'Diff Total Render'));
- assert.isTrue(element.reporting.timeEnd.calledWithExactly(
- 'Diff Syntax Render'));
- assert.isTrue(element.reporting.diffViewContentDisplayed.called);
- done();
- });
- });
+ await flush();
+ notifySyntaxProcessed();
+ // Multiple cascading microtasks are scheduled.
+ await flush();
+ assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+ 'Diff Total Render'));
+ assert.isTrue(element.reporting.timeEnd.calledWithExactly(
+ 'Diff Syntax Render'));
+ assert.isTrue(element.reporting.diffViewContentDisplayed.called);
});
- test('ends total timer w/ no syntax layer processing', done => {
+ test('ends total timer w/ no syntax layer processing', async () => {
sinon.stub(element.$.restAPI, 'getDiff').returns(
Promise.resolve({content: []}));
element.patchRange = {};
+ element.change = createChange();
element.reload();
// Multiple cascading microtasks are scheduled.
- setTimeout(() => {
- // Reporting can be called with other parameters (ex. PluginsLoaded),
- // but only 'Diff Total Render' is important in this test.
- assert.equal(
- element.reporting.timeEnd.getCalls()
- .filter(call => call.calledWithExactly('Diff Total Render'))
- .length,
- 1);
- done();
- });
+ await flush();
+ // Reporting can be called with other parameters (ex. PluginsLoaded),
+ // but only 'Diff Total Render' is important in this test.
+ assert.equal(
+ element.reporting.timeEnd.getCalls()
+ .filter(call => call.calledWithExactly('Diff Total Render'))
+ .length,
+ 1);
});
- test('completes reload promise after syntax layer processing', done => {
+ test('completes reload promise after syntax layer processing', async () => {
let notifySyntaxProcessed;
sinon.stub(element.$.syntaxLayer, 'process').returns(new Promise(
resolve => {
@@ -355,6 +366,7 @@
sinon.stub(element.$.restAPI, 'getDiff').returns(
Promise.resolve({content: []}));
element.patchRange = {};
+ element.change = createChange();
let reloadComplete = false;
element.$.restAPI.getDiffPreferences()
.then(prefs => {
@@ -365,15 +377,12 @@
reloadComplete = true;
});
// Multiple cascading microtasks are scheduled.
- setTimeout(() => {
- assert.isFalse(reloadComplete);
- notifySyntaxProcessed();
- // Assert after the notification task is processed.
- setTimeout(() => {
- assert.isTrue(reloadComplete);
- done();
- });
- });
+ await flush();
+ assert.isFalse(reloadComplete);
+ notifySyntaxProcessed();
+ // Assert after the notification task is processed.
+ await flush();
+ assert.isTrue(reloadComplete);
});
});
@@ -383,6 +392,14 @@
// Stub the network calls into requests that never resolve.
sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
element.patchRange = {};
+ element.change = createChange();
+
+ // Needs to be set to something first for it to cancel.
+ element.diff = {
+ content: [{
+ a: ['foo'],
+ }],
+ };
element.reload();
assert.isTrue(cancelStub.called);
@@ -392,6 +409,9 @@
setup(() => {
getLoggedIn = false;
element = basicFixture.instantiate();
+ element.changeNum = 123;
+ element.change = createChange();
+ element.path = 'some/path';
});
test('reload() loads files weblinks', () => {
@@ -454,9 +474,9 @@
test('reload resolves on error', () => {
const onErrStub = sinon.stub(element, '_handleGetDiffError');
- const error = {ok: false, status: 500};
+ const error = new Response(null, {ok: false, status: 500});
sinon.stub(element.$.restAPI, 'getDiff').callsFake(
- (changeNum, basePatchNum, patchNum, path, onErr) => {
+ (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
onErr(error);
});
element.patchRange = {};
@@ -522,6 +542,7 @@
);
element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+ element.change = createChange();
element.comments = {
left: [],
right: [],
@@ -818,7 +839,7 @@
test('delegates cancel()', () => {
const stub = sinon.stub(element.$.diff, 'cancel');
element.patchRange = {};
- element.reload();
+ element.cancel();
assert.isTrue(stub.calledOnce);
assert.equal(stub.lastCall.args.length, 0);
});
@@ -851,6 +872,8 @@
suite('blame', () => {
setup(() => {
element = basicFixture.instantiate();
+ element.changeNum = 123;
+ element.path = 'some/path';
});
test('clearBlame', () => {
@@ -904,7 +927,7 @@
test('getThreadEls() returns .comment-threads', () => {
const threadEl = document.createElement('div');
threadEl.className = 'comment-thread';
- dom(element.$.diff).appendChild(threadEl);
+ element.$.diff.appendChild(threadEl);
assert.deepEqual(element.getThreadEls(), [threadEl]);
});
@@ -932,9 +955,8 @@
});
test('passes in changeNum', () => {
- const value = '12345';
- element.changeNum = value;
- assert.equal(element.$.diff.changeNum, value);
+ element.changeNum = 12345;
+ assert.equal(element.$.diff.changeNum, 12345);
});
test('passes in noAutoRender', () => {
@@ -962,15 +984,8 @@
});
test('passes in changeNum', () => {
- const value = '12345';
- element.changeNum = value;
- assert.equal(element.$.diff.changeNum, value);
- });
-
- test('passes in projectName', () => {
- const value = 'Gerrit';
- element.projectName = value;
- assert.equal(element.$.diff.projectName, value);
+ element.changeNum = 12345;
+ assert.equal(element.$.diff.changeNum, 12345);
});
test('passes in displayLine', () => {
@@ -979,12 +994,6 @@
assert.equal(element.$.diff.displayLine, value);
});
- test('passes in commitRange', () => {
- const value = {};
- element.commitRange = value;
- assert.equal(element.$.diff.commitRange, value);
- });
-
test('passes in hidden', () => {
const value = true;
element.hidden = value;
@@ -1021,6 +1030,7 @@
setup(() => {
element = basicFixture.instantiate();
+ element.changeNum = 123;
element.path = 'file.txt';
element.patchRange = {basePatchNum: 1};
reportStub = sinon.stub(element.reporting, 'reportInteraction');
@@ -1137,7 +1147,7 @@
in_reply_to: 'sallys_confession',
},
];
- const sortedComments = element._sortComments(comments);
+ const sortedComments = sortComments(comments);
assert.equal(sortedComments[0], comments[1]);
assert.equal(sortedComments[1], comments[2]);
assert.equal(sortedComments[2], comments[0]);
@@ -1172,23 +1182,17 @@
assert.equal(actualThreads.length, 2);
- assert.equal(
- actualThreads[0].start_datetime, '2015-12-23 15:00:20.396000000');
assert.equal(actualThreads[0].commentSide, 'left');
assert.equal(actualThreads[0].comments.length, 2);
assert.deepEqual(actualThreads[0].comments[0], comments[0]);
assert.deepEqual(actualThreads[0].comments[1], comments[1]);
assert.equal(actualThreads[0].patchNum, undefined);
- assert.equal(actualThreads[0].rootId, 'sallys_confession');
assert.equal(actualThreads[0].lineNum, 1);
- assert.equal(
- actualThreads[1].start_datetime, '2015-12-20 15:01:20.396000000');
assert.equal(actualThreads[1].commentSide, 'left');
assert.equal(actualThreads[1].comments.length, 1);
assert.deepEqual(actualThreads[1].comments[0], comments[2]);
assert.equal(actualThreads[1].patchNum, undefined);
- assert.equal(actualThreads[1].rootId, 'new_draft');
assert.equal(actualThreads[1].lineNum, undefined);
});
@@ -1210,7 +1214,6 @@
const expectedThreads = [
{
- start_datetime: '2015-12-24 15:00:10.396000000',
commentSide: 'left',
comments: [{
id: 'betsys_confession',
@@ -1227,7 +1230,6 @@
line: 1,
}],
patchNum: 5,
- rootId: 'betsys_confession',
range: {
start_line: 1,
start_character: 1,
@@ -1269,14 +1271,12 @@
id: 'sallys_confession',
message: 'i like you, jack',
updated: '2015-12-23 15:00:20.396000000',
- // line: 1,
- // __commentSide: 'left',
+ __commentSide: 'left',
}, {
id: 'jacks_reply',
message: 'i like you, too',
updated: '2015-12-24 15:01:20.396000000',
- // __commentSide: 'left',
- // line: 1,
+ __commentSide: 'left',
in_reply_to: 'sallys_confession',
},
];
@@ -1380,9 +1380,9 @@
const threads = [];
assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
assert.deepEqual(element._filterThreadElsForLocation(threads, line,
- DiffSide.LEFT), []);
+ Side.LEFT), []);
assert.deepEqual(element._filterThreadElsForLocation(threads, line,
- DiffSide.RIGHT), []);
+ Side.RIGHT), []);
});
test('_filterThreadElsForLocation for line comments', () => {
@@ -1405,12 +1405,10 @@
r5.setAttribute('comment-side', 'right');
const threadEls = [l3, l5, r3, r5];
- assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
- [l3, r5]);
assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
- DiffSide.LEFT), [l3]);
+ Side.LEFT), [l3]);
assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
- DiffSide.RIGHT), [r5]);
+ Side.RIGHT), [r5]);
});
test('_filterThreadElsForLocation for file comments', () => {
@@ -1425,14 +1423,10 @@
r.setAttribute('line-num', 'FILE');
const threadEls = [l, r];
- assert.deepEqual(element._filterThreadElsForLocation(threadEls, line),
- [l, r]);
assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
- DiffSide.BOTH), [l, r]);
+ Side.LEFT), [l]);
assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
- DiffSide.LEFT), [l]);
- assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
- DiffSide.RIGHT), [r]);
+ Side.RIGHT), [r]);
});
suite('syntax layer with syntax_highlighting on', () => {
@@ -1446,6 +1440,9 @@
};
element.patchRange = {};
element.prefs = prefs;
+ element.changeNum = 123;
+ element.change = createChange();
+ element.path = 'some/path';
});
test('gr-diff-host provides syntax highlighting layer to gr-diff', () => {
@@ -1472,18 +1469,16 @@
assert.isFalse(element.$.syntaxLayer.enabled);
});
- test('starts syntax layer processing on render event', done => {
+ test('starts syntax layer processing on render event', async () => {
sinon.stub(element.$.syntaxLayer, 'process')
.returns(Promise.resolve());
sinon.stub(element.$.restAPI, 'getDiff').returns(
Promise.resolve({content: []}));
element.reload();
- setTimeout(() => {
- element.dispatchEvent(
- new CustomEvent('render', {bubbles: true, composed: true}));
- assert.isTrue(element.$.syntaxLayer.process.called);
- done();
- });
+ await flush();
+ element.dispatchEvent(
+ new CustomEvent('render', {bubbles: true, composed: true}));
+ assert.isTrue(element.$.syntaxLayer.process.called);
});
});
@@ -1501,6 +1496,7 @@
}],
};
element.patchRange = {};
+ element.change = createChange();
element.prefs = prefs;
});
@@ -1529,8 +1525,8 @@
setup(() => {
notifyStub = sinon.stub();
stub('gr-js-api-interface', {
- getCoverageAnnotationApi() {
- return Promise.resolve({
+ getCoverageAnnotationApis() {
+ return Promise.resolve([{
notify: notifyStub,
getCoverageProvider() {
return () => Promise.resolve([
@@ -1552,10 +1548,13 @@
},
]);
},
- });
+ }]);
},
});
element = basicFixture.instantiate();
+ element.changeNum = 123;
+ element.change = createChange();
+ element.path = 'some/path';
const prefs = {
line_length: 10,
show_tabs: true,
@@ -1571,10 +1570,10 @@
element.prefs = prefs;
});
- test('getCoverageAnnotationApi should be called', done => {
+ test('getCoverageAnnotationApis should be called', done => {
element.reload();
flush(() => {
- assert.isTrue(element.$.jsAPI.getCoverageAnnotationApi.calledOnce);
+ assert.isTrue(element.$.jsAPI.getCoverageAnnotationApis.calledOnce);
done();
});
});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
deleted file mode 100644
index 7c9d1ec..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.js
+++ /dev/null
@@ -1,85 +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 '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-diff-mode-selector_html.js';
-
-/** @extends PolymerElement */
-class GrDiffModeSelector extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff-mode-selector'; }
-
- static get properties() {
- return {
- mode: {
- type: String,
- notify: true,
- },
-
- /**
- * If set to true, the user's preference will be updated every time a
- * button is tapped. Don't set to true if there is no user.
- */
- saveOnChange: {
- type: Boolean,
- value: false,
- },
-
- /** @type {?} */
- _VIEW_MODES: {
- type: Object,
- readOnly: true,
- value: {
- SIDE_BY_SIDE: 'SIDE_BY_SIDE',
- UNIFIED: 'UNIFIED_DIFF',
- },
- },
- };
- }
-
- /**
- * Set the mode. If save on change is enabled also update the preference.
- */
- setMode(newMode) {
- if (this.saveOnChange && this.mode && this.mode !== newMode) {
- this.$.restAPI.savePreferences({diff_view: newMode});
- }
- this.mode = newMode;
- }
-
- _computeSelectedClass(diffViewMode, buttonViewMode) {
- return buttonViewMode === diffViewMode ? 'selected' : '';
- }
-
- _handleSideBySideTap() {
- this.setMode(this._VIEW_MODES.SIDE_BY_SIDE);
- }
-
- _handleUnifiedTap() {
- this.setMode(this._VIEW_MODES.UNIFIED);
- }
-}
-
-customElements.define(GrDiffModeSelector.is, GrDiffModeSelector);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
new file mode 100644
index 0000000..e0333cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector.ts
@@ -0,0 +1,114 @@
+/**
+ * @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 '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import {DiffViewMode} from '../../../constants/constants';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+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-diff-mode-selector_html';
+import {customElement, property} from '@polymer/decorators';
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import {FixIronA11yAnnouncer} from '../../../types/types';
+
+export interface GrDiffModeSelector {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-diff-mode-selector')
+export class GrDiffModeSelector extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, notify: true})
+ mode?: DiffViewMode;
+
+ /**
+ * If set to true, the user's preference will be updated every time a
+ * button is tapped. Don't set to true if there is no user.
+ */
+ @property({type: Boolean})
+ saveOnChange = false;
+
+ attached() {
+ ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
+ }
+
+ /**
+ * Set the mode. If save on change is enabled also update the preference.
+ */
+ setMode(newMode: DiffViewMode) {
+ if (this.saveOnChange && this.mode && this.mode !== newMode) {
+ this.$.restAPI.savePreferences({diff_view: newMode});
+ }
+ this.mode = newMode;
+ let annoucement;
+ if (this.isUnifiedSelected(newMode)) {
+ annoucement = 'Changed diff view to unified';
+ } else if (this.isSideBySideSelected(newMode)) {
+ annoucement = 'Changed diff view to side by side';
+ }
+ if (annoucement) {
+ this.fire(
+ 'iron-announce',
+ {
+ text: annoucement,
+ },
+ {bubbles: true}
+ );
+ }
+ }
+
+ _computeSideBySideSelected(mode: DiffViewMode) {
+ return mode === DiffViewMode.SIDE_BY_SIDE ? 'selected' : '';
+ }
+
+ _computeUnifiedSelected(mode: DiffViewMode) {
+ return mode === DiffViewMode.UNIFIED ? 'selected' : '';
+ }
+
+ isSideBySideSelected(mode: DiffViewMode) {
+ return mode === DiffViewMode.SIDE_BY_SIDE;
+ }
+
+ isUnifiedSelected(mode: DiffViewMode) {
+ return mode === DiffViewMode.UNIFIED;
+ }
+
+ _handleSideBySideTap() {
+ this.setMode(DiffViewMode.SIDE_BY_SIDE);
+ }
+
+ _handleUnifiedTap() {
+ this.setMode(DiffViewMode.UNIFIED);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-mode-selector': GrDiffModeSelector;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
index 40f6a32..a5bf269 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_html.ts
@@ -34,8 +34,9 @@
id="sideBySideBtn"
link=""
has-tooltip=""
- class$="[[_computeSelectedClass(mode, _VIEW_MODES.SIDE_BY_SIDE)]]"
+ class$="[[_computeSideBySideSelected(mode)]]"
title="Side-by-side diff"
+ aria-pressed="[[isSideBySideSelected(mode)]]"
on-click="_handleSideBySideTap"
>
<iron-icon icon="gr-icons:side-by-side"></iron-icon>
@@ -45,7 +46,8 @@
link=""
has-tooltip=""
title="Unified diff"
- class$="[[_computeSelectedClass(mode, _VIEW_MODES.UNIFIED)]]"
+ class$="[[_computeUnifiedSelected(mode)]]"
+ aria-pressed="[[isUnifiedSelected(mode)]]"
on-click="_handleUnifiedTap"
>
<iron-icon icon="gr-icons:unified"></iron-icon>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
index e84ef2b..07a1d16 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
@@ -17,6 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-diff-mode-selector.js';
+import {DiffViewMode} from '../../../constants/constants.js';
const basicFixture = fixtureFromElement('gr-diff-mode-selector');
@@ -28,11 +29,14 @@
});
test('_computeSelectedClass', () => {
- assert.equal(
- element._computeSelectedClass('SIDE_BY_SIDE', 'SIDE_BY_SIDE'),
+ assert.equal(element._computeSideBySideSelected(DiffViewMode.SIDE_BY_SIDE),
'selected');
- assert.equal(
- element._computeSelectedClass('SIDE_BY_SIDE', 'UNIFIED_DIFF'), '');
+ assert.equal(element._computeSideBySideSelected(DiffViewMode.UNIFIED),
+ '');
+ assert.equal(element._computeUnifiedSelected(DiffViewMode.UNIFIED),
+ 'selected');
+ assert.equal(element._computeUnifiedSelected(DiffViewMode.SIDE_BY_SIDE),
+ '');
});
test('setMode', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
deleted file mode 100644
index b68c889..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.js
+++ /dev/null
@@ -1,99 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-diff-preferences/gr-diff-preferences.js';
-import '../../shared/gr-overlay/gr-overlay.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-diff-preferences-dialog_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrDiffPreferencesDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff-preferences-dialog'; }
-
- static get properties() {
- return {
- /** @type {?} */
- diffPrefs: Object,
-
- /**
- * _editableDiffPrefs is a clone of diffPrefs.
- * All changes in the dialog are applied to this object
- * immediately, when a value in an editor is changed.
- * The "Save" button replaces the "diffPrefs" object with
- * the value of _editableDiffPrefs.
- *
- * @type {?}
- */
- _editableDiffPrefs: Object,
-
- _diffPrefsChanged: Boolean,
- };
- }
-
- getFocusStops() {
- return {
- start: this.$.diffPreferences.$.contextSelect,
- end: this.$.saveButton,
- };
- }
-
- resetFocus() {
- this.$.diffPreferences.$.contextSelect.focus();
- }
-
- _computeHeaderClass(changed) {
- return changed ? 'edited' : '';
- }
-
- _handleCancelDiff(e) {
- e.stopPropagation();
- this.$.diffPrefsOverlay.close();
- }
-
- open() {
- // JSON.parse(JSON.stringify(...)) makes a deep clone of diffPrefs.
- // It is known, that diffPrefs is obtained from an RestAPI call and
- // it is safe to clone the object this way.
- this._editableDiffPrefs = JSON.parse(JSON.stringify(this.diffPrefs));
- this.$.diffPrefsOverlay.open().then(() => {
- const focusStops = this.getFocusStops();
- this.$.diffPrefsOverlay.setFocusStops(focusStops);
- this.resetFocus();
- });
- }
-
- _handleSaveDiffPreferences() {
- this.diffPrefs = this._editableDiffPrefs;
- this.$.diffPreferences.save().then(() => {
- this.dispatchEvent(new CustomEvent('reload-diff-preference', {
- composed: true, bubbles: false,
- }));
-
- this.$.diffPrefsOverlay.close();
- });
- }
-}
-
-customElements.define(GrDiffPreferencesDialog.is, GrDiffPreferencesDialog);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
new file mode 100644
index 0000000..c66af58
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2019 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/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-diff-preferences/gr-diff-preferences';
+import '../../shared/gr-overlay/gr-overlay';
+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-diff-preferences-dialog_html';
+import {customElement, property} from '@polymer/decorators';
+import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {DiffPreferencesInfo} from '../../../types/common';
+
+export interface GrDiffPreferencesDialog {
+ $: {
+ diffPreferences: GrDiffPreferences;
+ saveButton: GrButton;
+ cancelButton: GrButton;
+ diffPrefsOverlay: GrOverlay;
+ };
+}
+@customElement('gr-diff-preferences-dialog')
+export class GrDiffPreferencesDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ diffPrefs?: DiffPreferencesInfo;
+
+ @property({type: Object})
+ _editableDiffPrefs?: DiffPreferencesInfo;
+
+ @property({type: Boolean, observer: '_onDiffPrefsChanged'})
+ _diffPrefsChanged?: boolean;
+
+ getFocusStops() {
+ return {
+ start: this.$.diffPreferences.$.contextSelect,
+ end: this.$.saveButton.disabled ? this.$.cancelButton : this.$.saveButton,
+ };
+ }
+
+ resetFocus() {
+ this.$.diffPreferences.$.contextSelect.focus();
+ }
+
+ _computeHeaderClass(changed: boolean) {
+ return changed ? 'edited' : '';
+ }
+
+ _handleCancelDiff(e: MouseEvent) {
+ e.stopPropagation();
+ this.$.diffPrefsOverlay.close();
+ }
+
+ _onDiffPrefsChanged() {
+ this.$.diffPrefsOverlay.setFocusStops(this.getFocusStops());
+ }
+
+ open() {
+ // JSON.parse(JSON.stringify(...)) makes a deep clone of diffPrefs.
+ // It is known, that diffPrefs is obtained from an RestAPI call and
+ // it is safe to clone the object this way.
+ this._editableDiffPrefs = JSON.parse(
+ JSON.stringify(this.diffPrefs)
+ ) as DiffPreferencesInfo;
+ this.$.diffPrefsOverlay.open().then(() => {
+ const focusStops = this.getFocusStops();
+ this.$.diffPrefsOverlay.setFocusStops(focusStops);
+ this.resetFocus();
+ });
+ }
+
+ _handleSaveDiffPreferences() {
+ this.diffPrefs = this._editableDiffPrefs;
+ this.$.diffPreferences.save().then(() => {
+ this.dispatchEvent(
+ new CustomEvent('reload-diff-preference', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+
+ this.$.diffPrefsOverlay.close();
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-preferences-dialog': GrDiffPreferencesDialog;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
index 9c942a3..787fe30 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
@@ -48,27 +48,32 @@
}
</style>
<gr-overlay id="diffPrefsOverlay" with-backdrop="">
- <div class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]">
- Diff Preferences
- </div>
- <gr-diff-preferences
- id="diffPreferences"
- diff-prefs="{{_editableDiffPrefs}}"
- has-unsaved-changes="{{_diffPrefsChanged}}"
- ></gr-diff-preferences>
- <div class="diffActions">
- <gr-button id="cancelButton" link="" on-click="_handleCancelDiff">
- Cancel
- </gr-button>
- <gr-button
- id="saveButton"
- link=""
- primary=""
- on-click="_handleSaveDiffPreferences"
- disabled$="[[!_diffPrefsChanged]]"
+ <div role="dialog" aria-labelledby="diffPreferencesTitle">
+ <h1
+ class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]"
+ id="diffPreferencesTitle"
>
- Save
- </gr-button>
+ Diff Preferences
+ </h1>
+ <gr-diff-preferences
+ id="diffPreferences"
+ diff-prefs="{{_editableDiffPrefs}}"
+ has-unsaved-changes="{{_diffPrefsChanged}}"
+ ></gr-diff-preferences>
+ <div class="diffActions">
+ <gr-button id="cancelButton" link="" on-click="_handleCancelDiff">
+ Cancel
+ </gr-button>
+ <gr-button
+ id="saveButton"
+ link=""
+ primary=""
+ on-click="_handleSaveDiffPreferences"
+ disabled$="[[!_diffPrefsChanged]]"
+ >
+ Save
+ </gr-button>
+ </div>
</div>
</gr-overlay>
`;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
deleted file mode 100644
index b9c97a9..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ /dev/null
@@ -1,669 +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 {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 {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
-import {util} from '../../../scripts/util.js';
-
-const WHOLE_FILE = -1;
-
-const DiffSide = {
- LEFT: 'left',
- RIGHT: 'right',
-};
-
-const DiffHighlights = {
- ADDED: 'edit_b',
- REMOVED: 'edit_a',
-};
-
-/**
- * The maximum size for an addition or removal chunk before it is broken down
- * into a series of chunks that are this size at most.
- *
- * Note: The value of 120 is chosen so that it is larger than the default
- * _asyncThreshold of 64, but feel free to tune this constant to your
- * performance needs.
- */
-const MAX_GROUP_SIZE = 120;
-
-/**
- * Converts the API's `DiffContent`s to `GrDiffGroup`s for rendering.
- *
- * Glossary:
- * - "chunk": A single `DiffContent` as returned by the API.
- * - "group": A single `GrDiffGroup` as used for rendering.
- * - "common" chunk/group: A chunk/group that should be considered unchanged
- * for diffing purposes. This can mean its either actually unchanged, or it
- * has only whitespace changes.
- * - "key location": A line number and side of the diff that should not be
- * collapsed e.g. because a comment is attached to it, or because it was
- * provided in the URL and thus should be visible
- * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
- * or cannot be collapsed because it contains a key location
- *
- * Here a a number of tasks this processor performs:
- * - splitting large chunks to allow more granular async rendering
- * - adding a group for the "File" pseudo line that file-level comments can
- * be attached to
- * - replacing common parts of the diff that are outside the user's
- * context setting and do not have comments with a group representing the
- * "expand context" widget. This may require splitting a chunk/group so
- * that the part that is within the context or has comments is shown, while
- * the rest is not.
- *
- * @extends PolymerElement
- */
-class GrDiffProcessor extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get is() { return 'gr-diff-processor'; }
-
- static get properties() {
- return {
-
- /**
- * The amount of context around collapsed groups.
- */
- context: Number,
-
- /**
- * The array of groups output by the processor.
- */
- groups: {
- type: Array,
- notify: true,
- },
-
- /**
- * Locations that should not be collapsed, including the locations of
- * comments.
- */
- keyLocations: {
- type: Object,
- value() { return {left: {}, right: {}}; },
- },
-
- /**
- * The maximum number of lines to process synchronously.
- */
- _asyncThreshold: {
- type: Number,
- value: 64,
- },
-
- /** @type {?number} */
- _nextStepHandle: Number,
- /**
- * The promise last returned from `process()` while the asynchronous
- * processing is running - `null` otherwise. Provides a `cancel()`
- * method that rejects it with `{isCancelled: true}`.
- *
- * @type {?Object}
- */
- _processPromise: {
- type: Object,
- value: null,
- },
- _isScrolling: Boolean,
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.listen(window, 'scroll', '_handleWindowScroll');
- }
-
- /** @override */
- detached() {
- super.detached();
- this.cancel();
- this.unlisten(window, 'scroll', '_handleWindowScroll');
- }
-
- _handleWindowScroll() {
- this._isScrolling = true;
- this.debounce('resetIsScrolling', () => {
- this._isScrolling = false;
- }, 50);
- }
-
- /**
- * Asynchronously process the diff chunks into groups. As it processes, it
- * will splice groups into the `groups` property of the component.
- *
- * @param {!Array<!Gerrit.DiffChunk>} chunks
- * @param {boolean} isBinary
- *
- * @return {!Promise<!Array<!Object>>} A promise that resolves with an
- * array of GrDiffGroups when the diff is completely processed.
- */
- process(chunks, isBinary) {
- // Cancel any still running process() calls, because they append to the
- // same groups field.
- this.cancel();
-
- this.groups = [];
- this.push('groups', this._makeFileComments());
-
- // If it's a binary diff, we won't be rendering hunks of text differences
- // so finish processing.
- if (isBinary) { return Promise.resolve(); }
-
- this._processPromise = util.makeCancelable(
- new Promise(resolve => {
- const state = {
- lineNums: {left: 0, right: 0},
- chunkIndex: 0,
- };
-
- chunks = this._splitLargeChunks(chunks);
- chunks = this._splitCommonChunksWithKeyLocations(chunks);
-
- let currentBatch = 0;
- const nextStep = () => {
- if (this._isScrolling) {
- this._nextStepHandle = this.async(nextStep, 100);
- return;
- }
- // If we are done, resolve the promise.
- if (state.chunkIndex >= chunks.length) {
- resolve();
- this._nextStepHandle = null;
- return;
- }
-
- // Process the next chunk and incorporate the result.
- const stateUpdate = this._processNext(state, chunks);
- for (const group of stateUpdate.groups) {
- this.push('groups', group);
- currentBatch += group.lines.length;
- }
- state.lineNums.left += stateUpdate.lineDelta.left;
- state.lineNums.right += stateUpdate.lineDelta.right;
-
- // Increment the index and recurse.
- state.chunkIndex = stateUpdate.newChunkIndex;
- if (currentBatch >= this._asyncThreshold) {
- currentBatch = 0;
- this._nextStepHandle = this.async(nextStep, 1);
- } else {
- nextStep.call(this);
- }
- };
-
- nextStep.call(this);
- }));
- return this._processPromise
- .finally(() => { this._processPromise = null; });
- }
-
- /**
- * Cancel any jobs that are running.
- */
- cancel() {
- if (this._nextStepHandle != null) {
- this.cancelAsync(this._nextStepHandle);
- this._nextStepHandle = null;
- }
- if (this._processPromise) {
- this._processPromise.cancel();
- }
- }
-
- /**
- * Process the next uncollapsible chunk, or the next collapsible chunks.
- *
- * @param {!Object} state
- * @param {!Array<!Object>} chunks
- * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
- */
- _processNext(state, chunks) {
- const firstUncollapsibleChunkIndex =
- this._firstUncollapsibleChunkIndex(chunks, state.chunkIndex);
- if (firstUncollapsibleChunkIndex === state.chunkIndex) {
- const chunk = chunks[state.chunkIndex];
- return {
- lineDelta: {
- left: this._linesLeft(chunk).length,
- right: this._linesRight(chunk).length,
- },
- groups: [this._chunkToGroup(
- chunk, state.lineNums.left + 1, state.lineNums.right + 1)],
- newChunkIndex: state.chunkIndex + 1,
- };
- }
-
- return this._processCollapsibleChunks(
- state, chunks, firstUncollapsibleChunkIndex);
- }
-
- _linesLeft(chunk) {
- return chunk.ab || chunk.a || [];
- }
-
- _linesRight(chunk) {
- return chunk.ab || chunk.b || [];
- }
-
- _firstUncollapsibleChunkIndex(chunks, offset) {
- let chunkIndex = offset;
- while (chunkIndex < chunks.length &&
- this._isCollapsibleChunk(chunks[chunkIndex])) {
- chunkIndex++;
- }
- return chunkIndex;
- }
-
- _isCollapsibleChunk(chunk) {
- return (chunk.ab || chunk.common) && !chunk.keyLocation;
- }
-
- /**
- * Process a stretch of collapsible chunks.
- *
- * Outputs up to three groups:
- * 1) Visible context before the hidden common code, unless it's the
- * very beginning of the file.
- * 2) Context hidden behind a context bar, unless empty.
- * 3) Visible context after the hidden common code, unless it's the very
- * end of the file.
- *
- * @param {!Object} state
- * @param {!Array<Object>} chunks
- * @param {number} firstUncollapsibleChunkIndex
- * @return {{lineDelta: {left: number, right: number}, groups: !Array<!Object>, newChunkIndex: number}}
- */
- _processCollapsibleChunks(
- state, chunks, firstUncollapsibleChunkIndex) {
- const collapsibleChunks = chunks.slice(
- state.chunkIndex, firstUncollapsibleChunkIndex);
- const lineCount = collapsibleChunks.reduce(
- (sum, chunk) => sum + this._commonChunkLength(chunk), 0);
-
- let groups = this._chunksToGroups(
- collapsibleChunks,
- state.lineNums.left + 1,
- state.lineNums.right + 1);
-
- if (this.context !== WHOLE_FILE) {
- const hiddenStart = state.chunkIndex === 0 ? 0 : this.context;
- const hiddenEnd = lineCount - (
- firstUncollapsibleChunkIndex === chunks.length ?
- 0 : this.context);
- groups = GrDiffGroup.hideInContextControl(
- groups, hiddenStart, hiddenEnd);
- }
-
- return {
- lineDelta: {
- left: lineCount,
- right: lineCount,
- },
- groups,
- newChunkIndex: firstUncollapsibleChunkIndex,
- };
- }
-
- _commonChunkLength(chunk) {
- console.assert(chunk.ab || chunk.common);
- console.assert(
- !chunk.a || (chunk.b && chunk.a.length === chunk.b.length),
- `common chunk needs same number of a and b lines: `, chunk);
- return this._linesLeft(chunk).length;
- }
-
- /**
- * @param {!Array<!Object>} chunks
- * @param {number} offsetLeft
- * @param {number} offsetRight
- * @return {!Array<!Object>} (GrDiffGroup)
- */
- _chunksToGroups(chunks, offsetLeft, offsetRight) {
- return chunks.map(chunk => {
- const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
- const chunkLength = this._commonChunkLength(chunk);
- offsetLeft += chunkLength;
- offsetRight += chunkLength;
- return group;
- });
- }
-
- /**
- * @param {!Object} chunk
- * @param {number} offsetLeft
- * @param {number} offsetRight
- * @return {!Object} (GrDiffGroup)
- */
- _chunkToGroup(chunk, offsetLeft, offsetRight) {
- const type = chunk.ab ? GrDiffGroup.Type.BOTH : GrDiffGroup.Type.DELTA;
- const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
- const group = new GrDiffGroup(type, lines);
- group.keyLocation = chunk.keyLocation;
- group.dueToRebase = chunk.due_to_rebase;
- group.ignoredWhitespaceOnly = chunk.common;
- return group;
- }
-
- _linesFromChunk(chunk, offsetLeft, offsetRight) {
- if (chunk.ab) {
- return chunk.ab.map((row, i) => this._lineFromRow(
- GrDiffLine.Type.BOTH, offsetLeft, offsetRight, row, i));
- }
- let lines = [];
- if (chunk.a) {
- // Avoiding a.push(...b) because that causes callstack overflows for
- // large b, which can occur when large files are added removed.
- lines = lines.concat(this._linesFromRows(
- GrDiffLine.Type.REMOVE, chunk.a, offsetLeft,
- chunk[DiffHighlights.REMOVED]));
- }
- if (chunk.b) {
- // Avoiding a.push(...b) because that causes callstack overflows for
- // large b, which can occur when large files are added removed.
- lines = lines.concat(this._linesFromRows(
- GrDiffLine.Type.ADD, chunk.b, offsetRight,
- chunk[DiffHighlights.ADDED]));
- }
- return lines;
- }
-
- /**
- * @param {string} lineType (GrDiffLine.Type)
- * @param {!Array<string>} rows
- * @param {number} offset
- * @param {?Array<!Gerrit.IntralineInfo>=} opt_intralineInfos
- * @return {!Array<!Object>} (GrDiffLine)
- */
- _linesFromRows(lineType, rows, offset, opt_intralineInfos) {
- const grDiffHighlights = opt_intralineInfos ?
- this._convertIntralineInfos(rows, opt_intralineInfos) : undefined;
- return rows.map((row, i) => this._lineFromRow(
- lineType, offset, offset, row, i, grDiffHighlights));
- }
-
- /**
- * @param {string} type (GrDiffLine.Type)
- * @param {number} offsetLeft
- * @param {number} offsetRight
- * @param {string} row
- * @param {number} i
- * @param {!Array<!Object>=} opt_highlights
- * @return {!Object} (GrDiffLine)
- */
- _lineFromRow(type, offsetLeft, offsetRight, row, i, opt_highlights) {
- const line = new GrDiffLine(type);
- line.text = row;
- if (type !== GrDiffLine.Type.ADD) line.beforeNumber = offsetLeft + i;
- if (type !== GrDiffLine.Type.REMOVE) line.afterNumber = offsetRight + i;
- if (opt_highlights) {
- line.hasIntralineInfo = true;
- line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
- } else {
- line.hasIntralineInfo = false;
- }
- return line;
- }
-
- _makeFileComments() {
- const line = new GrDiffLine(GrDiffLine.Type.BOTH);
- line.beforeNumber = GrDiffLine.FILE;
- line.afterNumber = GrDiffLine.FILE;
- return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
- }
-
- /**
- * Split chunks into smaller chunks of the same kind.
- *
- * This is done to prevent doing too much work on the main thread in one
- * uninterrupted rendering step, which would make the browser unresponsive.
- *
- * Note that in the case of unmodified chunks, we only split chunks if the
- * context is set to file (because otherwise they are split up further down
- * the processing into the visible and hidden context), and only split it
- * into 2 chunks, one max sized one and the rest (for reasons that are
- * unclear to me).
- *
- * @param {!Array<!Gerrit.DiffChunk>} chunks Chunks as returned from the server
- * @return {!Array<!Gerrit.DiffChunk>} Finer grained chunks.
- */
- _splitLargeChunks(chunks) {
- const newChunks = [];
-
- for (const chunk of chunks) {
- if (!chunk.ab) {
- for (const subChunk of this._breakdownChunk(chunk)) {
- newChunks.push(subChunk);
- }
- continue;
- }
-
- // If the context is set to "whole file", then break down the shared
- // chunks so they can be rendered incrementally. Note: this is not
- // enabled for any other context preference because manipulating the
- // chunks in this way violates assumptions by the context grouper logic.
- if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
- // Split large shared chunks in two, where the first is the maximum
- // group size.
- newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
- newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
- } else {
- newChunks.push(chunk);
- }
- }
- return newChunks;
- }
-
- /**
- * In order to show key locations, such as comments, out of the bounds of
- * the selected context, treat them as separate chunks within the model so
- * that the content (and context surrounding it) renders correctly.
- *
- * @param {!Array<!Object>} chunks DiffContents as returned from server.
- * @return {!Array<!Object>} Finer grained DiffContents.
- */
- _splitCommonChunksWithKeyLocations(chunks) {
- const result = [];
- let leftLineNum = 1;
- let rightLineNum = 1;
-
- for (const chunk of chunks) {
- // If it isn't a common chunk, append it as-is and update line numbers.
- if (!chunk.ab && !chunk.common) {
- if (chunk.a) {
- leftLineNum += chunk.a.length;
- }
- if (chunk.b) {
- rightLineNum += chunk.b.length;
- }
- result.push(chunk);
- continue;
- }
-
- if (chunk.common && chunk.a.length != chunk.b.length) {
- throw new Error(
- 'DiffContent with common=true must always have equal length');
- }
- const numLines = this._commonChunkLength(chunk);
- const chunkEnds = this._findChunkEndsAtKeyLocations(
- numLines, leftLineNum, rightLineNum);
- leftLineNum += numLines;
- rightLineNum += numLines;
-
- if (chunk.ab) {
- result.push(...this._splitAtChunkEnds(chunk.ab, chunkEnds)
- .map(({lines, keyLocation}) =>
- Object.assign({}, chunk, {ab: lines, keyLocation})));
- } else if (chunk.common) {
- const aChunks = this._splitAtChunkEnds(chunk.a, chunkEnds);
- const bChunks = this._splitAtChunkEnds(chunk.b, chunkEnds);
- result.push(...aChunks.map(({lines, keyLocation}, i) =>
- Object.assign(
- {}, chunk, {a: lines, b: bChunks[i].lines, keyLocation})));
- }
- }
-
- return result;
- }
-
- /**
- * @return {!Array<{offset: number, keyLocation: boolean}>} Offsets of the
- * new chunk ends, including whether it's a key location.
- */
- _findChunkEndsAtKeyLocations(numLines, leftOffset, rightOffset) {
- const result = [];
- let lastChunkEnd = 0;
- for (let i=0; i<numLines; i++) {
- // If this line should not be collapsed.
- if (this.keyLocations[DiffSide.LEFT][leftOffset + i] ||
- this.keyLocations[DiffSide.RIGHT][rightOffset + i]) {
- // If any lines have been accumulated into the chunk leading up to
- // this non-collapse line, then add them as a chunk and start a new
- // one.
- if (i > lastChunkEnd) {
- result.push({offset: i, keyLocation: false});
- lastChunkEnd = i;
- }
-
- // Add the non-collapse line as its own chunk.
- result.push({offset: i + 1, keyLocation: true});
- }
- }
-
- if (numLines > lastChunkEnd) {
- result.push({offset: numLines, keyLocation: false});
- }
-
- return result;
- }
-
- _splitAtChunkEnds(lines, chunkEnds) {
- const result = [];
- let lastChunkEndOffset = 0;
- for (const {offset, keyLocation} of chunkEnds) {
- result.push(
- {lines: lines.slice(lastChunkEndOffset, offset), keyLocation});
- lastChunkEndOffset = offset;
- }
- return result;
- }
-
- /**
- * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
- * for rendering.
- *
- * @param {!Array<string>} rows
- * @param {!Array<!Gerrit.IntralineInfo>} intralineInfos
- * @return {!Array<!Object>} (GrDiffLine.Highlight)
- */
- _convertIntralineInfos(rows, intralineInfos) {
- let rowIndex = 0;
- let idx = 0;
- const normalized = [];
- for (const [skipLength, markLength] of intralineInfos) {
- let line = rows[rowIndex] + '\n';
- let j = 0;
- while (j < skipLength) {
- if (idx === line.length) {
- idx = 0;
- line = rows[++rowIndex] + '\n';
- continue;
- }
- idx++;
- j++;
- }
- let lineHighlight = {
- contentIndex: rowIndex,
- startIndex: idx,
- };
-
- j = 0;
- while (line && j < markLength) {
- if (idx === line.length) {
- idx = 0;
- line = rows[++rowIndex] + '\n';
- normalized.push(lineHighlight);
- lineHighlight = {
- contentIndex: rowIndex,
- startIndex: idx,
- };
- continue;
- }
- idx++;
- j++;
- }
- lineHighlight.endIndex = idx;
- normalized.push(lineHighlight);
- }
- return normalized;
- }
-
- /**
- * If a group is an addition or a removal, break it down into smaller groups
- * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
- * or a delta it is returned as the single element of the result array.
- *
- * @param {!Gerrit.DiffChunk} chunk A raw chunk from a diff response.
- * @return {!Array<!Array<!Object>>}
- */
- _breakdownChunk(chunk) {
- let key = null;
- if (chunk.a && !chunk.b) {
- key = 'a';
- } else if (chunk.b && !chunk.a) {
- key = 'b';
- } else if (chunk.ab) {
- key = 'ab';
- }
-
- if (!key) { return [chunk]; }
-
- return this._breakdown(chunk[key], MAX_GROUP_SIZE)
- .map(subChunkLines => {
- const subChunk = {};
- subChunk[key] = subChunkLines;
- if (chunk.due_to_rebase) {
- subChunk.due_to_rebase = true;
- }
- return subChunk;
- });
- }
-
- /**
- * Given an array and a size, return an array of arrays where no inner array
- * is larger than that size, preserving the original order.
- *
- * @param {!Array<T>} array
- * @param {number} size
- * @return {!Array<!Array<T>>}
- * @template T
- */
- _breakdown(array, size) {
- if (!array.length) { return []; }
- if (array.length < size) { return [array]; }
-
- const head = array.slice(0, array.length - size);
- const tail = array.slice(array.length - size);
-
- return this._breakdown(head, size).concat([tail]);
- }
-}
-
-customElements.define(GrDiffProcessor.is, GrDiffProcessor);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
new file mode 100644
index 0000000..ab7ab8a
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.ts
@@ -0,0 +1,730 @@
+/**
+ * @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 {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 {
+ GrDiffLine,
+ GrDiffLineType,
+ FILE,
+ Highlights,
+} from '../gr-diff/gr-diff-line';
+import {
+ GrDiffGroup,
+ GrDiffGroupType,
+ hideInContextControl,
+} from '../gr-diff/gr-diff-group';
+import {CancelablePromise, util} from '../../../scripts/util';
+import {customElement, property} from '@polymer/decorators';
+import {DiffContent} from '../../../types/common';
+import {Side} from '../../../constants/constants';
+
+const WHOLE_FILE = -1;
+
+interface State {
+ lineNums: {
+ left: number;
+ right: number;
+ };
+ chunkIndex: number;
+}
+
+interface ChunkEnd {
+ offset: number;
+ keyLocation: boolean;
+}
+
+export interface KeyLocations {
+ left: {[key: string]: boolean};
+ right: {[key: string]: boolean};
+}
+
+/**
+ * The maximum size for an addition or removal chunk before it is broken down
+ * into a series of chunks that are this size at most.
+ *
+ * Note: The value of 120 is chosen so that it is larger than the default
+ * _asyncThreshold of 64, but feel free to tune this constant to your
+ * performance needs.
+ */
+const MAX_GROUP_SIZE = 120;
+
+/**
+ * Converts the API's `DiffContent`s to `GrDiffGroup`s for rendering.
+ *
+ * Glossary:
+ * - "chunk": A single `DiffContent` as returned by the API.
+ * - "group": A single `GrDiffGroup` as used for rendering.
+ * - "common" chunk/group: A chunk/group that should be considered unchanged
+ * for diffing purposes. This can mean its either actually unchanged, or it
+ * has only whitespace changes.
+ * - "key location": A line number and side of the diff that should not be
+ * collapsed e.g. because a comment is attached to it, or because it was
+ * provided in the URL and thus should be visible
+ * - "uncollapsible" chunk/group: A chunk/group that is either not "common",
+ * or cannot be collapsed because it contains a key location
+ *
+ * Here a a number of tasks this processor performs:
+ * - splitting large chunks to allow more granular async rendering
+ * - adding a group for the "File" pseudo line that file-level comments can
+ * be attached to
+ * - replacing common parts of the diff that are outside the user's
+ * context setting and do not have comments with a group representing the
+ * "expand context" widget. This may require splitting a chunk/group so
+ * that the part that is within the context or has comments is shown, while
+ * the rest is not.
+ */
+@customElement('gr-diff-processor')
+export class GrDiffProcessor extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ @property({type: Number})
+ context = 3;
+
+ @property({type: Array, notify: true})
+ groups: GrDiffGroup[] = [];
+
+ @property({type: Object})
+ keyLocations: KeyLocations = {left: {}, right: {}};
+
+ @property({type: Number})
+ _asyncThreshold = 64;
+
+ @property({type: Number})
+ _nextStepHandle: number | null = null;
+
+ @property({type: Object})
+ _processPromise: CancelablePromise<void> | null = null;
+
+ @property({type: Boolean})
+ _isScrolling?: boolean;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.listen(window, 'scroll', '_handleWindowScroll');
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.cancel();
+ this.unlisten(window, 'scroll', '_handleWindowScroll');
+ }
+
+ _handleWindowScroll() {
+ this._isScrolling = true;
+ this.debounce(
+ 'resetIsScrolling',
+ () => {
+ this._isScrolling = false;
+ },
+ 50
+ );
+ }
+
+ /**
+ * Asynchronously process the diff chunks into groups. As it processes, it
+ * will splice groups into the `groups` property of the component.
+ *
+ * @return A promise that resolves with an
+ * array of GrDiffGroups when the diff is completely processed.
+ */
+ process(chunks: DiffContent[], isBinary: boolean) {
+ // Cancel any still running process() calls, because they append to the
+ // same groups field.
+ this.cancel();
+
+ this.groups = [];
+ this.push('groups', this._makeFileComments());
+
+ // If it's a binary diff, we won't be rendering hunks of text differences
+ // so finish processing.
+ if (isBinary) {
+ return Promise.resolve();
+ }
+
+ this._processPromise = util.makeCancelable(
+ new Promise(resolve => {
+ const state = {
+ lineNums: {left: 0, right: 0},
+ chunkIndex: 0,
+ };
+
+ chunks = this._splitLargeChunks(chunks);
+ chunks = this._splitCommonChunksWithKeyLocations(chunks);
+
+ let currentBatch = 0;
+ const nextStep = () => {
+ if (this._isScrolling) {
+ this._nextStepHandle = this.async(nextStep, 100);
+ return;
+ }
+ // If we are done, resolve the promise.
+ if (state.chunkIndex >= chunks.length) {
+ resolve();
+ this._nextStepHandle = null;
+ return;
+ }
+
+ // Process the next chunk and incorporate the result.
+ const stateUpdate = this._processNext(state, chunks);
+ for (const group of stateUpdate.groups) {
+ this.push('groups', group);
+ currentBatch += group.lines.length;
+ }
+ state.lineNums.left += stateUpdate.lineDelta.left;
+ state.lineNums.right += stateUpdate.lineDelta.right;
+
+ // Increment the index and recurse.
+ state.chunkIndex = stateUpdate.newChunkIndex;
+ if (currentBatch >= this._asyncThreshold) {
+ currentBatch = 0;
+ this._nextStepHandle = this.async(nextStep, 1);
+ } else {
+ nextStep.call(this);
+ }
+ };
+
+ nextStep.call(this);
+ })
+ );
+ return this._processPromise.finally(() => {
+ this._processPromise = null;
+ });
+ }
+
+ /**
+ * Cancel any jobs that are running.
+ */
+ cancel() {
+ if (this._nextStepHandle !== null) {
+ this.cancelAsync(this._nextStepHandle);
+ this._nextStepHandle = null;
+ }
+ if (this._processPromise) {
+ this._processPromise.cancel();
+ }
+ }
+
+ /**
+ * Process the next uncollapsible chunk, or the next collapsible chunks.
+ */
+ _processNext(state: State, chunks: DiffContent[]) {
+ const firstUncollapsibleChunkIndex = this._firstUncollapsibleChunkIndex(
+ chunks,
+ state.chunkIndex
+ );
+ if (firstUncollapsibleChunkIndex === state.chunkIndex) {
+ const chunk = chunks[state.chunkIndex];
+ return {
+ lineDelta: {
+ left: this._linesLeft(chunk).length,
+ right: this._linesRight(chunk).length,
+ },
+ groups: [
+ this._chunkToGroup(
+ chunk,
+ state.lineNums.left + 1,
+ state.lineNums.right + 1
+ ),
+ ],
+ newChunkIndex: state.chunkIndex + 1,
+ };
+ }
+
+ return this._processCollapsibleChunks(
+ state,
+ chunks,
+ firstUncollapsibleChunkIndex
+ );
+ }
+
+ _linesLeft(chunk: DiffContent) {
+ return chunk.ab || chunk.a || [];
+ }
+
+ _linesRight(chunk: DiffContent) {
+ return chunk.ab || chunk.b || [];
+ }
+
+ _firstUncollapsibleChunkIndex(chunks: DiffContent[], offset: number) {
+ let chunkIndex = offset;
+ while (
+ chunkIndex < chunks.length &&
+ this._isCollapsibleChunk(chunks[chunkIndex])
+ ) {
+ chunkIndex++;
+ }
+ return chunkIndex;
+ }
+
+ _isCollapsibleChunk(chunk: DiffContent) {
+ return (chunk.ab || chunk.common || chunk.skip) && !chunk.keyLocation;
+ }
+
+ /**
+ * Process a stretch of collapsible chunks.
+ *
+ * Outputs up to three groups:
+ * 1) Visible context before the hidden common code, unless it's the
+ * very beginning of the file.
+ * 2) Context hidden behind a context bar, unless empty.
+ * 3) Visible context after the hidden common code, unless it's the very
+ * end of the file.
+ */
+ _processCollapsibleChunks(
+ state: State,
+ chunks: DiffContent[],
+ firstUncollapsibleChunkIndex: number
+ ) {
+ const collapsibleChunks = chunks.slice(
+ state.chunkIndex,
+ firstUncollapsibleChunkIndex
+ );
+ const lineCount = collapsibleChunks.reduce(
+ (sum, chunk) => sum + this._commonChunkLength(chunk),
+ 0
+ );
+
+ let groups = this._chunksToGroups(
+ collapsibleChunks,
+ state.lineNums.left + 1,
+ state.lineNums.right + 1
+ );
+
+ const hasSkippedGroup = !!groups.find(g => g.skip);
+ if (this.context !== WHOLE_FILE || hasSkippedGroup) {
+ const contextNumLines = this.context > 0 ? this.context : 0;
+ const hiddenStart = state.chunkIndex === 0 ? 0 : contextNumLines;
+ const hiddenEnd =
+ lineCount -
+ (firstUncollapsibleChunkIndex === chunks.length ? 0 : this.context);
+ groups = hideInContextControl(groups, hiddenStart, hiddenEnd);
+ }
+
+ return {
+ lineDelta: {
+ left: lineCount,
+ right: lineCount,
+ },
+ groups,
+ newChunkIndex: firstUncollapsibleChunkIndex,
+ };
+ }
+
+ _commonChunkLength(chunk: DiffContent) {
+ if (chunk.skip) {
+ return chunk.skip;
+ }
+ console.assert(!!chunk.ab || !!chunk.common);
+
+ console.assert(
+ !chunk.a || (!!chunk.b && chunk.a.length === chunk.b.length),
+ 'common chunk needs same number of a and b lines: ',
+ chunk
+ );
+ return this._linesLeft(chunk).length;
+ }
+
+ _chunksToGroups(
+ chunks: DiffContent[],
+ offsetLeft: number,
+ offsetRight: number
+ ): GrDiffGroup[] {
+ return chunks.map(chunk => {
+ const group = this._chunkToGroup(chunk, offsetLeft, offsetRight);
+ const chunkLength = this._commonChunkLength(chunk);
+ offsetLeft += chunkLength;
+ offsetRight += chunkLength;
+ return group;
+ });
+ }
+
+ _chunkToGroup(
+ chunk: DiffContent,
+ offsetLeft: number,
+ offsetRight: number
+ ): GrDiffGroup {
+ const type =
+ chunk.ab || chunk.skip ? GrDiffGroupType.BOTH : GrDiffGroupType.DELTA;
+ const lines = this._linesFromChunk(chunk, offsetLeft, offsetRight);
+ const group = new GrDiffGroup(type, lines);
+ group.keyLocation = !!chunk.keyLocation;
+ group.dueToRebase = !!chunk.due_to_rebase;
+ group.dueToMove = !!chunk.due_to_move;
+ group.skip = chunk.skip;
+ group.ignoredWhitespaceOnly = !!chunk.common;
+ if (chunk.skip) {
+ group.lineRange = {
+ left: {start: offsetLeft, end: offsetLeft + chunk.skip - 1},
+ right: {start: offsetRight, end: offsetRight + chunk.skip - 1},
+ };
+ }
+ return group;
+ }
+
+ _linesFromChunk(chunk: DiffContent, offsetLeft: number, offsetRight: number) {
+ if (chunk.ab) {
+ return chunk.ab.map((row, i) =>
+ this._lineFromRow(GrDiffLineType.BOTH, offsetLeft, offsetRight, row, i)
+ );
+ }
+ let lines: GrDiffLine[] = [];
+ if (chunk.a) {
+ // Avoiding a.push(...b) because that causes callstack overflows for
+ // large b, which can occur when large files are added removed.
+ lines = lines.concat(
+ this._linesFromRows(
+ GrDiffLineType.REMOVE,
+ chunk.a,
+ offsetLeft,
+ chunk.edit_a
+ )
+ );
+ }
+ if (chunk.b) {
+ // Avoiding a.push(...b) because that causes callstack overflows for
+ // large b, which can occur when large files are added removed.
+ lines = lines.concat(
+ this._linesFromRows(
+ GrDiffLineType.ADD,
+ chunk.b,
+ offsetRight,
+ chunk.edit_b
+ )
+ );
+ }
+ return lines;
+ }
+
+ _linesFromRows(
+ lineType: GrDiffLineType,
+ rows: string[],
+ offset: number,
+ intralineInfos?: number[][]
+ ): GrDiffLine[] {
+ const grDiffHighlights = intralineInfos
+ ? this._convertIntralineInfos(rows, intralineInfos)
+ : undefined;
+ return rows.map((row, i) =>
+ this._lineFromRow(lineType, offset, offset, row, i, grDiffHighlights)
+ );
+ }
+
+ _lineFromRow(
+ type: GrDiffLineType,
+ offsetLeft: number,
+ offsetRight: number,
+ row: string,
+ i: number,
+ highlights?: Highlights[]
+ ): GrDiffLine {
+ const line = new GrDiffLine(type);
+ line.text = row;
+ if (type !== GrDiffLineType.ADD) line.beforeNumber = offsetLeft + i;
+ if (type !== GrDiffLineType.REMOVE) line.afterNumber = offsetRight + i;
+ if (highlights) {
+ line.hasIntralineInfo = true;
+ line.highlights = highlights.filter(hl => hl.contentIndex === i);
+ } else {
+ line.hasIntralineInfo = false;
+ }
+ return line;
+ }
+
+ _makeFileComments() {
+ const line = new GrDiffLine(GrDiffLineType.BOTH);
+ line.beforeNumber = FILE;
+ line.afterNumber = FILE;
+ return new GrDiffGroup(GrDiffGroupType.BOTH, [line]);
+ }
+
+ /**
+ * Split chunks into smaller chunks of the same kind.
+ *
+ * This is done to prevent doing too much work on the main thread in one
+ * uninterrupted rendering step, which would make the browser unresponsive.
+ *
+ * Note that in the case of unmodified chunks, we only split chunks if the
+ * context is set to file (because otherwise they are split up further down
+ * the processing into the visible and hidden context), and only split it
+ * into 2 chunks, one max sized one and the rest (for reasons that are
+ * unclear to me).
+ *
+ * @param chunks Chunks as returned from the server
+ * @return Finer grained chunks.
+ */
+ _splitLargeChunks(chunks: DiffContent[]): DiffContent[] {
+ const newChunks = [];
+
+ for (const chunk of chunks) {
+ if (!chunk.ab) {
+ for (const subChunk of this._breakdownChunk(chunk)) {
+ newChunks.push(subChunk);
+ }
+ continue;
+ }
+
+ // If the context is set to "whole file", then break down the shared
+ // chunks so they can be rendered incrementally. Note: this is not
+ // enabled for any other context preference because manipulating the
+ // chunks in this way violates assumptions by the context grouper logic.
+ if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
+ // Split large shared chunks in two, where the first is the maximum
+ // group size.
+ newChunks.push({ab: chunk.ab.slice(0, MAX_GROUP_SIZE)});
+ newChunks.push({ab: chunk.ab.slice(MAX_GROUP_SIZE)});
+ } else {
+ newChunks.push(chunk);
+ }
+ }
+ return newChunks;
+ }
+
+ /**
+ * In order to show key locations, such as comments, out of the bounds of
+ * the selected context, treat them as separate chunks within the model so
+ * that the content (and context surrounding it) renders correctly.
+ *
+ * @param chunks DiffContents as returned from server.
+ * @return Finer grained DiffContents.
+ */
+ _splitCommonChunksWithKeyLocations(chunks: DiffContent[]): DiffContent[] {
+ const result = [];
+ let leftLineNum = 1;
+ let rightLineNum = 1;
+
+ for (const chunk of chunks) {
+ // If it isn't a common chunk, append it as-is and update line numbers.
+ if (!chunk.ab && !chunk.skip && !chunk.common) {
+ if (chunk.a) {
+ leftLineNum += chunk.a.length;
+ }
+ if (chunk.b) {
+ rightLineNum += chunk.b.length;
+ }
+ result.push(chunk);
+ continue;
+ }
+
+ if (chunk.common && chunk.a!.length !== chunk.b!.length) {
+ throw new Error(
+ 'DiffContent with common=true must always have equal length'
+ );
+ }
+ const numLines = this._commonChunkLength(chunk);
+ const chunkEnds = this._findChunkEndsAtKeyLocations(
+ numLines,
+ leftLineNum,
+ rightLineNum
+ );
+ leftLineNum += numLines;
+ rightLineNum += numLines;
+
+ if (chunk.skip) {
+ result.push({
+ ...chunk,
+ skip: chunk.skip,
+ keyLocation: false,
+ });
+ } else if (chunk.ab) {
+ result.push(
+ ...this._splitAtChunkEnds(chunk.ab, chunkEnds).map(
+ ({lines, keyLocation}) => {
+ return {
+ ...chunk,
+ ab: lines,
+ keyLocation,
+ };
+ }
+ )
+ );
+ } else if (chunk.common) {
+ const aChunks = this._splitAtChunkEnds(chunk.a!, chunkEnds);
+ const bChunks = this._splitAtChunkEnds(chunk.b!, chunkEnds);
+ result.push(
+ ...aChunks.map(({lines, keyLocation}, i) => {
+ return {
+ ...chunk,
+ a: lines,
+ b: bChunks[i].lines,
+ keyLocation,
+ };
+ })
+ );
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * @return Offsets of the new chunk ends, including whether it's a key
+ * location.
+ */
+ _findChunkEndsAtKeyLocations(
+ numLines: number,
+ leftOffset: number,
+ rightOffset: number
+ ): ChunkEnd[] {
+ const result = [];
+ let lastChunkEnd = 0;
+ for (let i = 0; i < numLines; i++) {
+ // If this line should not be collapsed.
+ if (
+ this.keyLocations[Side.LEFT][leftOffset + i] ||
+ this.keyLocations[Side.RIGHT][rightOffset + i]
+ ) {
+ // If any lines have been accumulated into the chunk leading up to
+ // this non-collapse line, then add them as a chunk and start a new
+ // one.
+ if (i > lastChunkEnd) {
+ result.push({offset: i, keyLocation: false});
+ lastChunkEnd = i;
+ }
+
+ // Add the non-collapse line as its own chunk.
+ result.push({offset: i + 1, keyLocation: true});
+ }
+ }
+
+ if (numLines > lastChunkEnd) {
+ result.push({offset: numLines, keyLocation: false});
+ }
+
+ return result;
+ }
+
+ _splitAtChunkEnds(lines: string[], chunkEnds: ChunkEnd[]) {
+ const result = [];
+ let lastChunkEndOffset = 0;
+ for (const {offset, keyLocation} of chunkEnds) {
+ result.push({
+ lines: lines.slice(lastChunkEndOffset, offset),
+ keyLocation,
+ });
+ lastChunkEndOffset = offset;
+ }
+ return result;
+ }
+
+ /**
+ * Converts `IntralineInfo`s return by the API to `GrLineHighlights` used
+ * for rendering.
+ */
+ _convertIntralineInfos(
+ rows: string[],
+ intralineInfos: number[][]
+ ): Highlights[] {
+ let rowIndex = 0;
+ let idx = 0;
+ const normalized = [];
+ for (const [skipLength, markLength] of intralineInfos) {
+ let line = rows[rowIndex] + '\n';
+ let j = 0;
+ while (j < skipLength) {
+ if (idx === line.length) {
+ idx = 0;
+ line = rows[++rowIndex] + '\n';
+ continue;
+ }
+ idx++;
+ j++;
+ }
+ let lineHighlight: Highlights = {
+ contentIndex: rowIndex,
+ startIndex: idx,
+ };
+
+ j = 0;
+ while (line && j < markLength) {
+ if (idx === line.length) {
+ idx = 0;
+ line = rows[++rowIndex] + '\n';
+ normalized.push(lineHighlight);
+ lineHighlight = {
+ contentIndex: rowIndex,
+ startIndex: idx,
+ };
+ continue;
+ }
+ idx++;
+ j++;
+ }
+ lineHighlight.endIndex = idx;
+ normalized.push(lineHighlight);
+ }
+ return normalized;
+ }
+
+ /**
+ * If a group is an addition or a removal, break it down into smaller groups
+ * of that type using the MAX_GROUP_SIZE. If the group is a shared chunk
+ * or a delta it is returned as the single element of the result array.
+ */
+ _breakdownChunk(chunk: DiffContent): DiffContent[] {
+ let key: 'a' | 'b' | 'ab' | null = null;
+ if (chunk.a && !chunk.b) {
+ key = 'a';
+ } else if (chunk.b && !chunk.a) {
+ key = 'b';
+ } else if (chunk.ab) {
+ key = 'ab';
+ }
+
+ if (!key) {
+ return [chunk];
+ }
+
+ return this._breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
+ const subChunk: DiffContent = {};
+ subChunk[key!] = subChunkLines;
+ if (chunk.due_to_rebase) {
+ subChunk.due_to_rebase = true;
+ }
+ if (chunk.due_to_move) {
+ subChunk.due_to_move = true;
+ }
+ return subChunk;
+ });
+ }
+
+ /**
+ * Given an array and a size, return an array of arrays where no inner array
+ * is larger than that size, preserving the original order.
+ */
+ _breakdown<T>(array: T[], size: number): T[][] {
+ if (!array.length) {
+ return [];
+ }
+ if (array.length < size) {
+ return [array];
+ }
+
+ const head = array.slice(0, array.length - size);
+ const tail = array.slice(array.length - size);
+
+ return this._breakdown(head, size).concat([tail]);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-processor': GrDiffProcessor;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
index a8fba5d..ce7a3c4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.js
@@ -16,9 +16,10 @@
*/
import '../../../test/common-test-setup-karma.js';
+import 'lodash/lodash.js';
import './gr-diff-processor.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {GrDiffGroup} from '../gr-diff/gr-diff-group.js';
+import {GrDiffLineType, FILE} from '../gr-diff/gr-diff-line.js';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group.js';
const basicFixture = fixtureFromElement('gr-diff-processor');
@@ -76,14 +77,14 @@
assert.equal(groups.length, 4);
let group = groups[0];
- assert.equal(group.type, GrDiffGroup.Type.BOTH);
+ assert.equal(group.type, GrDiffGroupType.BOTH);
assert.equal(group.lines.length, 1);
assert.equal(group.lines[0].text, '');
- assert.equal(group.lines[0].beforeNumber, GrDiffLine.FILE);
- assert.equal(group.lines[0].afterNumber, GrDiffLine.FILE);
+ assert.equal(group.lines[0].beforeNumber, FILE);
+ assert.equal(group.lines[0].afterNumber, FILE);
group = groups[1];
- assert.equal(group.type, GrDiffGroup.Type.BOTH);
+ assert.equal(group.type, GrDiffGroupType.BOTH);
assert.equal(group.lines.length, 2);
function beforeNumberFn(l) { return l.beforeNumber; }
@@ -98,7 +99,7 @@
]);
group = groups[2];
- assert.equal(group.type, GrDiffGroup.Type.DELTA);
+ assert.equal(group.type, GrDiffGroupType.DELTA);
assert.equal(group.lines.length, 3);
assert.equal(group.adds.length, 1);
assert.equal(group.removes.length, 2);
@@ -113,7 +114,7 @@
]);
group = groups[3];
- assert.equal(group.type, GrDiffGroup.Type.BOTH);
+ assert.equal(group.type, GrDiffGroupType.BOTH);
assert.equal(group.lines.length, 3);
assert.deepEqual(group.lines.map(beforeNumberFn), [5, 6, 7]);
assert.deepEqual(group.lines.map(afterNumberFn), [4, 5, 6]);
@@ -133,11 +134,11 @@
return element.process(content).then(() => {
const groups = element.groups;
- assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
+ assert.equal(groups[0].type, GrDiffGroupType.BOTH);
assert.equal(groups[0].lines.length, 1);
assert.equal(groups[0].lines[0].text, '');
- assert.equal(groups[0].lines[0].beforeNumber, GrDiffLine.FILE);
- assert.equal(groups[0].lines[0].afterNumber, GrDiffLine.FILE);
+ assert.equal(groups[0].lines[0].beforeNumber, FILE);
+ assert.equal(groups[0].lines[0].afterNumber, FILE);
});
});
@@ -155,14 +156,14 @@
// group[0] is the file group
- assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
- assert.instanceOf(groups[1].lines[0].contextGroups[0], GrDiffGroup);
- assert.equal(groups[1].lines[0].contextGroups[0].lines.length, 90);
- for (const l of groups[1].lines[0].contextGroups[0].lines) {
+ assert.equal(groups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(groups[1].contextGroups[0], GrDiffGroup);
+ assert.equal(groups[1].contextGroups[0].lines.length, 90);
+ for (const l of groups[1].contextGroups[0].lines) {
assert.equal(l.text, 'all work and no play make jack a dull boy');
}
- assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
assert.equal(groups[2].lines.length, 10);
for (const l of groups[2].lines) {
assert.equal(l.text, 'all work and no play make jack a dull boy');
@@ -170,6 +171,56 @@
});
});
+ test('at the beginning with skip chunks', async () => {
+ element.context = 10;
+ const content = [
+ {ab: new Array(20)
+ .fill('all work and no play make jack a dull boy')},
+ {skip: 43900},
+ {ab: new Array(30)
+ .fill('some other content')},
+ {a: ['some other content']},
+ ];
+
+ await element.process(content);
+
+ const groups = element.groups;
+
+ // group[0] is the file group
+
+ const commonGroup = groups[1];
+
+ // Hidden context before
+ assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+ assert.equal(commonGroup.contextGroups[0].lines.length, 20);
+ for (const l of commonGroup.contextGroups[0].lines) {
+ assert.equal(l.text, 'all work and no play make jack a dull boy');
+ }
+
+ // Skipped group
+ const skipGroup = commonGroup.contextGroups[1];
+ assert.equal(skipGroup.skip, 43900);
+ const expectedRange = {
+ left: {start: 21, end: 43920},
+ right: {start: 21, end: 43920},
+ };
+ assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+ // Hidden context after
+ assert.equal(commonGroup.contextGroups[2].lines.length, 20);
+ for (const l of commonGroup.contextGroups[2].lines) {
+ assert.equal(l.text, 'some other content');
+ }
+
+ // Displayed lines
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
+ assert.equal(groups[2].lines.length, 10);
+ for (const l of groups[2].lines) {
+ assert.equal(l.text, 'some other content');
+ }
+ });
+
test('at the beginning, smaller than context', () => {
element.context = 10;
const content = [
@@ -183,7 +234,7 @@
// group[0] is the file group
- assert.equal(groups[1].type, GrDiffGroup.Type.BOTH);
+ assert.equal(groups[1].type, GrDiffGroupType.BOTH);
assert.equal(groups[1].lines.length, 5);
for (const l of groups[1].lines) {
assert.equal(l.text, 'all work and no play make jack a dull boy');
@@ -205,17 +256,17 @@
// group[0] is the file group
// group[1] is the "a" group
- assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
assert.equal(groups[2].lines.length, 10);
for (const l of groups[2].lines) {
assert.equal(
l.text, 'all work and no play make jill a dull girl');
}
- assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
- assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
- assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 90);
- for (const l of groups[3].lines[0].contextGroups[0].lines) {
+ assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+ assert.equal(groups[3].contextGroups[0].lines.length, 90);
+ for (const l of groups[3].contextGroups[0].lines) {
assert.equal(
l.text, 'all work and no play make jill a dull girl');
}
@@ -236,7 +287,7 @@
// group[0] is the file group
// group[1] is the "a" group
- assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
assert.equal(groups[2].lines.length, 5);
for (const l of groups[2].lines) {
assert.equal(
@@ -280,14 +331,14 @@
// The first three interleaved chunks are completely shown because
// they are part of the context (3 * 3 <= 10)
- assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
assert.equal(groups[2].lines.length, 3);
for (const l of groups[2].lines) {
assert.equal(
l.text, 'all work and no play make jill a dull girl');
}
- assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
+ assert.equal(groups[3].type, GrDiffGroupType.DELTA);
assert.equal(groups[3].lines.length, 6);
assert.equal(groups[3].adds.length, 3);
assert.equal(groups[3].removes.length, 3);
@@ -300,7 +351,7 @@
l.text, ' all work and no play make jill a dull girl');
}
- assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+ assert.equal(groups[4].type, GrDiffGroupType.BOTH);
assert.equal(groups[4].lines.length, 3);
for (const l of groups[4].lines) {
assert.equal(
@@ -309,7 +360,7 @@
// The next chunk is partially shown, so it results in two groups
- assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
+ assert.equal(groups[5].type, GrDiffGroupType.DELTA);
assert.equal(groups[5].lines.length, 2);
assert.equal(groups[5].adds.length, 1);
assert.equal(groups[5].removes.length, 1);
@@ -322,27 +373,27 @@
l.text, ' all work and no play make jill a dull girl');
}
- assert.equal(groups[6].type, GrDiffGroup.Type.CONTEXT_CONTROL);
- assert.equal(groups[6].lines[0].contextGroups.length, 2);
+ assert.equal(groups[6].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.equal(groups[6].contextGroups.length, 2);
- assert.equal(groups[6].lines[0].contextGroups[0].lines.length, 4);
- assert.equal(groups[6].lines[0].contextGroups[0].removes.length, 2);
- assert.equal(groups[6].lines[0].contextGroups[0].adds.length, 2);
- for (const l of groups[6].lines[0].contextGroups[0].removes) {
+ assert.equal(groups[6].contextGroups[0].lines.length, 4);
+ assert.equal(groups[6].contextGroups[0].removes.length, 2);
+ assert.equal(groups[6].contextGroups[0].adds.length, 2);
+ for (const l of groups[6].contextGroups[0].removes) {
assert.equal(
l.text, 'all work and no play make jill a dull girl');
}
- for (const l of groups[6].lines[0].contextGroups[0].adds) {
+ for (const l of groups[6].contextGroups[0].adds) {
assert.equal(
l.text, ' all work and no play make jill a dull girl');
}
// The final chunk is completely hidden
assert.equal(
- groups[6].lines[0].contextGroups[1].type,
- GrDiffGroup.Type.BOTH);
- assert.equal(groups[6].lines[0].contextGroups[1].lines.length, 3);
- for (const l of groups[6].lines[0].contextGroups[1].lines) {
+ groups[6].contextGroups[1].type,
+ GrDiffGroupType.BOTH);
+ assert.equal(groups[6].contextGroups[1].lines.length, 3);
+ for (const l of groups[6].contextGroups[1].lines) {
assert.equal(
l.text, 'all work and no play make jill a dull girl');
}
@@ -364,22 +415,22 @@
// group[0] is the file group
// group[1] is the "a" group
- assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
assert.equal(groups[2].lines.length, 10);
for (const l of groups[2].lines) {
assert.equal(
l.text, 'all work and no play make jill a dull girl');
}
- assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
- assert.instanceOf(groups[3].lines[0].contextGroups[0], GrDiffGroup);
- assert.equal(groups[3].lines[0].contextGroups[0].lines.length, 80);
- for (const l of groups[3].lines[0].contextGroups[0].lines) {
+ assert.equal(groups[3].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(groups[3].contextGroups[0], GrDiffGroup);
+ assert.equal(groups[3].contextGroups[0].lines.length, 80);
+ for (const l of groups[3].contextGroups[0].lines) {
assert.equal(
l.text, 'all work and no play make jill a dull girl');
}
- assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
+ assert.equal(groups[4].type, GrDiffGroupType.BOTH);
assert.equal(groups[4].lines.length, 10);
for (const l of groups[4].lines) {
assert.equal(
@@ -403,7 +454,7 @@
// group[0] is the file group
// group[1] is the "a" group
- assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
+ assert.equal(groups[2].type, GrDiffGroupType.BOTH);
assert.equal(groups[2].lines.length, 5);
for (const l of groups[2].lines) {
assert.equal(
@@ -413,6 +464,55 @@
});
});
+ test('in the middle with skip chunks', async () => {
+ element.context = 10;
+ const content = [
+ {a: ['all work and no play make andybons a dull boy']},
+ {ab: new Array(20)
+ .fill('all work and no play make jill a dull girl')},
+ {skip: 60},
+ {ab: new Array(20)
+ .fill('all work and no play make jill a dull girl')},
+ {a: ['all work and no play make andybons a dull boy']},
+ ];
+
+ await element.process(content);
+
+ const groups = element.groups;
+
+ // group[0] is the file group
+ // group[1] is the chunk with a
+ // group[2] is the displayed part of ab before
+
+ const commonGroup = groups[3];
+
+ // Hidden context before
+ assert.equal(commonGroup.type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.instanceOf(commonGroup.contextGroups[0], GrDiffGroup);
+ assert.equal(commonGroup.contextGroups[0].lines.length, 10);
+ for (const l of commonGroup.contextGroups[0].lines) {
+ assert.equal(
+ l.text, 'all work and no play make jill a dull girl');
+ }
+
+ // Skipped group
+ const skipGroup = commonGroup.contextGroups[1];
+ assert.equal(skipGroup.skip, 60);
+ const expectedRange = {
+ left: {start: 22, end: 81},
+ right: {start: 21, end: 80},
+ };
+ assert.deepEqual(skipGroup.lineRange, expectedRange);
+
+ // Hidden context after
+ assert.equal(commonGroup.contextGroups[2].lines.length, 10);
+ for (const l of commonGroup.contextGroups[2].lines) {
+ assert.equal(
+ l.text, 'all work and no play make jill a dull girl');
+ }
+ // group[4] is the displayed part of the second ab
+ });
+
test('break up common diff chunks', () => {
element.keyLocations = {
left: {1: true},
@@ -537,6 +637,15 @@
endIndex: 6,
},
]);
+ const lines = element._linesFromRows(
+ GrDiffGroupType.BOTH, content, 0, highlights);
+ assert.equal(lines.length, 3);
+ assert.isTrue(lines[0].hasIntralineInfo);
+ assert.equal(lines[0].highlights.length, 1);
+ assert.isTrue(lines[1].hasIntralineInfo);
+ assert.equal(lines[1].highlights.length, 2);
+ assert.isTrue(lines[2].hasIntralineInfo);
+ assert.equal(lines[2].highlights.length, 1);
content = [
' this._path = value.path;',
@@ -644,7 +753,7 @@
// Results in one, uncollapsed group with all rows.
assert.equal(result.groups.length, 1);
- assert.equal(result.groups[0].type, GrDiffGroup.Type.BOTH);
+ assert.equal(result.groups[0].type, GrDiffGroupType.BOTH);
assert.equal(result.groups[0].lines.length, rows.length);
// Line numbers are set correctly.
@@ -661,6 +770,51 @@
state.lineNums.right + rows.length);
});
+ test('WHOLE_FILE with skip chunks still get collapsed', () => {
+ element.context = WHOLE_FILE;
+ const lineNums = {left: 10, right: 100};
+ const state = {
+ lineNums,
+ chunkIndex: 1,
+ };
+ const skip = 10000;
+ const chunks = [
+ {a: ['foo']},
+ {skip},
+ {ab: rows},
+ {a: ['bar']},
+ ];
+ const result = element._processNext(state, chunks);
+ // Results in one, uncollapsed group with all rows.
+ assert.equal(result.groups.length, 1);
+ assert.equal(result.groups[0].type, GrDiffGroupType.CONTEXT_CONTROL);
+
+ // Skip and ab group are hidden in the same context control
+ assert.equal(result.groups[0].contextGroups.length, 2);
+ const [skippedGroup, abGroup] = result.groups[0].contextGroups;
+
+ // Line numbers are set correctly.
+ assert.deepEqual(
+ skippedGroup.lineRange,
+ {
+ left: {start: lineNums.left + 1, end: lineNums.left + skip},
+ right: {start: lineNums.right + 1, end: lineNums.right + skip},
+ });
+
+ assert.deepEqual(
+ abGroup.lineRange,
+ {
+ left: {
+ start: lineNums.left + skip + 1,
+ end: lineNums.left + skip + rows.length,
+ },
+ right: {
+ start: lineNums.right + skip + 1,
+ end: lineNums.right + skip + rows.length,
+ },
+ });
+ });
+
test('with context', () => {
element.context = 10;
const state = {
@@ -680,11 +834,10 @@
// The first and last are uncollapsed context, whereas the middle has
// a single context-control line.
assert.equal(result.groups[0].lines.length, element.context);
- assert.equal(result.groups[1].lines.length, 1);
assert.equal(result.groups[2].lines.length, element.context);
// The collapsed group has the hidden lines as its context group.
- assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
+ assert.equal(result.groups[1].contextGroups[0].lines.length,
expectedCollapseSize);
});
@@ -705,11 +858,10 @@
assert.equal(result.groups.length, 2, 'Results in two groups');
// Only the first group is collapsed.
- assert.equal(result.groups[0].lines.length, 1);
assert.equal(result.groups[1].lines.length, element.context);
// The collapsed group has the hidden lines as its context group.
- assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
+ assert.equal(result.groups[0].contextGroups[0].lines.length,
expectedCollapseSize);
});
@@ -778,9 +930,8 @@
// 2) The context before the key location.
// The key location is not processed in this call to _processNext
assert.equal(result.groups.length, 2);
- assert.equal(result.groups[0].lines.length, 1);
// The collapsed group has the hidden lines as its context group.
- assert.equal(result.groups[0].lines[0].contextGroups[0].lines.length,
+ assert.equal(result.groups[0].contextGroups[0].lines.length,
rows.length - element.context);
assert.equal(result.groups[1].lines.length, element.context);
});
@@ -807,9 +958,8 @@
// key location.
assert.equal(result.groups.length, 2);
assert.equal(result.groups[0].lines.length, element.context);
- assert.equal(result.groups[1].lines.length, 1);
// The collapsed group has the hidden lines as its context group.
- assert.equal(result.groups[1].lines[0].contextGroups[0].lines.length,
+ assert.equal(result.groups[1].contextGroups[0].lines.length,
rows.length - element.context);
});
});
@@ -824,22 +974,24 @@
test('_linesFromRows', () => {
const startLineNum = 10;
- let result = element._linesFromRows(GrDiffLine.Type.ADD, rows,
+ let result = element._linesFromRows(GrDiffLineType.ADD, rows,
startLineNum + 1);
assert.equal(result.length, rows.length);
- assert.equal(result[0].type, GrDiffLine.Type.ADD);
+ assert.equal(result[0].type, GrDiffLineType.ADD);
+ assert.notOk(result[0].hasIntralineInfo);
assert.equal(result[0].afterNumber, startLineNum + 1);
assert.notOk(result[0].beforeNumber);
assert.equal(result[result.length - 1].afterNumber,
startLineNum + rows.length);
assert.notOk(result[result.length - 1].beforeNumber);
- result = element._linesFromRows(GrDiffLine.Type.REMOVE, rows,
+ result = element._linesFromRows(GrDiffLineType.REMOVE, rows,
startLineNum + 1);
assert.equal(result.length, rows.length);
- assert.equal(result[0].type, GrDiffLine.Type.REMOVE);
+ assert.equal(result[0].type, GrDiffLineType.REMOVE);
+ assert.notOk(result[0].hasIntralineInfo);
assert.equal(result[0].beforeNumber, startLineNum + 1);
assert.notOk(result[0].afterNumber);
assert.equal(result[result.length - 1].beforeNumber,
@@ -867,6 +1019,16 @@
}
});
+ test('_breakdownChunk keeps due_to_move for broken down additions',
+ () => {
+ sinon.spy(element, '_breakdown');
+ const chunk = {b: ['blah', 'blah', 'blah'], due_to_move: true};
+ const result = element._breakdownChunk(chunk);
+ for (const subResult of result) {
+ assert.isTrue(subResult.due_to_move);
+ }
+ });
+
test('_breakdown common case', () => {
const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
.split(' ');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
deleted file mode 100644
index 6d9060f..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ /dev/null
@@ -1,370 +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 '../../../styles/shared-styles.js';
-import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-diff-selection_html.js';
-import {GrRangeNormalizer} from '../gr-diff-highlight/gr-range-normalizer.js';
-import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util.js';
-
-/**
- * Possible CSS classes indicating the state of selection. Dynamically added/
- * removed based on where the user clicks within the diff.
- */
-const SelectionClass = {
- COMMENT: 'selected-comment',
- LEFT: 'selected-left',
- RIGHT: 'selected-right',
- BLAME: 'selected-blame',
-};
-
-const getNewCache = () => { return {left: null, right: null}; };
-
-/**
- * @extends PolymerElement
- */
-class GrDiffSelection extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff-selection'; }
-
- static get properties() {
- return {
- diff: Object,
- /** @type {?Object} */
- _cachedDiffBuilder: Object,
- _linesCache: {
- type: Object,
- value: getNewCache(),
- },
- };
- }
-
- static get observers() {
- return [
- '_diffChanged(diff)',
- ];
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('copy',
- e => this._handleCopy(e));
- addListener(this, 'down',
- e => this._handleDown(e));
- }
-
- /** @override */
- attached() {
- super.attached();
- this.classList.add(SelectionClass.RIGHT);
- }
-
- get diffBuilder() {
- if (!this._cachedDiffBuilder) {
- this._cachedDiffBuilder =
- dom(this).querySelector('gr-diff-builder');
- }
- return this._cachedDiffBuilder;
- }
-
- _diffChanged() {
- this._linesCache = getNewCache();
- }
-
- _handleDownOnRangeComment(node) {
- if (node &&
- node.nodeName &&
- node.nodeName.toLowerCase() === 'gr-comment-thread') {
- this._setClasses([
- SelectionClass.COMMENT,
- node.commentSide === 'left' ?
- SelectionClass.LEFT :
- SelectionClass.RIGHT,
- ]);
- return true;
- }
- return false;
- }
-
- _handleDown(e) {
- // Handle the down event on comment thread in Polymer 2
- const handled = this._handleDownOnRangeComment(e.target);
- if (handled) return;
-
- const lineEl = this.diffBuilder.getLineElByChild(e.target);
- const blameSelected = this._elementDescendedFromClass(e.target, 'blame');
- if (!lineEl && !blameSelected) { return; }
-
- const targetClasses = [];
-
- if (blameSelected) {
- targetClasses.push(SelectionClass.BLAME);
- } else {
- const commentSelected =
- this._elementDescendedFromClass(e.target, 'gr-comment');
- const side = this.diffBuilder.getSideByLineEl(lineEl);
-
- targetClasses.push(side === 'left' ?
- SelectionClass.LEFT :
- SelectionClass.RIGHT);
-
- if (commentSelected) {
- targetClasses.push(SelectionClass.COMMENT);
- }
- }
-
- this._setClasses(targetClasses);
- }
-
- /**
- * Set the provided list of classes on the element, to the exclusion of all
- * other SelectionClass values.
- *
- * @param {!Array<!string>} targetClasses
- */
- _setClasses(targetClasses) {
- // Remove any selection classes that do not belong.
- for (const key in SelectionClass) {
- if (SelectionClass.hasOwnProperty(key)) {
- const className = SelectionClass[key];
- if (!targetClasses.includes(className)) {
- this.classList.remove(SelectionClass[key]);
- }
- }
- }
- // Add new selection classes iff they are not already present.
- for (const _class of targetClasses) {
- if (!this.classList.contains(_class)) {
- this.classList.add(_class);
- }
- }
- }
-
- _getCopyEventTarget(e) {
- return dom(e).rootTarget;
- }
-
- /**
- * Utility function to determine whether an element is a descendant of
- * another element with the particular className.
- *
- * @param {!Element} element
- * @param {!string} className
- * @return {boolean}
- */
- _elementDescendedFromClass(element, className) {
- return descendedFromClass(element, className,
- this.diffBuilder.diffElement);
- }
-
- _handleCopy(e) {
- let commentSelected = false;
- const target = this._getCopyEventTarget(e);
- if (target.type === 'textarea') { return; }
- if (!this._elementDescendedFromClass(target, 'diff-row')) { return; }
- if (this.classList.contains(SelectionClass.COMMENT)) {
- commentSelected = true;
- }
- const lineEl = this.diffBuilder.getLineElByChild(target);
- if (!lineEl) {
- return;
- }
- const side = this.diffBuilder.getSideByLineEl(lineEl);
- const text = this._getSelectedText(side, commentSelected);
- if (text) {
- e.clipboardData.setData('Text', text);
- e.preventDefault();
- }
- }
-
- _getSelection() {
- const diffHosts = querySelectorAll(document.body, 'gr-diff');
- if (!diffHosts.length) return window.getSelection();
-
- const curDiffHost = diffHosts.find(diffHost => {
- if (!diffHost || !diffHost.shadowRoot) return false;
- const selection = diffHost.shadowRoot.getSelection();
- // Pick the one with valid selection:
- // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type
- return selection && selection.type !== 'None';
- });
-
- return curDiffHost ?
- curDiffHost.shadowRoot.getSelection(): window.getSelection();
- }
-
- /**
- * Get the text of the current selection. If commentSelected is
- * true, it returns only the text of comments within the selection.
- * Otherwise it returns the text of the selected diff region.
- *
- * @param {!string} side The side that is selected.
- * @param {boolean} commentSelected Whether or not a comment is selected.
- * @return {string} The selected text.
- */
- _getSelectedText(side, commentSelected) {
- const sel = this._getSelection();
- if (sel.rangeCount != 1) {
- return ''; // No multi-select support yet.
- }
- if (commentSelected) {
- return this._getCommentLines(sel, side);
- }
- const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
- const startLineEl =
- this.diffBuilder.getLineElByChild(range.startContainer);
- const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
- // Happens when triple click in side-by-side mode with other side empty.
- const endsAtOtherEmptySide = !endLineEl &&
- range.endOffset === 0 &&
- range.endContainer.nodeName === 'TD' &&
- (range.endContainer.classList.contains('left') ||
- range.endContainer.classList.contains('right'));
- const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
- let endLineNum;
- if (endsAtOtherEmptySide) {
- endLineNum = startLineNum + 1;
- } else if (endLineEl) {
- endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
- }
-
- return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
- range.endOffset, side);
- }
-
- /**
- * Query the diff object for the selected lines.
- *
- * @param {number} startLineNum
- * @param {number} startOffset
- * @param {number|undefined} endLineNum Use undefined to get the range
- * extending to the end of the file.
- * @param {number} endOffset
- * @param {!string} side The side that is currently selected.
- * @return {string} The selected diff text.
- */
- _getRangeFromDiff(startLineNum, startOffset, endLineNum, endOffset, side) {
- const lines =
- this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
- if (lines.length) {
- lines[lines.length - 1] = lines[lines.length - 1]
- .substring(0, endOffset);
- lines[0] = lines[0].substring(startOffset);
- }
- return lines.join('\n');
- }
-
- /**
- * Query the diff object for the lines from a particular side.
- *
- * @param {!string} side The side that is currently selected.
- * @return {!Array<string>} An array of strings indexed by line number.
- */
- _getDiffLines(side) {
- if (this._linesCache[side]) {
- return this._linesCache[side];
- }
- let lines = [];
- const key = side === 'left' ? 'a' : 'b';
- for (const chunk of this.diff.content) {
- if (chunk.ab) {
- lines = lines.concat(chunk.ab);
- } else if (chunk[key]) {
- lines = lines.concat(chunk[key]);
- }
- }
- this._linesCache[side] = lines;
- return lines;
- }
-
- /**
- * Query the diffElement for comments and check whether they lie inside the
- * selection range.
- *
- * @param {!Selection} sel The selection of the window.
- * @param {!string} side The side that is currently selected.
- * @return {string} The selected comment text.
- */
- _getCommentLines(sel, side) {
- const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
- const content = [];
- // Query the diffElement for comments.
- const messages = this.diffBuilder.diffElement.querySelectorAll(
- `.side-by-side [data-side="${side
- }"] .message *, .unified .message *`);
-
- for (let i = 0; i < messages.length; i++) {
- const el = messages[i];
- // Check if the comment element exists inside the selection.
- if (sel.containsNode(el, true)) {
- // Padded elements require newlines for accurate spacing.
- if (el.parentElement.id === 'container' ||
- el.parentElement.nodeName === 'BLOCKQUOTE') {
- if (content.length && content[content.length - 1] !== '') {
- content.push('');
- }
- }
-
- if (el.id === 'output' &&
- !this._elementDescendedFromClass(el, 'collapsed')) {
- content.push(this._getTextContentForRange(el, sel, range));
- }
- }
- }
-
- return content.join('\n');
- }
-
- /**
- * Given a DOM node, a selection, and a selection range, recursively get all
- * of the text content within that selection.
- * Using a domNode that isn't in the selection returns an empty string.
- *
- * @param {!Node} domNode The root DOM node.
- * @param {!Selection} sel The selection.
- * @param {!Range} range The normalized selection range.
- * @return {string} The text within the selection.
- */
- _getTextContentForRange(domNode, sel, range) {
- if (!sel.containsNode(domNode, true)) { return ''; }
-
- let text = '';
- if (domNode instanceof Text) {
- text = domNode.textContent;
- if (domNode === range.endContainer) {
- text = text.substring(0, range.endOffset);
- }
- if (domNode === range.startContainer) {
- text = text.substring(range.startOffset);
- }
- } else {
- for (const childNode of domNode.childNodes) {
- text += this._getTextContentForRange(childNode, sel, range);
- }
- }
- return text;
- }
-}
-
-customElements.define(GrDiffSelection.is, GrDiffSelection);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
new file mode 100644
index 0000000..b75ba8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.ts
@@ -0,0 +1,388 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import {addListener} from '@polymer/polymer/lib/utils/gestures';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-diff-selection_html';
+import {
+ normalize,
+ NormalizedRange,
+} from '../gr-diff-highlight/gr-range-normalizer';
+import {descendedFromClass, querySelectorAll} from '../../../utils/dom-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {DiffInfo} from '../../../types/common';
+import {Side} from '../../../constants/constants';
+import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+
+/**
+ * Possible CSS classes indicating the state of selection. Dynamically added/
+ * removed based on where the user clicks within the diff.
+ */
+const SelectionClass = {
+ COMMENT: 'selected-comment',
+ LEFT: 'selected-left',
+ RIGHT: 'selected-right',
+ BLAME: 'selected-blame',
+};
+
+interface LinesCache {
+ left: string[] | null;
+ right: string[] | null;
+}
+
+function getNewCache(): LinesCache {
+ return {left: null, right: null};
+}
+
+@customElement('gr-diff-selection')
+export class GrDiffSelection extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ diff?: DiffInfo;
+
+ @property({type: Object})
+ _cachedDiffBuilder?: GrDiffBuilderElement;
+
+ @property({type: Object})
+ _linesCache: LinesCache = {left: null, right: null};
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('copy', e => this._handleCopy(e));
+ addListener(this, 'down', e => this._handleDown(e));
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.classList.add(SelectionClass.RIGHT);
+ }
+
+ get diffBuilder() {
+ if (!this._cachedDiffBuilder) {
+ this._cachedDiffBuilder = this.querySelector(
+ 'gr-diff-builder'
+ ) as GrDiffBuilderElement;
+ }
+ return this._cachedDiffBuilder;
+ }
+
+ @observe('diff')
+ _diffChanged() {
+ this._linesCache = getNewCache();
+ }
+
+ _handleDownOnRangeComment(node: Element) {
+ if (node?.nodeName?.toLowerCase() === 'gr-comment-thread') {
+ this._setClasses([
+ SelectionClass.COMMENT,
+ node.getAttribute('comment-side') === Side.LEFT
+ ? SelectionClass.LEFT
+ : SelectionClass.RIGHT,
+ ]);
+ return true;
+ }
+ return false;
+ }
+
+ _handleDown(e: Event) {
+ const target = e.target;
+ if (!(target instanceof Element)) return;
+ // Handle the down event on comment thread in Polymer 2
+ const handled = this._handleDownOnRangeComment(target);
+ if (handled) return;
+ const lineEl = this.diffBuilder.getLineElByChild(target);
+ const blameSelected = this._elementDescendedFromClass(target, 'blame');
+ if (!lineEl && !blameSelected) {
+ return;
+ }
+
+ const targetClasses = [];
+
+ if (blameSelected) {
+ targetClasses.push(SelectionClass.BLAME);
+ } else if (lineEl) {
+ const commentSelected = this._elementDescendedFromClass(
+ target,
+ 'gr-comment'
+ );
+ const side = this.diffBuilder.getSideByLineEl(lineEl);
+
+ targetClasses.push(
+ side === 'left' ? SelectionClass.LEFT : SelectionClass.RIGHT
+ );
+
+ if (commentSelected) {
+ targetClasses.push(SelectionClass.COMMENT);
+ }
+ }
+
+ this._setClasses(targetClasses);
+ }
+
+ /**
+ * Set the provided list of classes on the element, to the exclusion of all
+ * other SelectionClass values.
+ */
+ _setClasses(targetClasses: string[]) {
+ // Remove any selection classes that do not belong.
+ for (const className of Object.values(SelectionClass)) {
+ if (!targetClasses.includes(className)) {
+ this.classList.remove(className);
+ }
+ }
+ // Add new selection classes iff they are not already present.
+ for (const _class of targetClasses) {
+ if (!this.classList.contains(_class)) {
+ this.classList.add(_class);
+ }
+ }
+ }
+
+ _getCopyEventTarget(e: Event) {
+ return (dom(e) as EventApi).rootTarget;
+ }
+
+ /**
+ * Utility function to determine whether an element is a descendant of
+ * another element with the particular className.
+ */
+ _elementDescendedFromClass(element: Element, className: string) {
+ return descendedFromClass(element, className, this.diffBuilder.diffElement);
+ }
+
+ _handleCopy(e: ClipboardEvent) {
+ let commentSelected = false;
+ const target = this._getCopyEventTarget(e);
+ if (!(target instanceof Element)) return;
+ if (target instanceof HTMLTextAreaElement) return;
+ if (!this._elementDescendedFromClass(target, 'diff-row')) return;
+ if (this.classList.contains(SelectionClass.COMMENT)) {
+ commentSelected = true;
+ }
+ const lineEl = this.diffBuilder.getLineElByChild(target);
+ if (!lineEl) return;
+ const side = this.diffBuilder.getSideByLineEl(lineEl);
+ const text = this._getSelectedText(side, commentSelected);
+ if (text && e.clipboardData) {
+ e.clipboardData.setData('Text', text);
+ e.preventDefault();
+ }
+ }
+
+ _getSelection() {
+ const diffHosts = querySelectorAll(document.body, 'gr-diff');
+ if (!diffHosts.length) return window.getSelection();
+
+ const curDiffHost = diffHosts.find(diffHost => {
+ if (!diffHost || !diffHost.shadowRoot) return false;
+ const selection = diffHost.shadowRoot.getSelection();
+ // Pick the one with valid selection:
+ // https://developer.mozilla.org/en-US/docs/Web/API/Selection/type
+ return selection && selection.type !== 'None';
+ });
+
+ return curDiffHost
+ ? curDiffHost.shadowRoot!.getSelection()
+ : window.getSelection();
+ }
+
+ /**
+ * Get the text of the current selection. If commentSelected is
+ * true, it returns only the text of comments within the selection.
+ * Otherwise it returns the text of the selected diff region.
+ *
+ * @param side The side that is selected.
+ * @param commentSelected Whether or not a comment is selected.
+ * @return The selected text.
+ */
+ _getSelectedText(side: Side, commentSelected: boolean) {
+ const sel = this._getSelection();
+ if (!sel || sel.rangeCount !== 1) {
+ return ''; // No multi-select support yet.
+ }
+ if (commentSelected) {
+ return this._getCommentLines(sel, side);
+ }
+ const range = normalize(sel.getRangeAt(0));
+ const startLineEl = this.diffBuilder.getLineElByChild(range.startContainer);
+ if (!startLineEl) return;
+ const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+ // Happens when triple click in side-by-side mode with other side empty.
+ const endsAtOtherEmptySide =
+ !endLineEl &&
+ range.endOffset === 0 &&
+ range.endContainer.nodeName === 'TD' &&
+ range.endContainer instanceof HTMLTableCellElement &&
+ (range.endContainer.classList.contains('left') ||
+ range.endContainer.classList.contains('right'));
+ const startLineDataValue = startLineEl.getAttribute('data-value');
+ if (!startLineDataValue) return;
+ const startLineNum = Number(startLineDataValue);
+ let endLineNum;
+ if (endsAtOtherEmptySide) {
+ endLineNum = startLineNum + 1;
+ } else if (endLineEl) {
+ const endLineDataValue = endLineEl.getAttribute('data-value');
+ if (endLineDataValue) endLineNum = Number(endLineDataValue);
+ }
+
+ return this._getRangeFromDiff(
+ startLineNum,
+ range.startOffset,
+ endLineNum,
+ range.endOffset,
+ side
+ );
+ }
+
+ /**
+ * Query the diff object for the selected lines.
+ */
+ _getRangeFromDiff(
+ startLineNum: number,
+ startOffset: number,
+ endLineNum: number | undefined,
+ endOffset: number,
+ side: Side
+ ) {
+ const lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+ if (lines.length) {
+ lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
+ lines[0] = lines[0].substring(startOffset);
+ }
+ return lines.join('\n');
+ }
+
+ /**
+ * Query the diff object for the lines from a particular side.
+ *
+ * @param side The side that is currently selected.
+ * @return An array of strings indexed by line number.
+ */
+ _getDiffLines(side: Side): string[] {
+ if (this._linesCache[side]) {
+ return this._linesCache[side]!;
+ }
+ if (!this.diff) return [];
+ let lines: string[] = [];
+ for (const chunk of this.diff.content) {
+ if (chunk.ab) {
+ lines = lines.concat(chunk.ab);
+ } else if (side === Side.LEFT && chunk.a) {
+ lines = lines.concat(chunk.a);
+ } else if (side === Side.RIGHT && chunk.b) {
+ lines = lines.concat(chunk.b);
+ }
+ }
+ this._linesCache[side] = lines;
+ return lines;
+ }
+
+ /**
+ * Query the diffElement for comments and check whether they lie inside the
+ * selection range.
+ *
+ * @param sel The selection of the window.
+ * @param side The side that is currently selected.
+ * @return The selected comment text.
+ */
+ _getCommentLines(sel: Selection, side: Side) {
+ const range = normalize(sel.getRangeAt(0));
+ const content = [];
+ // Query the diffElement for comments.
+ const messages = this.diffBuilder.diffElement.querySelectorAll(
+ `.side-by-side [data-side="${side}"] .message *, .unified .message *`
+ );
+
+ for (let i = 0; i < messages.length; i++) {
+ const el = messages[i];
+ // Check if the comment element exists inside the selection.
+ if (sel.containsNode(el, true)) {
+ // Padded elements require newlines for accurate spacing.
+ if (
+ el.parentElement!.id === 'container' ||
+ el.parentElement!.nodeName === 'BLOCKQUOTE'
+ ) {
+ if (content.length && content[content.length - 1] !== '') {
+ content.push('');
+ }
+ }
+
+ if (
+ el.id === 'output' &&
+ !this._elementDescendedFromClass(el, 'collapsed')
+ ) {
+ content.push(this._getTextContentForRange(el, sel, range));
+ }
+ }
+ }
+
+ return content.join('\n');
+ }
+
+ /**
+ * Given a DOM node, a selection, and a selection range, recursively get all
+ * of the text content within that selection.
+ * Using a domNode that isn't in the selection returns an empty string.
+ *
+ * @param domNode The root DOM node.
+ * @param sel The selection.
+ * @param range The normalized selection range.
+ * @return The text within the selection.
+ */
+ _getTextContentForRange(
+ domNode: Node,
+ sel: Selection,
+ range: NormalizedRange
+ ) {
+ if (!sel.containsNode(domNode, true)) {
+ return '';
+ }
+
+ let text = '';
+ if (domNode instanceof Text) {
+ text = domNode.textContent || '';
+ if (domNode === range.endContainer) {
+ text = text.substring(0, range.endOffset);
+ }
+ if (domNode === range.startContainer) {
+ text = text.substring(range.startOffset);
+ }
+ } else {
+ for (const childNode of domNode.childNodes) {
+ text += this._getTextContentForRange(childNode, sel, range);
+ }
+ }
+ return text;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-selection': GrDiffSelection;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
index c37ac93..5c9fe3f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
@@ -382,7 +382,7 @@
test('cache is reset when diff changes', () => {
element._linesCache = {left: 'test', right: 'test'};
element.diff = {};
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(element._linesCache, {left: null, right: null});
});
});
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
deleted file mode 100644
index 854ac63..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ /dev/null
@@ -1,1464 +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 '@polymer/iron-dropdown/iron-dropdown.js';
-import '@polymer/iron-input/iron-input.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
-import '../../shared/gr-fixed-panel/gr-fixed-panel.js';
-import '../../shared/gr-icons/gr-icons.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../../shared/revision-info/revision-info.js';
-import '../gr-comment-api/gr-comment-api.js';
-import '../gr-diff-cursor/gr-diff-cursor.js';
-import '../gr-apply-fix-dialog/gr-apply-fix-dialog.js';
-import '../gr-diff-host/gr-diff-host.js';
-import '../gr-diff-mode-selector/gr-diff-mode-selector.js';
-import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog.js';
-import '../gr-patch-range-select/gr-patch-range-select.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-diff-view_html.js';
-import {KeyboardShortcutMixin, Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {RevisionInfo} from '../../shared/revision-info/revision-info.js';
-import {appContext} from '../../../services/app-context.js';
-import {
- computeAllPatchSets,
- computeLatestPatchNum,
- patchNumEquals,
- SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-import {
- addUnmodifiedFiles, computeDisplayPath, computeTruncatedPath,
- isMagicPath, specialFilePathCompare,
-} from '../../../utils/path-list-util.js';
-import {changeBaseURL, changeIsOpen} from '../../../utils/change-util.js';
-
-const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
-const MSG_LOADING_BLAME = 'Loading blame...';
-const MSG_LOADED_BLAME = 'Blame loaded';
-
-const PARENT = 'PARENT';
-
-const DiffSides = {
- LEFT: 'left',
- RIGHT: 'right',
-};
-
-const DiffViewMode = {
- SIDE_BY_SIDE: 'SIDE_BY_SIDE',
- UNIFIED: 'UNIFIED_DIFF',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrDiffView extends KeyboardShortcutMixin(
- GestureEventListeners(LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
- * Fired when user tries to navigate away while comments are pending save.
- *
- * @event show-alert
- */
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- /**
- * @type {{ diffMode: (string|undefined) }}
- */
- changeViewState: {
- type: Object,
- notify: true,
- value() { return {}; },
- observer: '_changeViewStateChanged',
- },
- disableDiffPrefs: {
- type: Boolean,
- value: false,
- },
- _diffPrefsDisabled: {
- type: Boolean,
- computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
- },
- /** @type {?} */
- _patchRange: Object,
- /** @type {?} */
- _commitRange: Object,
- /**
- * @type {{
- * subject: string,
- * project: string,
- * revisions: string,
- * }}
- */
- _change: Object,
- /** @type {?} */
- _changeComments: Object,
- _changeNum: String,
- /**
- * This is a DiffInfo object.
- * This is retrieved and owned by a child component.
- */
- _diff: Object,
- // An array specifically formatted to be used in a gr-dropdown-list
- // element for selected a file to view.
- _formattedFiles: {
- type: Array,
- computed: '_formatFilesForDropdown(_files, ' +
- '_patchRange.patchNum, _changeComments)',
- },
- // An sorted array of files, as returned by the rest API.
- _fileList: {
- type: Array,
- computed: '_getSortedFileList(_files)',
- },
- /**
- * Contains information about files as returned by the rest API.
- *
- * @type {{ sortedFileList: Array<string>, changeFilesByPath: Object }}
- */
- _files: {
- type: Object,
- value() { return {sortedFileList: [], changeFilesByPath: {}}; },
- },
-
- /** @type {Gerrit.FileRange} */
- _file: {
- type: Object,
- computed: '_getCurrentFile(_files, _path)',
- },
-
- _path: {
- type: String,
- observer: '_pathChanged',
- },
- _fileNum: {
- type: Number,
- computed: '_computeFileNum(_path, _formattedFiles)',
- },
- _loggedIn: {
- type: Boolean,
- value: false,
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- _prefs: Object,
- _localPrefs: Object,
- _projectConfig: Object,
- _userPrefs: Object,
- _diffMode: {
- type: String,
- computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
- },
- _isImageDiff: Boolean,
- _filesWeblinks: Object,
-
- /**
- * Map of paths in the current change and patch range that have comments
- * or drafts or robot comments.
- */
- _commentMap: Object,
-
- _commentsForDiff: Object,
-
- /**
- * Object to contain the path of the next and previous file in the current
- * change and patch range that has comments.
- */
- _commentSkips: {
- type: Object,
- computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
- },
- _panelFloatingDisabled: {
- type: Boolean,
- value: () => window.PANEL_FLOATING_DISABLED,
- },
- _editMode: {
- type: Boolean,
- computed: '_computeEditMode(_patchRange.*)',
- },
- _isBlameLoaded: Boolean,
- _isBlameLoading: {
- type: Boolean,
- value: false,
- },
- _allPatchSets: {
- type: Array,
- computed: '_computeAllPatchSets(_change, _change.revisions.*)',
- },
- _revisionInfo: {
- type: Object,
- computed: '_getRevisionInfo(_change)',
- },
- _reviewedFiles: {
- type: Object,
- value: () => new Set(),
- },
-
- /**
- * gr-diff-view has gr-fixed-panel on top. The panel can
- * intersect a main element and partially hides a content of
- * the main element. To correctly calculates visibility of an
- * element, the cursor must know how much height occupied by a fixed
- * panel.
- * The scrollTopMargin defines margin occupied by fixed panel.
- */
- _scrollTopMargin: {
- type: Number,
- value: 0,
- },
- };
- }
-
- static get observers() {
- return [
- '_getProjectConfig(_change.project)',
- '_getFiles(_changeNum, _patchRange.*, _changeComments)',
- '_setReviewedObserver(_loggedIn, params.*, _prefs)',
- '_recomputeComments(_files.changeFilesByPath,' +
- '_path, _patchRange, _projectConfig)',
- ];
- }
-
- get keyBindings() {
- return {
- esc: '_handleEscKey',
- };
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.LEFT_PANE]: '_handleLeftPane',
- [Shortcut.RIGHT_PANE]: '_handleRightPane',
- [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
- [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
- [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
- [Shortcut.NEXT_FILE_WITH_COMMENTS]:
- '_handleNextLineOrFileWithComments',
- [Shortcut.PREV_FILE_WITH_COMMENTS]:
- '_handlePrevLineOrFileWithComments',
- [Shortcut.NEW_COMMENT]: '_handleNewComment',
- [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
- [Shortcut.NEXT_FILE]: '_handleNextFile',
- [Shortcut.PREV_FILE]: '_handlePrevFile',
- [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
- [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
- [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
- [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
- [Shortcut.OPEN_REPLY_DIALOG]:
- '_handleOpenReplyDialogOrToggleLeftPane',
- [Shortcut.TOGGLE_LEFT_PANE]:
- '_handleOpenReplyDialogOrToggleLeftPane',
- [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
- [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
- [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
- [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
- [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
- [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
- [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
- [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
- '_handleToggleHideAllCommentThreads',
- [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
- [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
- [Shortcut.DIFF_RIGHT_AGAINST_LATEST]:
- '_handleDiffRightAgainstLatest',
- [Shortcut.DIFF_BASE_AGAINST_LATEST]:
- '_handleDiffBaseAgainstLatest',
-
- // Final two are actually handled by gr-comment-thread.
- [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
- [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- });
-
- this.addEventListener('open-fix-preview',
- this._onOpenFixPreview.bind(this));
- this.$.cursor.push('diffs', this.$.diffHost);
-
- const onRender = () => {
- this.$.diffHost.removeEventListener('render', onRender);
- this.$.cursor.reInitCursor();
- };
- this.$.diffHost.addEventListener('render', onRender);
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _getProjectConfig(project) {
- return this.$.restAPI.getProjectConfig(project).then(
- config => {
- this._projectConfig = config;
- });
- }
-
- _getChangeDetail(changeNum) {
- return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
- this._change = change;
- return change;
- });
- }
-
- _getChangeEdit(changeNum) {
- return this.$.restAPI.getChangeEdit(this._changeNum);
- }
-
- _getSortedFileList(files) {
- return files.sortedFileList;
- }
-
- /**
- * @param {!Object} files
- * @param {string} path
- * @returns {!Gerrit.FileRange}
- */
- _getCurrentFile(files, path) {
- if ([files, path].includes(undefined)) return;
- const fileInfo = files.changeFilesByPath[path];
- const fileRange = {path};
- if (fileInfo && fileInfo.old_path) {
- fileRange.basePath = fileInfo.old_path;
- }
- return fileRange;
- }
-
- _getFiles(changeNum, patchRangeRecord, changeComments) {
- // Polymer 2: check for undefined
- if ([changeNum, patchRangeRecord, patchRangeRecord.base, changeComments]
- .some(arg => arg === undefined)) {
- return Promise.resolve();
- }
-
- const patchRange = patchRangeRecord.base;
- return this.$.restAPI.getChangeFiles(
- changeNum, patchRange).then(changeFiles => {
- if (!changeFiles) return;
- const commentedPaths = changeComments.getPaths(patchRange);
- const files = Object.assign({}, changeFiles);
- addUnmodifiedFiles(files, commentedPaths);
- this._files = {
- sortedFileList: Object.keys(files).sort(specialFilePathCompare),
- changeFilesByPath: files,
- };
- });
- }
-
- _getDiffPreferences() {
- return this.$.restAPI.getDiffPreferences().then(prefs => {
- this._prefs = prefs;
- });
- }
-
- _getPreferences() {
- return this.$.restAPI.getPreferences();
- }
-
- _getWindowWidth() {
- return window.innerWidth;
- }
-
- _handleReviewedChange(e) {
- this._setReviewed(dom(e).rootTarget.checked);
- }
-
- _setReviewed(reviewed) {
- if (this._editMode) { return; }
- this.$.reviewed.checked = reviewed;
- this._saveReviewedState(reviewed).catch(err => {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_REVIEW_STATUS},
- composed: true, bubbles: true,
- }));
- throw err;
- });
- }
-
- _saveReviewedState(reviewed) {
- return this.$.restAPI.saveFileReviewed(this._changeNum,
- this._patchRange.patchNum, this._path, reviewed);
- }
-
- _handleToggleFileReviewed(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._setReviewed(!this.$.reviewed.checked);
- }
-
- _handleEscKey(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.diffHost.displayLine = false;
- }
-
- _handleLeftPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this.$.cursor.moveLeft();
- }
-
- _handleRightPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this.$.cursor.moveRight();
- }
-
- _handlePrevLineOrFileWithComments(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (e.detail.keyboardEvent.shiftKey &&
- e.detail.keyboardEvent.keyCode === 75) { // 'K'
- this._moveToPreviousFileWithComment();
- return;
- }
- if (this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.diffHost.displayLine = true;
- this.$.cursor.moveUp();
- }
-
- _handleVisibleLine(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- this.$.cursor.moveToVisibleArea();
- }
-
- _onOpenFixPreview(e) {
- this.$.applyFixDialog.open(e);
- }
-
- _handleNextLineOrFileWithComments(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (e.detail.keyboardEvent.shiftKey &&
- e.detail.keyboardEvent.keyCode === 74) { // 'J'
- this._moveToNextFileWithComment();
- return;
- }
- if (this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this.$.diffHost.displayLine = true;
- this.$.cursor.moveDown();
- }
-
- _moveToPreviousFileWithComment() {
- if (!this._commentSkips) { return; }
-
- // If there is no previous diff with comments, then return to the change
- // view.
- if (!this._commentSkips.previous) {
- this._navToChangeView();
- return;
- }
-
- GerritNav.navigateToDiff(this._change, this._commentSkips.previous,
- this._patchRange.patchNum, this._patchRange.basePatchNum);
- }
-
- _moveToNextFileWithComment() {
- if (!this._commentSkips) { return; }
-
- // If there is no next diff with comments, then return to the change view.
- if (!this._commentSkips.next) {
- this._navToChangeView();
- return;
- }
-
- GerritNav.navigateToDiff(this._change, this._commentSkips.next,
- this._patchRange.patchNum, this._patchRange.basePatchNum);
- }
-
- _handleNewComment(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- e.preventDefault();
- this.$.cursor.createCommentInPlace();
- }
-
- _handlePrevFile(e) {
- // Check for meta key to avoid overriding native chrome shortcut.
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.getKeyboardEvent(e).metaKey) { return; }
-
- e.preventDefault();
- this._navToFile(this._path, this._fileList, -1);
- }
-
- _handleNextFile(e) {
- // Check for meta key to avoid overriding native chrome shortcut.
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.getKeyboardEvent(e).metaKey) { return; }
-
- e.preventDefault();
- this._navToFile(this._path, this._fileList, 1);
- }
-
- _handleNextChunkOrCommentThread(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- if (e.detail.keyboardEvent.shiftKey) {
- this.$.cursor.moveToNextCommentThread();
- } else {
- if (this.modifierPressed(e)) { return; }
- // navigate to next file if key is not being held down
- this.$.cursor.moveToNextChunk(/* opt_clipToTop = */false,
- /* opt_navigateToNextFile = */!e.detail.keyboardEvent.repeat);
- }
- }
-
- _handlePrevChunkOrCommentThread(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- e.preventDefault();
- if (e.detail.keyboardEvent.shiftKey) {
- this.$.cursor.moveToPreviousCommentThread();
- } else {
- if (this.modifierPressed(e)) { return; }
- this.$.cursor.moveToPreviousChunk();
- }
- }
-
- _handleOpenReplyDialogOrToggleLeftPane(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
- e.preventDefault();
- this.$.diffHost.toggleLeftDiff();
- return;
- }
-
- if (this.modifierPressed(e)) { return; }
-
- if (!this._loggedIn) { return; }
-
- this.set('changeViewState.showReplyDialog', true);
- e.preventDefault();
- this._navToChangeView();
- }
-
- _handleUpToChange(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- this._navToChangeView();
- }
-
- _handleCommaKey(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- if (this._diffPrefsDisabled) { return; }
-
- e.preventDefault();
- this.$.diffPreferencesDialog.open();
- }
-
- _handleToggleDiffMode(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
-
- e.preventDefault();
- if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
- this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
- } else {
- this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
- }
- }
-
- _navToChangeView() {
- if (!this._changeNum || !this._patchRange.patchNum) { return; }
- this._navigateToChange(
- this._change,
- this._patchRange,
- this._change && this._change.revisions);
- }
-
- _navToFile(path, fileList, direction) {
- const newPath = this._getNavLinkPath(path, fileList, direction);
- if (!newPath) { return; }
-
- if (newPath.up) {
- this._navigateToChange(
- this._change,
- this._patchRange,
- this._change && this._change.revisions);
- return;
- }
-
- GerritNav.navigateToDiff(this._change, newPath.path,
- this._patchRange.patchNum, this._patchRange.basePatchNum);
- }
-
- /**
- * @param {?string} path The path of the current file being shown.
- * @param {!Array<string>} fileList The list of files in this change and
- * patch range.
- * @param {number} direction Either 1 (next file) or -1 (prev file).
- * @param {(number|boolean)} opt_noUp Whether to return to the change view
- * when advancing the file goes outside the bounds of fileList.
- *
- * @return {?string} The next URL when proceeding in the specified
- * direction.
- */
- _computeNavLinkURL(change, path, fileList, direction, opt_noUp) {
- const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
- if (!newPath) { return null; }
-
- if (newPath.up) {
- return this._getChangePath(
- this._change,
- this._patchRange,
- this._change && this._change.revisions);
- }
- return this._getDiffUrl(this._change, this._patchRange, newPath.path);
- }
-
- _goToEditFile() {
- // TODO(taoalpha): add a shortcut for editing
- const cursorAddress = this.$.cursor.getAddress();
- const editUrl = GerritNav.getEditUrlForDiff(
- this._change,
- this._path,
- this._patchRange.patchNum,
- cursorAddress && cursorAddress.number
- );
- return GerritNav.navigateToRelativeUrl(editUrl);
- }
-
- /**
- * Gives an object representing the target of navigating either left or
- * right through the change. The resulting object will have one of the
- * following forms:
- * * {path: "<target file path>"} - When another file path should be the
- * result of the navigation.
- * * {up: true} - When the result of navigating should go back to the
- * change view.
- * * null - When no navigation is possible for the given direction.
- *
- * @param {?string} path The path of the current file being shown.
- * @param {!Array<string>} fileList The list of files in this change and
- * patch range.
- * @param {number} direction Either 1 (next file) or -1 (prev file).
- * @param {?number|boolean=} opt_noUp Whether to return to the change view
- * when advancing the file goes outside the bounds of fileList.
- * @return {?Object}
- */
- _getNavLinkPath(path, fileList, direction, opt_noUp) {
- if (!path || !fileList || fileList.length === 0) { return null; }
-
- let idx = fileList.indexOf(path);
- if (idx === -1) {
- const file = direction > 0 ?
- fileList[0] :
- fileList[fileList.length - 1];
- return {path: file};
- }
-
- idx += direction;
- // Redirect to the change view if opt_noUp isn’t truthy and idx falls
- // outside the bounds of [0, fileList.length).
- if (idx < 0 || idx > fileList.length - 1) {
- if (opt_noUp) { return null; }
- return {up: true};
- }
-
- return {path: fileList[idx]};
- }
-
- _getReviewedFiles(changeNum, patchNum) {
- return this.$.restAPI.getReviewedFiles(changeNum, patchNum)
- .then(files => {
- this._reviewedFiles = new Set(files);
- return this._reviewedFiles;
- });
- }
-
- _getReviewedStatus(editMode, changeNum, patchNum, path) {
- if (editMode) { return Promise.resolve(false); }
- return this._getReviewedFiles(changeNum, patchNum)
- .then(files => files.has(path));
- }
-
- _paramsChanged(value) {
- if (value.view !== GerritNav.View.DIFF) { return; }
-
- if (value.changeNum && value.project) {
- this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
- }
-
- this.$.diffHost.lineOfInterest = this._getLineOfInterest(this.params);
- this._initCursor(this.params);
-
- this._changeNum = value.changeNum;
- this.classList.remove('hideComments');
- this._path = value.path;
- this._patchRange = {
- patchNum: value.patchNum,
- basePatchNum: value.basePatchNum || PARENT,
- };
-
- // NOTE: This may be called before attachment (e.g. while parentElement is
- // null). Fire title-change in an async so that, if attachment to the DOM
- // has been queued, the event can bubble up to the handler in gr-app.
- this.async(() => {
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: computeTruncatedPath(this._path)},
- composed: true, bubbles: true,
- }));
- });
-
- // When navigating away from the page, there is a possibility that the
- // patch number is no longer a part of the URL (say when navigating to
- // the top-level change info view) and therefore undefined in `params`.
- if (!this._patchRange.patchNum) {
- return;
- }
-
- const promises = [];
-
- promises.push(this._getDiffPreferences());
-
- promises.push(this._getPreferences().then(prefs => {
- this._userPrefs = prefs;
- }));
-
- promises.push(this._getChangeDetail(this._changeNum).then(change => {
- let commit;
- let baseCommit;
- if (change) {
- for (const commitSha in change.revisions) {
- if (!change.revisions.hasOwnProperty(commitSha)) continue;
- const revision = change.revisions[commitSha];
- const patchNum = revision._number.toString();
- if (patchNum === this._patchRange.patchNum) {
- commit = commitSha;
- const commitObj = revision.commit || {};
- const parents = commitObj.parents || [];
- if (this._patchRange.basePatchNum === PARENT && parents.length) {
- baseCommit = parents[parents.length - 1].commit;
- }
- } else if (patchNum === this._patchRange.basePatchNum) {
- baseCommit = commitSha;
- }
- }
- this._commitRange = {commit, baseCommit};
- }
- }));
-
- promises.push(this._loadComments());
-
- promises.push(this._getChangeEdit(this._changeNum));
-
- this.$.diffHost.cancel();
- this.$.diffHost.clearDiffContent();
- this._loading = true;
- return Promise.all(promises)
- .then(r => {
- const edit = r[4];
- if (edit) {
- this.set('_change.revisions.' + edit.commit.commit, {
- _number: SPECIAL_PATCH_SET_NUM.EDIT,
- basePatchNum: edit.base_patch_set_number,
- commit: edit.commit,
- });
- }
- this._loading = false;
- this.$.diffHost.comments = this._commentsForDiff;
- return this.$.diffHost.reload(true);
- })
- .then(() => {
- this.reporting.diffViewFullyLoaded();
- // If diff view displayed has not ended yet, it ends here.
- this.reporting.diffViewDisplayed();
- })
- .then(() => {
- // If the blame was loaded for a previous file and user navigates to
- // another file, then we load the blame for this file too
- if (this._isBlameLoaded) this._loadBlame();
- });
- }
-
- _changeViewStateChanged(changeViewState) {
- if (changeViewState.diffMode === null) {
- // If screen size is small, always default to unified view.
- this.$.restAPI.getPreferences().then(prefs => {
- this.set('changeViewState.diffMode', prefs.default_diff_view);
- });
- }
- }
-
- _setReviewedObserver(_loggedIn, paramsRecord, _prefs) {
- // Polymer 2: check for undefined
- if ([_loggedIn, paramsRecord, _prefs].includes(undefined)) {
- return;
- }
-
- const params = paramsRecord.base || {};
- if (!_loggedIn) { return; }
-
- if (_prefs.manual_review) {
- // Checkbox state needs to be set explicitly only when manual_review
- // is specified.
- this._getReviewedStatus(this.editMode, this._changeNum,
- this._patchRange.patchNum, this._path).then(status => {
- this.$.reviewed.checked = status;
- });
- return;
- }
-
- if (params.view === GerritNav.View.DIFF) {
- this._setReviewed(true);
- }
- }
-
- /**
- * If the params specify a diff address then configure the diff cursor.
- */
- _initCursor(params) {
- if (params.lineNum === undefined) { return; }
- if (params.leftSide) {
- this.$.cursor.side = DiffSides.LEFT;
- } else {
- this.$.cursor.side = DiffSides.RIGHT;
- }
- this.$.cursor.initialLineNumber = params.lineNum;
- }
-
- _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};
- }
-
- _pathChanged(path) {
- if (path) {
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: computeTruncatedPath(path)},
- composed: true, bubbles: true,
- }));
- }
-
- if (this._fileList.length == 0) { return; }
-
- this.set('changeViewState.selectedFileIndex',
- this._fileList.indexOf(path));
- }
-
- _getDiffUrl(change, patchRange, path) {
- if ([change, patchRange, path].includes(undefined)) {
- return '';
- }
- return GerritNav.getUrlForDiff(change, path, patchRange.patchNum,
- patchRange.basePatchNum);
- }
-
- _patchRangeStr(patchRange) {
- let patchStr = patchRange.patchNum;
- if (patchRange.basePatchNum != null &&
- patchRange.basePatchNum != PARENT) {
- patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
- }
- return patchStr;
- }
-
- /**
- * When the latest patch of the change is selected (and there is no base
- * patch) then the patch range need not appear in the URL. Return a patch
- * range object with undefined values when a range is not needed.
- *
- * @param {!Object} patchRange
- * @param {!Object} revisions
- * @return {!Object}
- */
- _getChangeUrlRange(patchRange, revisions) {
- let patchNum = undefined;
- let basePatchNum = undefined;
- let latestPatchNum = -1;
- for (const rev of Object.values(revisions || {})) {
- latestPatchNum = Math.max(latestPatchNum, rev._number);
- }
- if (patchRange.basePatchNum !== PARENT ||
- parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
- patchNum = patchRange.patchNum;
- basePatchNum = patchRange.basePatchNum;
- }
- return {patchNum, basePatchNum};
- }
-
- _getChangePath(change, patchRange, revisions) {
- if ([change, patchRange].includes(undefined)) {
- return '';
- }
- const range = this._getChangeUrlRange(patchRange, revisions);
- return GerritNav.getUrlForChange(change, range.patchNum,
- range.basePatchNum);
- }
-
- _navigateToChange(change, patchRange, revisions) {
- const range = this._getChangeUrlRange(patchRange, revisions);
- GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
- }
-
- _computeChangePath(change, patchRangeRecord, revisions) {
- return this._getChangePath(change, patchRangeRecord.base, revisions);
- }
-
- _formatFilesForDropdown(files, patchNum, changeComments) {
- // Polymer 2: check for undefined
- if ([
- files,
- patchNum,
- changeComments,
- ].includes(undefined)) {
- return;
- }
-
- if (!files) { return; }
- const dropdownContent = [];
- for (const path of files.sortedFileList) {
- dropdownContent.push({
- text: computeDisplayPath(path),
- mobileText: computeTruncatedPath(path),
- value: path,
- bottomText: this._computeCommentString(changeComments, patchNum,
- path, files.changeFilesByPath[path]),
- });
- }
- return dropdownContent;
- }
-
- _computeCommentString(changeComments, patchNum, path, changeFileInfo) {
- const unresolvedCount = changeComments.computeUnresolvedNum({patchNum,
- path});
- const commentCount = changeComments.computeCommentCount({patchNum, path});
- const commentString = GrCountStringFormatter.computePluralString(
- commentCount, 'comment');
- const unresolvedString = GrCountStringFormatter.computeString(
- unresolvedCount, 'unresolved');
-
- const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes': '';
-
- return [
- unmodifiedString,
- commentString,
- unresolvedString]
- .filter(v => v && v.length > 0).join(', ');
- }
-
- _computePrefsButtonHidden(prefs, prefsDisabled) {
- return prefsDisabled || !prefs;
- }
-
- _handleFileChange(e) {
- // This is when it gets set initially.
- const path = e.detail.value;
- if (path === this._path) {
- return;
- }
-
- GerritNav.navigateToDiff(this._change, path, this._patchRange.patchNum,
- this._patchRange.basePatchNum);
- }
-
- _handleFileTap(e) {
- // async is needed so that that the click event is fired before the
- // dropdown closes (This was a bug for touch devices).
- this.async(() => {
- this.$.dropdown.close();
- }, 1);
- }
-
- _handlePatchChange(e) {
- const {basePatchNum, patchNum} = e.detail;
- if (patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
- patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
- GerritNav.navigateToDiff(
- this._change, this._path, patchNum, basePatchNum);
- }
-
- _handlePrefsTap(e) {
- e.preventDefault();
- this.$.diffPreferencesDialog.open();
- }
-
- /**
- * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
- * the current state.
- *
- * The expected behavior is to use the mode specified in the user's
- * preferences unless they have manually chosen the alternative view or they
- * are on a mobile device. If the user navigates up to the change view, it
- * should clear this choice and revert to the preference the next time a
- * diff is viewed.
- *
- * Use side-by-side if the user is not logged in.
- *
- * @return {string}
- */
- _getDiffViewMode() {
- if (this.changeViewState.diffMode) {
- return this.changeViewState.diffMode;
- } else if (this._userPrefs) {
- this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
- return this._userPrefs.default_diff_view;
- } else {
- return 'SIDE_BY_SIDE';
- }
- }
-
- _computeModeSelectHideClass(_diff) {
- return _diff.binary ? 'hide' : '';
- }
-
- _onLineSelected(e, detail) {
- if (!this._change) { return; }
- const cursorAddress = this.$.cursor.getAddress();
- const number = cursorAddress ? cursorAddress.number : undefined;
- const leftSide = cursorAddress ? cursorAddress.leftSide : undefined;
- const url = GerritNav.getUrlForDiffById(this._changeNum,
- this._change.project, this._path, this._patchRange.patchNum,
- this._patchRange.basePatchNum, number, leftSide);
- history.replaceState(null, '', url);
- }
-
- _computeDownloadDropdownLinks(
- project, changeNum, patchRange, path, diff) {
- if (!patchRange || !patchRange.patchNum) { return []; }
-
- const links = [
- {
- url: this._computeDownloadPatchLink(
- project, changeNum, patchRange, path),
- name: 'Patch',
- },
- ];
-
- if (diff && diff.meta_a) {
- let leftPath = path;
- if (diff.change_type === 'RENAMED') {
- leftPath = diff.meta_a.name;
- }
- links.push(
- {
- url: this._computeDownloadFileLink(
- project, changeNum, patchRange, leftPath, true),
- name: 'Left Content',
- }
- );
- }
-
- if (diff && diff.meta_b) {
- links.push(
- {
- url: this._computeDownloadFileLink(
- project, changeNum, patchRange, path, false),
- name: 'Right Content',
- }
- );
- }
-
- return links;
- }
-
- _computeDownloadFileLink(
- project, changeNum, patchRange, path, isBase) {
- let patchNum = patchRange.patchNum;
-
- const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
-
- if (isBase && !comparedAgainsParent) {
- patchNum = patchRange.basePatchNum;
- }
-
- let url = changeBaseURL(project, changeNum, patchNum) +
- `/files/${encodeURIComponent(path)}/download`;
-
- if (isBase && comparedAgainsParent) {
- url += '?parent=1';
- }
-
- return url;
- }
-
- _computeDownloadPatchLink(project, changeNum, patchRange, path) {
- let url = changeBaseURL(project, changeNum, patchRange.patchNum);
- url += '/patch?zip&path=' + encodeURIComponent(path);
- return url;
- }
-
- _loadComments() {
- return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
- this._changeComments = comments;
- this._commentMap = this._getPaths(this._patchRange);
-
- this._commentsForDiff = this._getCommentsForPath(this._path,
- this._patchRange, this._projectConfig);
- });
- }
-
- _recomputeComments(files, path, patchRange, projectConfig) {
- // Polymer 2: check for undefined
- if ([
- files,
- path,
- patchRange,
- projectConfig,
- ].includes(undefined)) {
- return undefined;
- }
-
- const file = files[path];
- if (file && file.old_path) {
- this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
- {path, basePath: file.old_path},
- patchRange,
- projectConfig);
-
- this.$.diffHost.comments = this._commentsForDiff;
- }
- }
-
- _getPaths(patchRange) {
- return this._changeComments.getPaths(patchRange);
- }
-
- _getCommentsForPath(path, patchRange, projectConfig) {
- return this._changeComments.getCommentsBySideForPath(path, patchRange,
- projectConfig);
- }
-
- _getDiffDrafts() {
- return this.$.restAPI.getDiffDrafts(this._changeNum);
- }
-
- _computeCommentSkips(commentMap, fileList, path) {
- // Polymer 2: check for undefined
- if ([
- commentMap,
- fileList,
- path,
- ].includes(undefined)) {
- return undefined;
- }
-
- const skips = {previous: null, next: null};
- if (!fileList.length) { return skips; }
- const pathIndex = fileList.indexOf(path);
-
- // Scan backward for the previous file.
- for (let i = pathIndex - 1; i >= 0; i--) {
- if (commentMap[fileList[i]]) {
- skips.previous = fileList[i];
- break;
- }
- }
-
- // Scan forward for the next file.
- for (let i = pathIndex + 1; i < fileList.length; i++) {
- if (commentMap[fileList[i]]) {
- skips.next = fileList[i];
- break;
- }
- }
-
- return skips;
- }
-
- _computeDiffClass(panelFloatingDisabled) {
- if (panelFloatingDisabled) {
- return 'noOverflow';
- }
- }
-
- /**
- * @param {!Object} patchRangeRecord
- */
- _computeEditMode(patchRangeRecord) {
- const patchRange = patchRangeRecord.base || {};
- return patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
- }
-
- /**
- * @param {boolean} editMode
- */
- _computeContainerClass(editMode) {
- return editMode ? 'editMode' : '';
- }
-
- _computeBlameToggleLabel(loaded, loading) {
- if (loaded) { return 'Hide blame'; }
- return 'Show blame';
- }
-
- _loadBlame() {
- this._isBlameLoading = true;
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: MSG_LOADING_BLAME},
- composed: true, bubbles: true,
- }));
- this.$.diffHost.loadBlame()
- .then(() => {
- this._isBlameLoading = false;
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: MSG_LOADED_BLAME},
- composed: true, bubbles: true,
- }));
- })
- .catch(() => {
- this._isBlameLoading = false;
- });
- }
-
- /**
- * Load and display blame information if it has not already been loaded.
- * Otherwise hide it.
- */
- _toggleBlame() {
- if (this._isBlameLoaded) {
- this.$.diffHost.clearBlame();
- return;
- }
- this._loadBlame();
- }
-
- _handleToggleBlame(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- this._toggleBlame();
- }
-
- _handleToggleHideAllCommentThreads(e) {
- if (this.shouldSuppressKeyboardShortcut(e) ||
- this.modifierPressed(e)) { return; }
- this.toggleClass('hideComments');
- }
-
- _handleDiffAgainstBase(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Base is already selected.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToDiff(
- this._change, this._path, this._patchRange.patchNum);
- }
-
- _handleDiffBaseAgainstLeft(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- if (patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Left is already base.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToDiff(this._change, this._path,
- this._patchRange.basePatchNum);
- }
-
- _handleDiffAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Latest is already selected.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
-
- GerritNav.navigateToDiff(
- this._change, this._path, latestPatchNum,
- this._patchRange.basePatchNum);
- }
-
- _handleDiffRightAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Right is already latest.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToDiff(this._change, this._path, latestPatchNum,
- this._patchRange.patchNum);
- }
-
- _handleDiffBaseAgainstLatest(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
- if (patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
- patchNumEquals(this._patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Already diffing base against latest.',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
- }
-
- _computeBlameLoaderClass(isImageDiff, path) {
- return !isMagicPath(path) && !isImageDiff ? 'show' : '';
- }
-
- _getRevisionInfo(change) {
- return new RevisionInfo(change);
- }
-
- _computeFileNum(file, files) {
- // Polymer 2: check for undefined
- if ([file, files].includes(undefined)) {
- return undefined;
- }
-
- return files.findIndex(({value}) => value === file) + 1;
- }
-
- /**
- * @param {number} fileNum
- * @param {!Array<string>} files
- * @return {string}
- */
- _computeFileNumClass(fileNum, files) {
- if (files && fileNum > 0) {
- return 'show';
- }
- return '';
- }
-
- _handleExpandAllDiffContext(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- this.$.diffHost.expandAllContext();
- }
-
- _computeDiffPrefsDisabled(disableDiffPrefs, loggedIn) {
- return disableDiffPrefs || !loggedIn;
- }
-
- _handleNextUnreviewedFile(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- this._setReviewed(true);
- // Ensure that the currently viewed file always appears in unreviewedFiles
- // so we resolve the right "next" file.
- const unreviewedFiles = this._fileList
- .filter(file =>
- (file === this._path || !this._reviewedFiles.has(file)));
- this._navToFile(this._path, unreviewedFiles, 1);
- }
-
- _handleReloadingDiffPreference() {
- this._getDiffPreferences();
- }
-
- _onChangeHeaderPanelHeightChanged(e) {
- this._scrollTopMargin = e.detail.value;
- }
-
- _computeCanEdit(loggedIn, changeChangeRecord) {
- if ([changeChangeRecord, changeChangeRecord.base]
- .some(arg => arg === undefined)) {
- return false;
- }
- return loggedIn && changeIsOpen(changeChangeRecord.base);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeAllPatchSets(change) {
- return computeAllPatchSets(change);
- }
-
- /**
- * Wrapper for using in the element template and computed properties
- */
- _computeDisplayPath(path) {
- return computeDisplayPath(path);
- }
-}
-
-customElements.define(GrDiffView.is, GrDiffView);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
new file mode 100644
index 0000000..d0be880
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -0,0 +1,1972 @@
+/**
+ * @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 '@polymer/iron-dropdown/iron-dropdown';
+import '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-icons/gr-icons';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../../shared/revision-info/revision-info';
+import '../gr-comment-api/gr-comment-api';
+import '../gr-diff-cursor/gr-diff-cursor';
+import '../gr-apply-fix-dialog/gr-apply-fix-dialog';
+import '../gr-diff-host/gr-diff-host';
+import '../gr-diff-mode-selector/gr-diff-mode-selector';
+import '../gr-diff-preferences-dialog/gr-diff-preferences-dialog';
+import '../gr-patch-range-select/gr-patch-range-select';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-diff-view_html';
+import {
+ KeyboardShortcutMixin,
+ Shortcut,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {GerritNav, GerritView} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {
+ computeAllPatchSets,
+ computeLatestPatchNum,
+ patchNumEquals,
+ PatchSet,
+} from '../../../utils/patch-set-util';
+import {
+ addUnmodifiedFiles,
+ computeDisplayPath,
+ computeTruncatedPath,
+ isMagicPath,
+ specialFilePathCompare,
+} from '../../../utils/path-list-util';
+import {changeBaseURL, changeIsOpen} from '../../../utils/change-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrDiffHost} from '../gr-diff-host/gr-diff-host';
+import {
+ DropdownItem,
+ GrDropdownList,
+} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {
+ ChangeComments,
+ GrCommentApi,
+ TwoSidesComments,
+} from '../gr-comment-api/gr-comment-api';
+import {GrDiffModeSelector} from '../gr-diff-mode-selector/gr-diff-mode-selector';
+import {
+ ChangeInfo,
+ CommitId,
+ ConfigInfo,
+ DiffInfo,
+ DiffPreferencesInfo,
+ EditInfo,
+ EditPatchSetNum,
+ ElementPropertyDeepChange,
+ FileInfo,
+ NumericChangeId,
+ ParentPatchSetNum,
+ PatchRange,
+ PatchSetNum,
+ PreferencesInfo,
+ RepoName,
+ RevisionInfo,
+} from '../../../types/common';
+import {ChangeViewState, CommitRange, FileRange} from '../../../types/types';
+import {FilesWebLinks} from '../gr-patch-range-select/gr-patch-range-select';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrDiffCursor} from '../gr-diff-cursor/gr-diff-cursor';
+import {CommentSide, DiffViewMode, Side} from '../../../constants/constants';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {GrApplyFixDialog} from '../gr-apply-fix-dialog/gr-apply-fix-dialog';
+import {LineOfInterest} from '../gr-diff/gr-diff';
+import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
+import {CommentMap} from '../../../utils/comment-util';
+import {AppElementParams} from '../../gr-app-types';
+import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
+import {PORTING_COMMENTS_DIFF_LATENCY_LABEL} from '../../../services/gr-reporting/gr-reporting';
+
+const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
+const MSG_LOADING_BLAME = 'Loading blame...';
+const MSG_LOADED_BLAME = 'Blame loaded';
+
+interface Files {
+ sortedFileList: string[];
+ changeFilesByPath: {[path: string]: FileInfo};
+}
+
+interface CommentSkips {
+ previous: string | null;
+ next: string | null;
+}
+
+export interface GrDiffView {
+ $: {
+ restAPI: RestApiService & Element;
+ commentAPI: GrCommentApi;
+ cursor: GrDiffCursor;
+ diffHost: GrDiffHost;
+ reviewed: HTMLInputElement;
+ dropdown: GrDropdownList;
+ diffPreferencesDialog: GrOverlay;
+ applyFixDialog: GrApplyFixDialog;
+ modeSelect: GrDiffModeSelector;
+ };
+}
+
+@customElement('gr-diff-view')
+export class GrDiffView extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ /**
+ * Fired when user tries to navigate away while comments are pending save.
+ *
+ * @event show-alert
+ */
+
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: AppElementParams;
+
+ @property({type: Object})
+ keyEventTarget: HTMLElement = document.body;
+
+ @property({type: Object, notify: true, observer: '_changeViewStateChanged'})
+ changeViewState: Partial<ChangeViewState> = {};
+
+ @property({type: Boolean})
+ disableDiffPrefs = false;
+
+ @property({
+ type: Boolean,
+ computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
+ })
+ _diffPrefsDisabled?: boolean;
+
+ @property({type: Object})
+ _patchRange?: PatchRange;
+
+ @property({type: Object})
+ _commitRange?: CommitRange;
+
+ @property({type: Object})
+ _change?: ChangeInfo;
+
+ @property({type: Object})
+ _changeComments?: ChangeComments;
+
+ @property({type: String})
+ _changeNum?: NumericChangeId;
+
+ @property({type: Object})
+ _diff?: DiffInfo;
+
+ @property({
+ type: Array,
+ computed:
+ '_formatFilesForDropdown(_files, ' +
+ '_patchRange.patchNum, _changeComments)',
+ })
+ _formattedFiles?: DropdownItem[];
+
+ @property({type: Array, computed: '_getSortedFileList(_files)'})
+ _fileList?: string[];
+
+ @property({type: Object})
+ _files: Files = {sortedFileList: [], changeFilesByPath: {}};
+
+ @property({type: Object, computed: '_getCurrentFile(_files, _path)'})
+ _file?: FileInfo;
+
+ @property({type: String, observer: '_pathChanged'})
+ _path?: string;
+
+ @property({type: Number, computed: '_computeFileNum(_path, _formattedFiles)'})
+ _fileNum?: number;
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Object})
+ _prefs?: DiffPreferencesInfo;
+
+ @property({type: Object})
+ _projectConfig?: ConfigInfo;
+
+ @property({type: Object})
+ _userPrefs?: PreferencesInfo;
+
+ @property({
+ type: String,
+ computed: '_getDiffViewMode(changeViewState.diffMode, _userPrefs)',
+ })
+ _diffMode?: string;
+
+ @property({type: Boolean})
+ _isImageDiff?: boolean;
+
+ @property({type: Object})
+ _filesWeblinks?: FilesWebLinks;
+
+ @property({type: Object})
+ _commentMap?: CommentMap;
+
+ @property({type: Object})
+ _commentsForDiff?: TwoSidesComments;
+
+ @property({
+ type: Object,
+ computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
+ })
+ _commentSkips?: CommentSkips;
+
+ @property({type: Boolean, computed: '_computeEditMode(_patchRange.*)'})
+ _editMode?: boolean;
+
+ @property({type: Boolean})
+ _isBlameLoaded?: boolean;
+
+ @property({type: Boolean})
+ _isBlameLoading = false;
+
+ @property({
+ type: Array,
+ computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+ })
+ _allPatchSets?: PatchSet[] = [];
+
+ @property({type: Object, computed: '_getRevisionInfo(_change)'})
+ _revisionInfo?: RevisionInfoObj;
+
+ @property({type: Object})
+ _reviewedFiles = new Set<string>();
+
+ @property({type: Number})
+ _focusLineNum?: number;
+
+ get keyBindings() {
+ return {
+ esc: '_handleEscKey',
+ };
+ }
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.LEFT_PANE]: '_handleLeftPane',
+ [Shortcut.RIGHT_PANE]: '_handleRightPane',
+ [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
+ [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
+ [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
+ [Shortcut.NEXT_FILE_WITH_COMMENTS]: '_handleNextLineOrFileWithComments',
+ [Shortcut.PREV_FILE_WITH_COMMENTS]: '_handlePrevLineOrFileWithComments',
+ [Shortcut.NEW_COMMENT]: '_handleNewComment',
+ [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
+ [Shortcut.NEXT_FILE]: '_handleNextFile',
+ [Shortcut.PREV_FILE]: '_handlePrevFile',
+ [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
+ [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
+ [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
+ [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
+ [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialogOrToggleLeftPane',
+ [Shortcut.TOGGLE_LEFT_PANE]: '_handleOpenReplyDialogOrToggleLeftPane',
+ [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialog',
+ [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
+ [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
+ [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
+ [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
+ [Shortcut.EXPAND_ALL_DIFF_CONTEXT]: '_handleExpandAllDiffContext',
+ [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
+ [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
+ [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
+ '_handleToggleHideAllCommentThreads',
+ [Shortcut.OPEN_FILE_LIST]: '_handleOpenFileList',
+ [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
+ [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
+ [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
+ [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
+ [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
+
+ // Final two are actually handled by gr-comment-thread.
+ [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
+ [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
+ };
+ }
+
+ reporting = appContext.reportingService;
+
+ flagsService = appContext.flagsService;
+
+ _throttledToggleFileReviewed?: EventListener;
+
+ _onRenderHandler?: EventListener;
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ this._throttledToggleFileReviewed = this._throttleWrap(e =>
+ this._handleToggleFileReviewed(e as CustomKeyboardEvent)
+ );
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ });
+
+ this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
+ this.$.cursor.push('diffs', this.$.diffHost);
+ this._onRenderHandler = (_: Event) => {
+ this.$.cursor.reInitCursor();
+ };
+ this.$.diffHost.addEventListener('render', this._onRenderHandler);
+ }
+
+ /** @override */
+ detached() {
+ if (this._onRenderHandler) {
+ this.$.diffHost.removeEventListener('render', this._onRenderHandler);
+ }
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ @observe('_change.project')
+ _getProjectConfig(project?: RepoName) {
+ if (!project) return;
+ return this.$.restAPI.getProjectConfig(project).then(config => {
+ this._projectConfig = config;
+ });
+ }
+
+ _getChangeDetail(changeNum: NumericChangeId) {
+ return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+ if (!change) throw new Error('Missing "change" in API response.');
+ this._change = change;
+ return change;
+ });
+ }
+
+ _getChangeEdit() {
+ if (!this._changeNum) throw new Error('Missing this._changeNum');
+ return this.$.restAPI.getChangeEdit(this._changeNum);
+ }
+
+ _getSortedFileList(files?: Files) {
+ if (!files) return [];
+ return files.sortedFileList;
+ }
+
+ _getCurrentFile(files?: Files, path?: string) {
+ if (!files || !path) return;
+ const fileInfo = files.changeFilesByPath[path];
+ const fileRange: FileRange = {path};
+ if (fileInfo && fileInfo.old_path) {
+ fileRange.basePath = fileInfo.old_path;
+ }
+ return fileRange;
+ }
+
+ @observe('_changeNum', '_patchRange.*', '_changeComments')
+ _getFiles(
+ changeNum: NumericChangeId,
+ patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+ changeComments: ChangeComments
+ ) {
+ // Polymer 2: check for undefined
+ if (
+ [changeNum, patchRangeRecord, patchRangeRecord.base, changeComments].some(
+ arg => arg === undefined
+ )
+ ) {
+ return Promise.resolve();
+ }
+
+ if (!patchRangeRecord.base.patchNum) {
+ return Promise.resolve();
+ }
+
+ const patchRange = patchRangeRecord.base;
+ return this.$.restAPI
+ .getChangeFiles(changeNum, patchRange)
+ .then(changeFiles => {
+ if (!changeFiles) return;
+ const commentedPaths = changeComments.getPaths(patchRange);
+ const files = {...changeFiles};
+ addUnmodifiedFiles(files, commentedPaths);
+ this._files = {
+ sortedFileList: Object.keys(files).sort(specialFilePathCompare),
+ changeFilesByPath: files,
+ };
+ });
+ }
+
+ _getDiffPreferences() {
+ return this.$.restAPI.getDiffPreferences().then(prefs => {
+ this._prefs = prefs;
+ });
+ }
+
+ _getPreferences() {
+ return this.$.restAPI.getPreferences();
+ }
+
+ _getWindowWidth() {
+ return window.innerWidth;
+ }
+
+ _handleReviewedChange(e: Event) {
+ this._setReviewed(
+ ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
+ );
+ }
+
+ _setReviewed(reviewed: boolean) {
+ if (this._editMode) return;
+ this.$.reviewed.checked = reviewed;
+ if (!this._patchRange?.patchNum) return;
+ this._saveReviewedState(reviewed).catch(err => {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: ERR_REVIEW_STATUS},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ throw err;
+ });
+ }
+
+ _saveReviewedState(reviewed: boolean): Promise<Response | undefined> {
+ if (!this._changeNum) return Promise.resolve(undefined);
+ if (!this._patchRange?.patchNum) return Promise.resolve(undefined);
+ if (!this._path) return Promise.resolve(undefined);
+ return this.$.restAPI.saveFileReviewed(
+ this._changeNum,
+ this._patchRange?.patchNum,
+ this._path,
+ reviewed
+ );
+ }
+
+ _handleToggleFileReviewed(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ e.preventDefault();
+ this._setReviewed(!this.$.reviewed.checked);
+ }
+
+ _handleEscKey(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ e.preventDefault();
+ this.$.diffHost.displayLine = false;
+ }
+
+ _handleLeftPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ e.preventDefault();
+ this.$.cursor.moveLeft();
+ }
+
+ _handleRightPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ e.preventDefault();
+ this.$.cursor.moveRight();
+ }
+
+ _handlePrevLineOrFileWithComments(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ if (
+ e.detail.keyboardEvent?.shiftKey &&
+ e.detail.keyboardEvent?.keyCode === 75
+ ) {
+ // 'K'
+ this._moveToPreviousFileWithComment();
+ return;
+ }
+ if (this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.diffHost.displayLine = true;
+ this.$.cursor.moveUp();
+ }
+
+ _handleVisibleLine(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ e.preventDefault();
+ this.$.cursor.moveToVisibleArea();
+ }
+
+ _onOpenFixPreview(e: OpenFixPreviewEvent) {
+ this.$.applyFixDialog.open(e);
+ }
+
+ _handleNextLineOrFileWithComments(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ if (
+ e.detail.keyboardEvent?.shiftKey &&
+ e.detail.keyboardEvent?.keyCode === 74
+ ) {
+ // 'J'
+ this._moveToNextFileWithComment();
+ return;
+ }
+ if (this.modifierPressed(e)) {
+ return;
+ }
+
+ e.preventDefault();
+ this.$.diffHost.displayLine = true;
+ this.$.cursor.moveDown();
+ }
+
+ _moveToPreviousFileWithComment() {
+ if (!this._commentSkips) return;
+ if (!this._change) return;
+ if (!this._patchRange?.patchNum) return;
+
+ // If there is no previous diff with comments, then return to the change
+ // view.
+ if (!this._commentSkips.previous) {
+ this._navToChangeView();
+ return;
+ }
+
+ GerritNav.navigateToDiff(
+ this._change,
+ this._commentSkips.previous,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _moveToNextFileWithComment() {
+ if (!this._commentSkips) return;
+ if (!this._change) return;
+ if (!this._patchRange?.patchNum) return;
+
+ // If there is no next diff with comments, then return to the change view.
+ if (!this._commentSkips.next) {
+ this._navToChangeView();
+ return;
+ }
+
+ GerritNav.navigateToDiff(
+ this._change,
+ this._commentSkips.next,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _handleNewComment(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ e.preventDefault();
+ this.$.cursor.createCommentInPlace();
+ }
+
+ _handlePrevFile(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ // Check for meta key to avoid overriding native chrome shortcut.
+ if (this.getKeyboardEvent(e).metaKey) return;
+ if (!this._path) return;
+ if (!this._fileList) return;
+
+ e.preventDefault();
+ this._navToFile(this._path, this._fileList, -1);
+ }
+
+ _handleNextFile(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ // Check for meta key to avoid overriding native chrome shortcut.
+ if (this.getKeyboardEvent(e).metaKey) return;
+ if (!this._path) return;
+ if (!this._fileList) return;
+
+ e.preventDefault();
+ this._navToFile(this._path, this._fileList, 1);
+ }
+
+ _handleNextChunkOrCommentThread(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ e.preventDefault();
+ if (e.detail.keyboardEvent?.shiftKey) {
+ this.$.cursor.moveToNextCommentThread();
+ } else {
+ if (this.modifierPressed(e)) return;
+ // navigate to next file if key is not being held down
+ this.$.cursor.moveToNextChunk(
+ /* opt_clipToTop = */ false,
+ /* opt_navigateToNextFile = */ !e.detail.keyboardEvent?.repeat
+ );
+ }
+ }
+
+ _handlePrevChunkOrCommentThread(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ e.preventDefault();
+ if (e.detail.keyboardEvent?.shiftKey) {
+ this.$.cursor.moveToPreviousCommentThread();
+ } else {
+ if (this.modifierPressed(e)) return;
+ this.$.cursor.moveToPreviousChunk();
+ }
+ }
+
+ _handleOpenReplyDialogOrToggleLeftPane(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ if (e.detail.keyboardEvent?.shiftKey) {
+ // Hide left diff.
+ e.preventDefault();
+ this.$.diffHost.toggleLeftDiff();
+ return;
+ }
+
+ if (this.modifierPressed(e)) return;
+ if (!this._loggedIn) return;
+
+ this.set('changeViewState.showReplyDialog', true);
+ e.preventDefault();
+ this._navToChangeView();
+ }
+
+ _handleOpenDownloadDialog(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ this.set('changeViewState.showDownloadDialog', true);
+ e.preventDefault();
+ this._navToChangeView();
+ }
+
+ _handleUpToChange(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ e.preventDefault();
+ this._navToChangeView();
+ }
+
+ _handleCommaKey(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+ if (this._diffPrefsDisabled) return;
+
+ e.preventDefault();
+ this.$.diffPreferencesDialog.open();
+ }
+
+ _handleToggleDiffMode(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ e.preventDefault();
+ if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
+ this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
+ } else {
+ this.$.modeSelect.setMode(DiffViewMode.SIDE_BY_SIDE);
+ }
+ }
+
+ _navToChangeView() {
+ if (!this._changeNum || !this._patchRange?.patchNum) {
+ return;
+ }
+ this._navigateToChange(
+ this._change,
+ this._patchRange,
+ this._change && this._change.revisions
+ );
+ }
+
+ _navToFile(path: string, fileList: string[], direction: -1 | 1) {
+ const newPath = this._getNavLinkPath(path, fileList, direction);
+ if (!newPath) return;
+ if (!this._change) return;
+ if (!this._patchRange) return;
+
+ if (newPath.up) {
+ this._navigateToChange(
+ this._change,
+ this._patchRange,
+ this._change && this._change.revisions
+ );
+ return;
+ }
+
+ if (!newPath.path) return;
+ GerritNav.navigateToDiff(
+ this._change,
+ newPath.path,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ /**
+ * @param path The path of the current file being shown.
+ * @param fileList The list of files in this change and
+ * patch range.
+ * @param direction Either 1 (next file) or -1 (prev file).
+ * @param opt_noUp Whether to return to the change view
+ * when advancing the file goes outside the bounds of fileList.
+ * @return The next URL when proceeding in the specified
+ * direction.
+ */
+ _computeNavLinkURL(
+ change?: ChangeInfo,
+ path?: string,
+ fileList?: string[],
+ direction?: -1 | 1,
+ opt_noUp?: boolean
+ ) {
+ if (!change) return null;
+ if (!path) return null;
+ if (!fileList) return null;
+ if (!direction) return null;
+
+ const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
+ if (!newPath) {
+ return null;
+ }
+
+ if (newPath.up) {
+ return this._getChangePath(
+ this._change,
+ this._patchRange,
+ this._change && this._change.revisions
+ );
+ }
+ return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+ }
+
+ _goToEditFile() {
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ // TODO(taoalpha): add a shortcut for editing
+ const cursorAddress = this.$.cursor.getAddress();
+ const editUrl = GerritNav.getEditUrlForDiff(
+ this._change,
+ this._path,
+ this._patchRange.patchNum,
+ cursorAddress?.number
+ );
+ GerritNav.navigateToRelativeUrl(editUrl);
+ }
+
+ /**
+ * Gives an object representing the target of navigating either left or
+ * right through the change. The resulting object will have one of the
+ * following forms:
+ * * {path: "<target file path>"} - When another file path should be the
+ * result of the navigation.
+ * * {up: true} - When the result of navigating should go back to the
+ * change view.
+ * * null - When no navigation is possible for the given direction.
+ *
+ * @param path The path of the current file being shown.
+ * @param fileList The list of files in this change and
+ * patch range.
+ * @param direction Either 1 (next file) or -1 (prev file).
+ * @param opt_noUp Whether to return to the change view
+ * when advancing the file goes outside the bounds of fileList.
+ */
+ _getNavLinkPath(
+ path: string,
+ fileList: string[],
+ direction: -1 | 1,
+ opt_noUp?: boolean
+ ) {
+ if (!path || !fileList || fileList.length === 0) {
+ return null;
+ }
+
+ let idx = fileList.indexOf(path);
+ if (idx === -1) {
+ const file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
+ return {path: file};
+ }
+
+ idx += direction;
+ // Redirect to the change view if opt_noUp isn’t truthy and idx falls
+ // outside the bounds of [0, fileList.length).
+ if (idx < 0 || idx > fileList.length - 1) {
+ if (opt_noUp) {
+ return null;
+ }
+ return {up: true};
+ }
+
+ return {path: fileList[idx]};
+ }
+
+ _getReviewedFiles(
+ changeNum?: NumericChangeId,
+ patchNum?: PatchSetNum
+ ): Promise<Set<string>> {
+ if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
+ return this.$.restAPI.getReviewedFiles(changeNum, patchNum).then(files => {
+ this._reviewedFiles = new Set(files);
+ return this._reviewedFiles;
+ });
+ }
+
+ _getReviewedStatus(
+ editMode?: boolean,
+ changeNum?: NumericChangeId,
+ patchNum?: PatchSetNum,
+ path?: string
+ ) {
+ if (editMode || !path) {
+ return Promise.resolve(false);
+ }
+ return this._getReviewedFiles(changeNum, patchNum).then(files =>
+ files.has(path)
+ );
+ }
+
+ _initLineOfInterestAndCursor(leftSide: boolean) {
+ this.$.diffHost.lineOfInterest = this._getLineOfInterest(leftSide);
+ this._initCursor(leftSide);
+ }
+
+ _displayDiffBaseAgainstLeftToast() {
+ if (!this._patchRange) return;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ // \u2190 = ←
+ message:
+ `Patchset ${this._patchRange.basePatchNum} vs ` +
+ `${this._patchRange.patchNum} selected. Press v + \u2190 to view ` +
+ `Base vs ${this._patchRange.basePatchNum}`,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _displayDiffAgainstLatestToast(latestPatchNum?: PatchSetNum) {
+ if (!this._patchRange) return;
+ const leftPatchset = patchNumEquals(
+ this._patchRange.basePatchNum,
+ ParentPatchSetNum
+ )
+ ? 'Base'
+ : `Patchset ${this._patchRange.basePatchNum}`;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ // \u2191 = ↑
+ message: `${leftPatchset} vs
+ ${this._patchRange.patchNum} selected\n. Press v + \u2191 to view
+ ${leftPatchset} vs Patchset ${latestPatchNum}`,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _displayToasts() {
+ if (!this._patchRange) return;
+ if (!patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this._displayDiffBaseAgainstLeftToast();
+ return;
+ }
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (!patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this._displayDiffAgainstLatestToast(latestPatchNum);
+ return;
+ }
+ }
+
+ _initCommitRange() {
+ let commit: CommitId | undefined;
+ let baseCommit: CommitId | undefined;
+ if (!this._change) return;
+ if (!this._patchRange || !this._patchRange.patchNum) return;
+ for (const commitSha in this._change.revisions) {
+ if (!hasOwnProperty(this._change.revisions, commitSha)) continue;
+ const revision = this._change.revisions[commitSha];
+ const patchNum = revision._number;
+ if (patchNumEquals(patchNum, this._patchRange.patchNum)) {
+ commit = commitSha as CommitId;
+ const commitObj = revision.commit;
+ const parents = commitObj?.parents || [];
+ if (
+ patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum) &&
+ parents.length
+ ) {
+ baseCommit = parents[parents.length - 1].commit;
+ }
+ } else if (patchNumEquals(patchNum, this._patchRange.basePatchNum)) {
+ baseCommit = commitSha as CommitId;
+ }
+ }
+ this._commitRange = commit && baseCommit ? {commit, baseCommit} : undefined;
+ }
+
+ _initPatchRange() {
+ let leftSide = false;
+ if (!this._change) return;
+ if (this.params?.view !== GerritView.DIFF) return;
+ if (this.params?.commentId) {
+ const comment = this._changeComments?.findCommentById(
+ this.params.commentId
+ );
+ if (!comment) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'comment not found',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ GerritNav.navigateToChange(this._change);
+ return;
+ }
+ this._path = comment.path;
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (!comment.patch_set) throw new Error('Missing comment.patch_set');
+ if (!latestPatchNum) throw new Error('Missing _allPatchSets');
+ if (patchNumEquals(latestPatchNum, comment.patch_set)) {
+ this._patchRange = {
+ patchNum: latestPatchNum,
+ basePatchNum: ParentPatchSetNum,
+ };
+ leftSide = comment.__commentSide === 'left';
+ } else {
+ this._patchRange = {
+ patchNum: latestPatchNum,
+ basePatchNum: comment.patch_set,
+ };
+ // comment is now on the left side since we are showing
+ // comment.patch_set vs latest
+ leftSide = true;
+ }
+ this._focusLineNum = comment.line;
+ } else {
+ if (this.params.path) {
+ this._path = this.params.path;
+ }
+ if (this.params.patchNum) {
+ this._patchRange = {
+ patchNum: this.params.patchNum,
+ basePatchNum: this.params.basePatchNum || ParentPatchSetNum,
+ };
+ }
+ if (this.params.lineNum) {
+ this._focusLineNum = this.params.lineNum;
+ leftSide = !!this.params.leftSide;
+ }
+ }
+ if (!this._patchRange) throw new Error('Failed to initialize patchRange.');
+ this._initLineOfInterestAndCursor(leftSide);
+ this._commentMap = this._getPaths(this._patchRange);
+
+ this._commentsForDiff = this._getCommentsForPath(
+ this._path,
+ this._patchRange,
+ this._projectConfig
+ );
+ }
+
+ _isFileUnchanged(diff: DiffInfo) {
+ if (!diff || !diff.content) return false;
+ return !diff.content.some(
+ content =>
+ (content.a && !content.common) || (content.b && !content.common)
+ );
+ }
+
+ _paramsChanged(value: AppElementParams) {
+ if (value.view !== GerritView.DIFF) {
+ return;
+ }
+
+ this._change = undefined;
+ this._files = {sortedFileList: [], changeFilesByPath: {}};
+ this._path = undefined;
+ this._patchRange = undefined;
+ this._commitRange = undefined;
+ this._changeComments = undefined;
+ this._focusLineNum = undefined;
+
+ if (value.changeNum && value.project) {
+ this.$.restAPI.setInProjectLookup(value.changeNum, value.project);
+ }
+
+ this._changeNum = value.changeNum;
+ this.classList.remove('hideComments');
+
+ // When navigating away from the page, there is a possibility that the
+ // patch number is no longer a part of the URL (say when navigating to
+ // the top-level change info view) and therefore undefined in `params`.
+ // If route is of type /comment/<commentId>/ then no patchNum is present
+ if (!value.patchNum && !value.commentLink) {
+ console.warn('invalid url, no patchNum found');
+ return;
+ }
+
+ const portedCommentsPromise = this.$.commentAPI.getPortedComments(
+ value.changeNum,
+ value.patchNum || 'current'
+ );
+
+ const promises: Promise<unknown>[] = [];
+
+ promises.push(this._getDiffPreferences());
+
+ promises.push(
+ this._getPreferences().then(prefs => {
+ this._userPrefs = prefs;
+ })
+ );
+
+ promises.push(this._getChangeDetail(this._changeNum));
+ promises.push(this._loadComments());
+
+ promises.push(this._getChangeEdit());
+
+ this.$.diffHost.cancel();
+ this.$.diffHost.clearDiffContent();
+ this._loading = true;
+ return Promise.all(promises)
+ .then(r => {
+ this.reporting.time(PORTING_COMMENTS_DIFF_LATENCY_LABEL);
+ this._loading = false;
+ this._initPatchRange();
+ this._initCommitRange();
+ this.$.diffHost.comments = this._commentsForDiff;
+ portedCommentsPromise.then(() => {
+ this.reporting.timeEnd(PORTING_COMMENTS_DIFF_LATENCY_LABEL);
+ });
+ const edit = r[4] as EditInfo | undefined;
+ if (edit) {
+ this.set(`_change.revisions.${edit.commit.commit}`, {
+ _number: EditPatchSetNum,
+ basePatchNum: edit.base_patch_set_number,
+ commit: edit.commit,
+ });
+ }
+ return this.$.diffHost.reload(true);
+ })
+ .then(() => {
+ this.reporting.diffViewFullyLoaded();
+ // If diff view displayed has not ended yet, it ends here.
+ this.reporting.diffViewDisplayed();
+ })
+ .then(() => {
+ if (!this._diff) throw new Error('Missing this._diff');
+ const fileUnchanged = this._isFileUnchanged(this._diff);
+ if (fileUnchanged && value.commentLink) {
+ if (!this._change) throw new Error('Missing this._change');
+ if (!this._path) throw new Error('Missing this._path');
+ if (!this._patchRange) throw new Error('Missing this._patchRange');
+
+ if (this._patchRange.basePatchNum === ParentPatchSetNum) {
+ // file is unchanged between Base vs X
+ // hence should not show diff between Base vs Base
+ return;
+ }
+
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: `File is unchanged between Patchset
+ ${this._patchRange.basePatchNum} and
+ ${this._patchRange.patchNum}. Showing diff of Base vs
+ ${this._patchRange.basePatchNum}`,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ GerritNav.navigateToDiff(
+ this._change,
+ this._path,
+ this._patchRange.basePatchNum,
+ ParentPatchSetNum,
+ this._focusLineNum
+ );
+ return;
+ }
+ if (value.commentLink) {
+ this._displayToasts();
+ }
+ // If the blame was loaded for a previous file and user navigates to
+ // another file, then we load the blame for this file too
+ if (this._isBlameLoaded) this._loadBlame();
+ });
+ }
+
+ _changeViewStateChanged(changeViewState: Partial<ChangeViewState>) {
+ if (changeViewState.diffMode === null) {
+ // If screen size is small, always default to unified view.
+ this.$.restAPI.getPreferences().then(prefs => {
+ if (prefs) {
+ this.set('changeViewState.diffMode', prefs.default_diff_view);
+ }
+ });
+ }
+ }
+
+ @observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
+ _setReviewedObserver(
+ _loggedIn?: boolean,
+ paramsRecord?: ElementPropertyDeepChange<GrDiffView, 'params'>,
+ _prefs?: DiffPreferencesInfo,
+ patchRangeRecord?: ElementPropertyDeepChange<GrDiffView, '_patchRange'>
+ ) {
+ if (_loggedIn === undefined) return;
+ if (paramsRecord === undefined) return;
+ if (_prefs === undefined) return;
+ if (patchRangeRecord === undefined) return;
+ if (patchRangeRecord.base === undefined) return;
+
+ const patchRange = patchRangeRecord.base;
+ if (!_loggedIn) {
+ return;
+ }
+
+ if (_prefs.manual_review) {
+ // Checkbox state needs to be set explicitly only when manual_review
+ // is specified.
+
+ if (patchRange.patchNum) {
+ this._getReviewedStatus(
+ this._editMode,
+ this._changeNum,
+ patchRange.patchNum,
+ this._path
+ ).then((status: boolean) => {
+ this.$.reviewed.checked = status;
+ });
+ }
+ return;
+ }
+
+ if (paramsRecord.base?.view === GerritNav.View.DIFF) {
+ this._setReviewed(true);
+ }
+ }
+
+ /**
+ * If the params specify a diff address then configure the diff cursor.
+ */
+ _initCursor(leftSide: boolean) {
+ if (this._focusLineNum === undefined) {
+ return;
+ }
+ if (leftSide) {
+ this.$.cursor.side = Side.LEFT;
+ } else {
+ this.$.cursor.side = Side.RIGHT;
+ }
+ this.$.cursor.initialLineNumber = this._focusLineNum;
+ }
+
+ _getLineOfInterest(leftSide: boolean): LineOfInterest | undefined {
+ // If there is a line number specified, pass it along to the diff so that
+ // it will not get collapsed.
+ if (!this._focusLineNum) {
+ return undefined;
+ }
+
+ return {number: this._focusLineNum, leftSide};
+ }
+
+ _pathChanged(path: string) {
+ if (path) {
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: computeTruncatedPath(path)},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ if (!this._fileList || this._fileList.length === 0) return;
+
+ this.set('changeViewState.selectedFileIndex', this._fileList.indexOf(path));
+ }
+
+ _getDiffUrl(change?: ChangeInfo, patchRange?: PatchRange, path?: string) {
+ if (!change || !patchRange || !path) return '';
+ return GerritNav.getUrlForDiff(
+ change,
+ path,
+ patchRange.patchNum,
+ patchRange.basePatchNum
+ );
+ }
+
+ _patchRangeStr(patchRange: PatchRange) {
+ let patchStr = `${patchRange.patchNum}`;
+ if (
+ patchRange.basePatchNum &&
+ patchRange.basePatchNum !== ParentPatchSetNum
+ ) {
+ patchStr = `${patchRange.basePatchNum}..${patchRange.patchNum}`;
+ }
+ return patchStr;
+ }
+
+ /**
+ * When the latest patch of the change is selected (and there is no base
+ * patch) then the patch range need not appear in the URL. Return a patch
+ * range object with undefined values when a range is not needed.
+ */
+ _getChangeUrlRange(
+ patchRange?: PatchRange,
+ revisions?: {[revisionId: string]: RevisionInfo}
+ ) {
+ let patchNum = undefined;
+ let basePatchNum = undefined;
+ let latestPatchNum = -1;
+ for (const rev of Object.values(revisions || {})) {
+ if (typeof rev._number === 'number') {
+ latestPatchNum = Math.max(latestPatchNum, rev._number);
+ }
+ }
+ if (!patchRange) return {patchNum, basePatchNum};
+ if (
+ patchRange.basePatchNum !== ParentPatchSetNum ||
+ !patchNumEquals(patchRange.patchNum, latestPatchNum as PatchSetNum)
+ ) {
+ patchNum = patchRange.patchNum;
+ basePatchNum = patchRange.basePatchNum;
+ }
+ return {patchNum, basePatchNum};
+ }
+
+ _getChangePath(
+ change?: ChangeInfo,
+ patchRange?: PatchRange,
+ revisions?: {[revisionId: string]: RevisionInfo}
+ ) {
+ if (!change) return '';
+ if (!patchRange) return '';
+
+ const range = this._getChangeUrlRange(patchRange, revisions);
+ return GerritNav.getUrlForChange(
+ change,
+ range.patchNum,
+ range.basePatchNum
+ );
+ }
+
+ _navigateToChange(
+ change?: ChangeInfo,
+ patchRange?: PatchRange,
+ revisions?: {[revisionId: string]: RevisionInfo}
+ ) {
+ if (!change) return;
+ const range = this._getChangeUrlRange(patchRange, revisions);
+ GerritNav.navigateToChange(change, range.patchNum, range.basePatchNum);
+ }
+
+ _computeChangePath(
+ change?: ChangeInfo,
+ patchRangeRecord?: PolymerDeepPropertyChange<PatchRange, PatchRange>,
+ revisions?: {[revisionId: string]: RevisionInfo}
+ ) {
+ if (!patchRangeRecord) return '';
+ return this._getChangePath(change, patchRangeRecord.base, revisions);
+ }
+
+ _formatFilesForDropdown(
+ files?: Files,
+ patchNum?: PatchSetNum,
+ changeComments?: ChangeComments
+ ): DropdownItem[] {
+ if (!files) return [];
+ if (!patchNum) return [];
+ if (!changeComments) return [];
+
+ const dropdownContent: DropdownItem[] = [];
+ for (const path of files.sortedFileList) {
+ dropdownContent.push({
+ text: computeDisplayPath(path),
+ mobileText: computeTruncatedPath(path),
+ value: path,
+ bottomText: this._computeCommentString(
+ changeComments,
+ patchNum,
+ path,
+ files.changeFilesByPath[path]
+ ),
+ });
+ }
+ return dropdownContent;
+ }
+
+ _computeCommentString(
+ changeComments?: ChangeComments,
+ patchNum?: PatchSetNum,
+ path?: string,
+ changeFileInfo?: FileInfo
+ ) {
+ if (!changeComments) return '';
+ if (!path) return '';
+ if (!changeFileInfo) return '';
+
+ const unresolvedCount = changeComments.computeUnresolvedNum({
+ patchNum,
+ path,
+ });
+ const commentThreadCount = changeComments.computeCommentThreadCount({
+ patchNum,
+ path,
+ });
+ const commentThreadString = GrCountStringFormatter.computePluralString(
+ commentThreadCount,
+ 'comment'
+ );
+ const unresolvedString = GrCountStringFormatter.computeString(
+ unresolvedCount,
+ 'unresolved'
+ );
+
+ const unmodifiedString = changeFileInfo.status === 'U' ? 'no changes' : '';
+
+ return [unmodifiedString, commentThreadString, unresolvedString]
+ .filter(v => v && v.length > 0)
+ .join(', ');
+ }
+
+ _computePrefsButtonHidden(
+ prefs?: DiffPreferencesInfo,
+ prefsDisabled?: boolean
+ ) {
+ return prefsDisabled || !prefs;
+ }
+
+ _handleFileChange(e: CustomEvent) {
+ if (!this._change) return;
+ if (!this._patchRange) return;
+
+ // This is when it gets set initially.
+ const path = e.detail.value;
+ if (path === this._path) {
+ return;
+ }
+
+ GerritNav.navigateToDiff(
+ this._change,
+ path,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _handlePatchChange(e: CustomEvent) {
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ const {basePatchNum, patchNum} = e.detail;
+ if (
+ patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
+ patchNumEquals(patchNum, this._patchRange.patchNum)
+ ) {
+ return;
+ }
+ GerritNav.navigateToDiff(this._change, this._path, patchNum, basePatchNum);
+ }
+
+ _handlePrefsTap(e: Event) {
+ e.preventDefault();
+ this.$.diffPreferencesDialog.open();
+ }
+
+ /**
+ * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
+ * the current state.
+ *
+ * The expected behavior is to use the mode specified in the user's
+ * preferences unless they have manually chosen the alternative view or they
+ * are on a mobile device. If the user navigates up to the change view, it
+ * should clear this choice and revert to the preference the next time a
+ * diff is viewed.
+ *
+ * Use side-by-side if the user is not logged in.
+ */
+ _getDiffViewMode() {
+ if (this.changeViewState.diffMode) {
+ return this.changeViewState.diffMode;
+ } else if (this._userPrefs) {
+ this.set('changeViewState.diffMode', this._userPrefs.default_diff_view);
+ return this._userPrefs.default_diff_view;
+ } else {
+ return 'SIDE_BY_SIDE';
+ }
+ }
+
+ _computeModeSelectHideClass(diff?: DiffInfo) {
+ return !diff || diff.binary ? 'hide' : '';
+ }
+
+ _onLineSelected(
+ _: Event,
+ detail: {side: Side | CommentSide; number: number}
+ ) {
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._changeNum) return;
+ if (!this._patchRange) return;
+
+ const number = detail.number;
+ // for on-comment-anchor-tap side can be PARENT/REVISIONS
+ // for on-line-selected side can be left/right
+ const leftSide =
+ detail.side === Side.LEFT || detail.side === CommentSide.PARENT;
+ const url = GerritNav.getUrlForDiffById(
+ this._changeNum,
+ this._change.project,
+ this._path,
+ this._patchRange.patchNum,
+ this._patchRange.basePatchNum,
+ number,
+ leftSide
+ );
+ history.replaceState(null, '', url);
+ }
+
+ _computeDownloadDropdownLinks(
+ project?: RepoName,
+ changeNum?: NumericChangeId,
+ patchRange?: PatchRange,
+ path?: string,
+ diff?: DiffInfo
+ ) {
+ if (!project) return [];
+ if (!changeNum) return [];
+ if (!patchRange || !patchRange.patchNum) return [];
+ if (!path) return [];
+
+ const links = [
+ {
+ url: this._computeDownloadPatchLink(
+ project,
+ changeNum,
+ patchRange,
+ path
+ ),
+ name: 'Patch',
+ },
+ ];
+
+ if (diff && diff.meta_a) {
+ let leftPath = path;
+ if (diff.change_type === 'RENAMED') {
+ leftPath = diff.meta_a.name;
+ }
+ links.push({
+ url: this._computeDownloadFileLink(
+ project,
+ changeNum,
+ patchRange,
+ leftPath,
+ true
+ ),
+ name: 'Left Content',
+ });
+ }
+
+ if (diff && diff.meta_b) {
+ links.push({
+ url: this._computeDownloadFileLink(
+ project,
+ changeNum,
+ patchRange,
+ path,
+ false
+ ),
+ name: 'Right Content',
+ });
+ }
+
+ return links;
+ }
+
+ _computeDownloadFileLink(
+ project: RepoName,
+ changeNum: NumericChangeId,
+ patchRange: PatchRange,
+ path: string,
+ isBase?: boolean
+ ) {
+ let patchNum = patchRange.patchNum;
+
+ const comparedAgainsParent = patchRange.basePatchNum === 'PARENT';
+
+ if (isBase && !comparedAgainsParent) {
+ patchNum = patchRange.basePatchNum;
+ }
+
+ let url =
+ changeBaseURL(project, changeNum, patchNum) +
+ `/files/${encodeURIComponent(path)}/download`;
+
+ if (isBase && comparedAgainsParent) {
+ url += '?parent=1';
+ }
+
+ return url;
+ }
+
+ _computeDownloadPatchLink(
+ project: RepoName,
+ changeNum: NumericChangeId,
+ patchRange: PatchRange,
+ path: string
+ ) {
+ let url = changeBaseURL(project, changeNum, patchRange.patchNum);
+ url += '/patch?zip&path=' + encodeURIComponent(path);
+ return url;
+ }
+
+ _loadComments() {
+ if (!this._changeNum) throw new Error('Missing this._changeNum');
+ return this.$.commentAPI.loadAll(this._changeNum).then(comments => {
+ this._changeComments = comments;
+ });
+ }
+
+ @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
+ _recomputeComments(
+ files?: {[path: string]: FileInfo},
+ path?: string,
+ patchRange?: PatchRange,
+ projectConfig?: ConfigInfo
+ ) {
+ if (!files) return;
+ if (!path) return;
+ if (!patchRange) return;
+ if (!projectConfig) return;
+ if (!this._changeComments) return;
+
+ const file = files[path];
+ if (file && file.old_path) {
+ this._commentsForDiff = this._changeComments.getCommentsBySideForFile(
+ {path, basePath: file.old_path},
+ patchRange,
+ projectConfig
+ );
+
+ this.$.diffHost.comments = this._commentsForDiff;
+ }
+ }
+
+ _getPaths(patchRange: PatchRange) {
+ if (!this._changeComments) return {};
+ return this._changeComments.getPaths(patchRange);
+ }
+
+ _getCommentsForPath(
+ path?: string,
+ patchRange?: PatchRange,
+ projectConfig?: ConfigInfo
+ ) {
+ if (!path) return undefined;
+ if (!patchRange) return undefined;
+ if (!this._changeComments) return undefined;
+
+ return this._changeComments.getCommentsBySideForPath(
+ path,
+ patchRange,
+ projectConfig
+ );
+ }
+
+ _getDiffDrafts() {
+ if (!this._changeNum) throw new Error('Missing this._changeNum');
+
+ return this.$.restAPI.getDiffDrafts(this._changeNum);
+ }
+
+ _computeCommentSkips(
+ commentMap?: CommentMap,
+ fileList?: string[],
+ path?: string
+ ) {
+ if (!commentMap) return undefined;
+ if (!fileList) return undefined;
+ if (!path) return undefined;
+
+ const skips: CommentSkips = {previous: null, next: null};
+ if (!fileList.length) {
+ return skips;
+ }
+ const pathIndex = fileList.indexOf(path);
+
+ // Scan backward for the previous file.
+ for (let i = pathIndex - 1; i >= 0; i--) {
+ if (commentMap[fileList[i]]) {
+ skips.previous = fileList[i];
+ break;
+ }
+ }
+
+ // Scan forward for the next file.
+ for (let i = pathIndex + 1; i < fileList.length; i++) {
+ if (commentMap[fileList[i]]) {
+ skips.next = fileList[i];
+ break;
+ }
+ }
+
+ return skips;
+ }
+
+ _computeContainerClass(editMode: boolean) {
+ return editMode ? 'editMode' : '';
+ }
+
+ _computeEditMode(
+ patchRangeRecord: PolymerDeepPropertyChange<PatchRange, PatchRange>
+ ) {
+ const patchRange = patchRangeRecord.base || {};
+ return patchNumEquals(patchRange.patchNum, EditPatchSetNum);
+ }
+
+ _computeBlameToggleLabel(loaded?: boolean, loading?: boolean) {
+ return loaded && !loading ? 'Hide blame' : 'Show blame';
+ }
+
+ _loadBlame() {
+ this._isBlameLoading = true;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: MSG_LOADING_BLAME},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this.$.diffHost
+ .loadBlame()
+ .then(() => {
+ this._isBlameLoading = false;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: MSG_LOADED_BLAME},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ })
+ .catch(() => {
+ this._isBlameLoading = false;
+ });
+ }
+
+ /**
+ * Load and display blame information if it has not already been loaded.
+ * Otherwise hide it.
+ */
+ _toggleBlame() {
+ if (this._isBlameLoaded) {
+ this.$.diffHost.clearBlame();
+ return;
+ }
+ this._loadBlame();
+ }
+
+ _handleToggleBlame(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ this._toggleBlame();
+ }
+
+ _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+
+ this.toggleClass('hideComments');
+ }
+
+ _handleOpenFileList(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (this.modifierPressed(e)) return;
+ this.$.dropdown.open();
+ }
+
+ _handleDiffAgainstBase(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Base is already selected.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToDiff(
+ this._change,
+ this._path,
+ this._patchRange.patchNum
+ );
+ }
+
+ _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ if (patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Left is already base.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToDiff(
+ this._change,
+ this._path,
+ this._patchRange.basePatchNum,
+ 'PARENT' as PatchSetNum,
+ this.params?.view === GerritView.DIFF && this.params?.commentLink
+ ? this._focusLineNum
+ : undefined
+ );
+ }
+
+ _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Latest is already selected.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+
+ GerritNav.navigateToDiff(
+ this._change,
+ this._path,
+ latestPatchNum,
+ this._patchRange.basePatchNum
+ );
+ }
+
+ _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (patchNumEquals(this._patchRange.patchNum, latestPatchNum)) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Right is already latest.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToDiff(
+ this._change,
+ this._path,
+ latestPatchNum,
+ this._patchRange.patchNum
+ );
+ }
+
+ _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._change) return;
+ if (!this._path) return;
+ if (!this._patchRange) return;
+
+ const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
+ if (
+ patchNumEquals(this._patchRange.patchNum, latestPatchNum) &&
+ patchNumEquals(this._patchRange.basePatchNum, ParentPatchSetNum)
+ ) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Already diffing base against latest.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ GerritNav.navigateToDiff(this._change, this._path, latestPatchNum);
+ }
+
+ _computeBlameLoaderClass(isImageDiff?: boolean, path?: string) {
+ return !isMagicPath(path) && !isImageDiff ? 'show' : '';
+ }
+
+ _getRevisionInfo(change: ChangeInfo) {
+ return new RevisionInfoObj(change);
+ }
+
+ _computeFileNum(file?: string, files?: DropdownItem[]) {
+ if (!file || !files) return undefined;
+
+ return files.findIndex(({value}) => value === file) + 1;
+ }
+
+ _computeFileNumClass(fileNum?: number, files?: DropdownItem[]) {
+ if (files && fileNum && fileNum > 0) {
+ return 'show';
+ }
+ return '';
+ }
+
+ _handleExpandAllDiffContext(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+
+ this.$.diffHost.expandAllContext();
+ }
+
+ _computeDiffPrefsDisabled(disableDiffPrefs?: boolean, loggedIn?: boolean) {
+ return disableDiffPrefs || !loggedIn;
+ }
+
+ _handleNextUnreviewedFile(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ if (!this._path) return;
+ if (!this._fileList) return;
+ if (!this._reviewedFiles) return;
+
+ this._setReviewed(true);
+ // Ensure that the currently viewed file always appears in unreviewedFiles
+ // so we resolve the right "next" file.
+ const unreviewedFiles = this._fileList.filter(
+ file => file === this._path || !this._reviewedFiles.has(file)
+ );
+ this._navToFile(this._path, unreviewedFiles, 1);
+ }
+
+ _handleReloadingDiffPreference() {
+ this._getDiffPreferences();
+ }
+
+ _computeCanEdit(
+ loggedIn?: boolean,
+ changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
+ ) {
+ if (!changeChangeRecord?.base) return false;
+ return loggedIn && changeIsOpen(changeChangeRecord.base);
+ }
+
+ _computeIsLoggedIn(loggedIn: boolean) {
+ return loggedIn ? true : false;
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeAllPatchSets(change: ChangeInfo) {
+ return computeAllPatchSets(change);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeDisplayPath(path: string) {
+ return computeDisplayPath(path);
+ }
+
+ /**
+ * Wrapper for using in the element template and computed properties
+ */
+ _computeTruncatedPath(path?: string) {
+ return path ? computeTruncatedPath(path) : '';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-view': GrDiffView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
index e2cb880..be2dce5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_html.ts
@@ -19,6 +19,7 @@
export const htmlTemplate = html`
<style include="shared-styles">
:host {
+ display: block;
background-color: var(--view-background-color);
}
.hidden {
@@ -33,10 +34,16 @@
border-bottom: 1px solid var(--border-color);
}
}
- gr-fixed-panel {
+ .stickyHeader {
background-color: var(--view-background-color);
- border-bottom: 1px solid var(--border-color);
+ position: sticky;
+ top: 0;
+ /* TODO(dhruvsri): This is required only because of 'position:relative' in
+ <gr-diff-highlight> (which could maybe be removed??). */
z-index: 1;
+ box-shadow: var(--elevation-level-1);
+ /* This is just for giving the box-shadow some space. */
+ margin-bottom: 2px;
}
header,
.subHeader {
@@ -73,7 +80,9 @@
.reviewed {
display: inline-block;
margin: 0 var(--spacing-xs);
- vertical-align: 0.15em;
+ vertical-align: top;
+ position: relative;
+ top: 8px;
}
.jumpToFileContainer {
display: inline-block;
@@ -200,13 +209,10 @@
--gr-comment-thread-display: none;
}
</style>
- <gr-fixed-panel
- class$="[[_computeContainerClass(_editMode)]]"
- floating-disabled="[[_panelFloatingDisabled]]"
- keep-on-scroll=""
- ready-for-measure="[[!_loading]]"
- on-floating-height-changed="_onChangeHeaderPanelHeightChanged"
- >
+ <div class$="stickyHeader [[_computeContainerClass(_editMode)]]">
+ <h1 class="assistive-tech-only">
+ Diff of [[_computeTruncatedPath(_path)]]
+ </h1>
<header>
<div>
<a
@@ -222,6 +228,8 @@
on-change="_handleReviewedChange"
hidden$="[[!_loggedIn]]"
hidden=""
+ title="Toggle reviewed status of file"
+ aria-label="file reviewed"
/><!--
-->
<div class="jumpToFileContainer">
@@ -231,6 +239,7 @@
on-value-change="_handleFileChange"
items="[[_formattedFiles]]"
initial-count="75"
+ show-copy-for-trigger-text
>
</gr-dropdown-list>
</div>
@@ -379,17 +388,18 @@
></a
>
</div>
- </gr-fixed-panel>
+ </div>
<div class="loading" hidden$="[[!_loading]]">Loading...</div>
+ <h2 class="assistive-tech-only">Diff view</h2>
<gr-diff-host
id="diffHost"
hidden=""
hidden$="[[_loading]]"
- class$="[[_computeDiffClass(_panelFloatingDisabled)]]"
is-image-diff="{{_isImageDiff}}"
files-weblinks="{{_filesWeblinks}}"
diff="{{_diff}}"
change-num="[[_changeNum]]"
+ change="[[_change]]"
commit-range="[[_commitRange]]"
patch-range="[[_patchRange]]"
file="[[_file]]"
@@ -419,7 +429,6 @@
<gr-storage id="storage"></gr-storage>
<gr-diff-cursor
id="cursor"
- scroll-top-margin="[[_scrollTopMargin]]"
on-navigate-to-next-unreviewed-file="_handleNextUnreviewedFile"
></gr-diff-cursor>
<gr-comment-api id="commentAPI"></gr-comment-api>
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 ce36a06..9a08723 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
@@ -17,12 +17,18 @@
import '../../../test/common-test-setup-karma.js';
import './gr-diff-view.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {ChangeStatus} from '../../../constants/constants.js';
-import {generateChange, TestKeyboardShortcutBinder} from '../../../test/test-utils';
+import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {_testOnly_findCommentById} from '../gr-comment-api/gr-comment-api.js';
+import {appContext} from '../../../services/app-context.js';
+import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
+import {
+ createChange,
+ createRevisions,
+} from '../../../test/test-data-generators.js';
const basicFixture = fixtureFromElement('gr-diff-view');
@@ -31,6 +37,7 @@
suite('gr-diff-view tests', () => {
suite('basic tests', () => {
let element;
+ let clock;
suiteSetup(() => {
const kb = TestKeyboardShortcutBinder.push();
@@ -49,6 +56,7 @@
kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
kb.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
+ kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
kb.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
@@ -60,6 +68,7 @@
kb.bindShortcut(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, 'shift+e');
kb.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
kb.bindShortcut(Shortcut.TOGGLE_BLAME, 'b');
+ kb.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
});
suiteTeardown(() => {
@@ -79,7 +88,9 @@
};
}
- setup(() => {
+ setup(async () => {
+ clock = sinon.useFakeTimers();
+ sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
stub('gr-rest-api-interface', {
getConfig() {
return Promise.resolve({change: {}});
@@ -113,47 +124,240 @@
},
});
element = basicFixture.instantiate();
- return element._loadComments();
+ element._changeNum = '42';
+ element._path = 'some/path.txt';
+ element._change = {};
+ element._diff = {content: []};
+ element._patchRange = {
+ patchNum: 77,
+ basePatchNum: 'PARENT',
+ };
+ sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
+ _comments: {'/COMMIT_MSG': [
+ {
+ id: 'c1',
+ line: 10,
+ patch_set: 2,
+ __commentSide: 'left',
+ path: '/COMMIT_MSG',
+ }, {
+ id: 'c3',
+ line: 10,
+ patch_set: 'PARENT',
+ __commentSide: 'left',
+ path: '/COMMIT_MSG',
+ },
+ ]},
+ computeCommentThreadCount: () => {},
+ computeUnresolvedNum: () => {},
+ getPaths: () => {},
+ getCommentsBySideForPath: () => {},
+ findCommentById: _testOnly_findCommentById,
+ }));
+ await element._loadComments();
+ await flush();
+ });
+
+ teardown(() => {
+ clock.restore();
+ sinon.restore();
});
test('params change triggers diffViewDisplayed()', () => {
sinon.stub(element.reporting, 'diffViewDisplayed');
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+ sinon.stub(element, '_initPatchRange');
+ sinon.stub(element, '_getFiles');
sinon.spy(element, '_paramsChanged');
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
- patchNum: '2',
- basePatchNum: '1',
+ patchNum: 2,
+ basePatchNum: 1,
path: '/COMMIT_MSG',
};
-
return element._paramsChanged.returnValues[0].then(() => {
assert.isTrue(element.reporting.diffViewDisplayed.calledOnce);
});
});
- test('params change cases blame to load if it was set to true', () => {
+ test('comment route', () => {
+ const initLineOfInterestAndCursorStub =
+ sinon.stub(element, '_initLineOfInterestAndCursor');
+ sinon.stub(element, '_getFiles');
+ sinon.stub(element.reporting, 'diffViewDisplayed');
+ sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+ sinon.spy(element, '_paramsChanged');
+ sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+ ...createChange(),
+ revisions: createRevisions(11),
+ }));
+ element.params = {
+ view: GerritNav.View.DIFF,
+ changeNum: '42',
+ commentLink: true,
+ commentId: 'c1',
+ };
+ sinon.stub(element.$.diffHost, '_commentsChanged');
+ sinon.stub(element, '_getCommentsForPath').returns({
+ left: [{id: 'c1', __commentSide: 'left', line: 10}],
+ right: [{id: 'c2', __commentSide: 'right', line: 11}],
+ });
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(11),
+ };
+ return element._paramsChanged.returnValues[0].then(() => {
+ assert.isTrue(initLineOfInterestAndCursorStub.
+ calledWithExactly(true));
+ assert.equal(element._focusLineNum, 10);
+ assert.equal(element._patchRange.patchNum, 11);
+ assert.equal(element._patchRange.basePatchNum, 2);
+ });
+ });
+
+ test('params change causes blame to load if it was set to true', () => {
// Blame loads for subsequent files if it was loaded for one file
element._isBlameLoaded = true;
sinon.stub(element.reporting, 'diffViewDisplayed');
sinon.stub(element, '_loadBlame');
sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
sinon.spy(element, '_paramsChanged');
+ sinon.stub(element, '_initPatchRange');
+ sinon.stub(element, '_getFiles');
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
- patchNum: '2',
- basePatchNum: '1',
+ patchNum: 2,
+ basePatchNum: 1,
path: '/COMMIT_MSG',
};
-
return element._paramsChanged.returnValues[0].then(() => {
assert.isTrue(element._isBlameLoaded);
assert.isTrue(element._loadBlame.calledOnce);
});
});
+ test('unchanged diff X vs latest from comment links navigates to base vs X'
+ , () => {
+ const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+ sinon.stub(element.reporting, 'diffViewDisplayed');
+ sinon.stub(element, '_loadBlame');
+ sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+ sinon.stub(element, '_isFileUnchanged').returns(true);
+ sinon.spy(element, '_paramsChanged');
+ sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+ ...createChange(),
+ revisions: createRevisions(11),
+ }));
+ element.params = {
+ view: GerritNav.View.DIFF,
+ changeNum: '42',
+ path: '/COMMIT_MSG',
+ commentLink: true,
+ commentId: 'c1',
+ };
+ sinon.stub(element.$.diffHost, '_commentsChanged');
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(11),
+ };
+ return element._paramsChanged.returnValues[0].then(() => {
+ assert.isTrue(diffNavStub.lastCall.calledWithExactly(
+ element._change, '/COMMIT_MSG', 2, 'PARENT', 10));
+ });
+ });
+
+ test('unchanged diff Base vs latest from comment does not navigate'
+ , () => {
+ const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+ sinon.stub(element.reporting, 'diffViewDisplayed');
+ sinon.stub(element, '_loadBlame');
+ sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+ sinon.stub(element, '_isFileUnchanged').returns(true);
+ sinon.spy(element, '_paramsChanged');
+ sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
+ ...createChange(),
+ revisions: createRevisions(11),
+ }));
+ element.params = {
+ view: GerritNav.View.DIFF,
+ changeNum: '42',
+ path: '/COMMIT_MSG',
+ commentLink: true,
+ commentId: 'c3',
+ };
+ sinon.stub(element.$.diffHost, '_commentsChanged');
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(11),
+ };
+ return element._paramsChanged.returnValues[0].then(() => {
+ assert.isFalse(diffNavStub.called);
+ });
+ });
+
+ test('_isFileUnchanged', () => {
+ let diff = {
+ content: [
+ {a: 'abcd', ab: 'ef'},
+ {b: 'ancd', a: 'xx'},
+ ],
+ };
+ assert.equal(element._isFileUnchanged(diff), false);
+ diff = {
+ content: [
+ {ab: 'abcd'},
+ {ab: 'ancd'},
+ ],
+ };
+ assert.equal(element._isFileUnchanged(diff), true);
+ diff = {
+ content: [
+ {a: 'abcd', ab: 'ef', common: true},
+ {b: 'ancd', ab: 'xx'},
+ ],
+ };
+ assert.equal(element._isFileUnchanged(diff), false);
+ diff = {
+ content: [
+ {a: 'abcd', ab: 'ef', common: true},
+ {b: 'ancd', ab: 'xx', common: true},
+ ],
+ };
+ assert.equal(element._isFileUnchanged(diff), true);
+ });
+
+ test('diff toast to go to latest is shown and not base', async () => {
+ sinon.stub(element.reporting, 'diffViewDisplayed');
+ sinon.stub(element, '_loadBlame');
+ sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+ sinon.spy(element, '_paramsChanged');
+ element.$.restAPI.getDiffChangeDetail.restore();
+ sinon.stub(element.$.restAPI, 'getDiffChangeDetail')
+ .returns(
+ Promise.resolve({
+ ...createChange(),
+ revisions: createRevisions(11),
+ }));
+ element._patchRange = {
+ patchNum: 2,
+ basePatchNum: 1,
+ };
+ sinon.stub(element, '_isFileUnchanged').returns(false);
+ const toastStub =
+ sinon.stub(element, '_displayDiffBaseAgainstLeftToast');
+ element.params = {
+ view: GerritNav.View.DIFF,
+ changeNum: '42',
+ project: 'p',
+ commentId: 'c1',
+ commentLink: true,
+ };
+ await element._paramsChanged.returnValues[0];
+ assert.isTrue(toastStub.called);
+ });
+
test('toggle left diff with a hotkey', () => {
const toggleLeftDiffStub = sinon.stub(
element.$.diffHost, 'toggleLeftDiff');
@@ -165,7 +369,7 @@
element._changeNum = '42';
element._patchRange = {
basePatchNum: PARENT,
- patchNum: '10',
+ patchNum: 10,
};
element._change = {
_number: 42,
@@ -188,20 +392,20 @@
MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
- '10', PARENT), 'Should navigate to /c/42/10/wheatley.md');
+ 10, PARENT), 'Should navigate to /c/42/10/wheatley.md');
element._path = 'wheatley.md';
assert.equal(element.changeViewState.selectedFileIndex, 2);
assert.isTrue(element._loading);
MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
- '10', PARENT), 'Should navigate to /c/42/10/glados.txt');
+ 10, PARENT), 'Should navigate to /c/42/10/glados.txt');
element._path = 'glados.txt';
assert.equal(element.changeViewState.selectedFileIndex, 1);
assert.isTrue(element._loading);
MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
- assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', '10',
+ assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', 10,
PARENT), 'Should navigate to /c/42/10/chell.go');
element._path = 'chell.go';
assert.equal(element.changeViewState.selectedFileIndex, 0);
@@ -259,6 +463,11 @@
assert.isTrue(element._handleToggleFileReviewed.calledOnce);
MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+ assert.isTrue(element._handleToggleFileReviewed.calledOnce);
+
+ clock.tick(1000);
+
+ MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
assert.isTrue(element._handleToggleFileReviewed.calledTwice);
assert.isTrue(element._setReviewed.called);
assert.equal(element._setReviewed.lastCall.args[0], true);
@@ -267,14 +476,14 @@
test('shift+x shortcut expands all diff context', () => {
const expandStub = sinon.stub(element.$.diffHost, 'expandAllContext');
MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(expandStub.called);
});
test('diff against base', () => {
element._patchRange = {
- basePatchNum: '5',
- patchNum: '10',
+ basePatchNum: 5,
+ patchNum: 10,
};
sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
@@ -285,10 +494,13 @@
});
test('diff against latest', () => {
- element._change = generateChange({revisionsCount: 12});
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(12),
+ };
element._patchRange = {
- basePatchNum: '5',
- patchNum: '10',
+ basePatchNum: 5,
+ patchNum: 10,
};
sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
@@ -299,22 +511,53 @@
});
test('_handleDiffBaseAgainstLeft', () => {
- element._change = generateChange({revisionsCount: 10});
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(10),
+ };
element._patchRange = {
patchNum: 3,
basePatchNum: 1,
};
+ element.params = {};
sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
element._handleDiffBaseAgainstLeft(new CustomEvent(''));
assert(diffNavStub.called);
const args = diffNavStub.getCall(0).args;
assert.equal(args[2], 1);
- assert.isNotOk(args[3]);
+ assert.equal(args[3], 'PARENT');
+ assert.isNotOk(args[4]);
});
+ test('_handleDiffBaseAgainstLeft when initially navigating to a comment',
+ () => {
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(10),
+ };
+ element._patchRange = {
+ patchNum: 3,
+ basePatchNum: 1,
+ };
+ sinon.stub(element, '_paramsChanged');
+ element.params = {commentLink: true, view: GerritView.DIFF};
+ element._focusLineNum = 10;
+ sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
+ const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+ element._handleDiffBaseAgainstLeft(new CustomEvent(''));
+ assert(diffNavStub.called);
+ const args = diffNavStub.getCall(0).args;
+ assert.equal(args[2], 1);
+ assert.equal(args[3], 'PARENT');
+ assert.equal(args[4], 10);
+ });
+
test('_handleDiffRightAgainstLatest', () => {
- element._change = generateChange({revisionsCount: 10});
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(10),
+ };
element._patchRange = {
basePatchNum: 1,
patchNum: 3,
@@ -329,7 +572,10 @@
});
test('_handleDiffBaseAgainstLatest', () => {
- element._change = generateChange({revisionsCount: 10});
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(10),
+ };
element._patchRange = {
basePatchNum: 1,
patchNum: 3,
@@ -346,8 +592,8 @@
test('keyboard shortcuts with patch range', () => {
element._changeNum = '42';
element._patchRange = {
- basePatchNum: '5',
- patchNum: '10',
+ basePatchNum: 5,
+ patchNum: 10,
};
element._change = {
_number: 42,
@@ -373,24 +619,24 @@
MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
assert.isTrue(element.changeViewState.showReplyDialog);
- assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
- '5'), 'Should navigate to /c/42/5..10');
+ assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+ 5), 'Should navigate to /c/42/5..10');
MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
- assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
- '5'), 'Should navigate to /c/42/5..10');
+ assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+ 5), 'Should navigate to /c/42/5..10');
MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
assert.isTrue(element._loading);
assert(diffNavStub.lastCall.calledWithExactly(element._change,
- 'wheatley.md', '10', '5'),
+ 'wheatley.md', 10, 5),
'Should navigate to /c/42/5..10/wheatley.md');
element._path = 'wheatley.md';
MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
assert.isTrue(element._loading);
assert(diffNavStub.lastCall.calledWithExactly(element._change,
- 'glados.txt', '10', '5'),
+ 'glados.txt', 10, 5),
'Should navigate to /c/42/5..10/glados.txt');
element._path = 'glados.txt';
@@ -399,23 +645,27 @@
assert(diffNavStub.lastCall.calledWithExactly(
element._change,
'chell.go',
- '10',
- '5'),
+ 10,
+ 5),
'Should navigate to /c/42/5..10/chell.go');
element._path = 'chell.go';
MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
assert.isTrue(element._loading);
- assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
- '5'),
+ assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+ 5),
'Should navigate to /c/42/5..10');
+
+ assert.isUndefined(element.changeViewState.showDownloadDialog);
+ MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+ assert.isTrue(element.changeViewState.showDownloadDialog);
});
test('keyboard shortcuts with old patch number', () => {
element._changeNum = '42';
element._patchRange = {
basePatchNum: PARENT,
- patchNum: '1',
+ patchNum: 1,
};
element._change = {
_number: 42,
@@ -441,22 +691,22 @@
MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
assert.isTrue(element.changeViewState.showReplyDialog);
- assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+ assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
PARENT), 'Should navigate to /c/42/1');
MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
- assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+ assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
PARENT), 'Should navigate to /c/42/1');
MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
assert(diffNavStub.lastCall.calledWithExactly(element._change,
- 'wheatley.md', '1', PARENT),
+ 'wheatley.md', 1, PARENT),
'Should navigate to /c/42/1/wheatley.md');
element._path = 'wheatley.md';
MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
assert(diffNavStub.lastCall.calledWithExactly(element._change,
- 'glados.txt', '1', PARENT),
+ 'glados.txt', 1, PARENT),
'Should navigate to /c/42/1/glados.txt');
element._path = 'glados.txt';
@@ -464,13 +714,15 @@
assert(diffNavStub.lastCall.calledWithExactly(
element._change,
'chell.go',
- '1',
+ 1,
PARENT), 'Should navigate to /c/42/1/chell.go');
element._path = 'chell.go';
+ changeNavStub.reset();
MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
- assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+ assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
PARENT), 'Should navigate to /c/42/1');
+ assert.isTrue(changeNavStub.calledOnce);
});
test('edit should redirect to edit page', done => {
@@ -478,7 +730,7 @@
element._path = 't.txt';
element._patchRange = {
basePatchNum: PARENT,
- patchNum: '1',
+ patchNum: 1,
};
element._change = {
_number: 42,
@@ -512,7 +764,7 @@
element._path = 't.txt';
element._patchRange = {
basePatchNum: PARENT,
- patchNum: '1',
+ patchNum: 1,
};
element._change = {
_number: 42,
@@ -549,7 +801,7 @@
element._path = 't.txt';
element._patchRange = {
basePatchNum: PARENT,
- patchNum: '1',
+ patchNum: 1,
};
element._change = {
_number: 42,
@@ -604,20 +856,20 @@
test('when no prefs or logged out', () => {
element.disableDiffPrefs = false;
element._loggedIn = false;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.diffPrefsContainer.hidden);
element._loggedIn = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.diffPrefsContainer.hidden);
element._loggedIn = false;
element._prefs = {font_size: '12'};
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.diffPrefsContainer.hidden);
element._loggedIn = true;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.diffPrefsContainer.hidden);
});
@@ -625,11 +877,11 @@
element._loggedIn = true;
element._prefs = {font_size: '12'};
element.disableDiffPrefs = false;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.diffPrefsContainer.hidden);
element.disableDiffPrefs = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.diffPrefsContainer.hidden);
});
@@ -640,7 +892,7 @@
const overlayOpenStub = sinon.stub(element.$.diffPreferencesDialog,
'open');
const prefsButton =
- dom(element.root).querySelector('.prefsButton');
+ element.root.querySelector('.prefsButton');
MockInteractions.tap(prefsButton);
@@ -651,14 +903,14 @@
test('_computeCommentString', done => {
const path = '/test';
element.$.commentAPI.loadAll().then(comments => {
- const commentCountStub =
- sinon.stub(comments, 'computeCommentCount');
+ const commentThreadCountStub =
+ sinon.stub(comments, 'computeCommentThreadCount');
const unresolvedCountStub =
sinon.stub(comments, 'computeUnresolvedNum');
- commentCountStub.withArgs({patchNum: 1, path}).returns(0);
- commentCountStub.withArgs({patchNum: 2, path}).returns(1);
- commentCountStub.withArgs({patchNum: 3, path}).returns(2);
- commentCountStub.withArgs({patchNum: 4, path}).returns(0);
+ commentThreadCountStub.withArgs({patchNum: 1, path}).returns(0);
+ commentThreadCountStub.withArgs({patchNum: 2, path}).returns(1);
+ commentThreadCountStub.withArgs({patchNum: 3, path}).returns(2);
+ commentThreadCountStub.withArgs({patchNum: 4, path}).returns(0);
unresolvedCountStub.withArgs({patchNum: 1, path}).returns(1);
unresolvedCountStub.withArgs({patchNum: 2, path}).returns(0);
unresolvedCountStub.withArgs({patchNum: 3, path}).returns(2);
@@ -688,6 +940,7 @@
suite('url params', () => {
setup(() => {
+ sinon.stub(element, '_getFiles');
sinon.stub(
GerritNav,
'getUrlForDiff')
@@ -702,8 +955,11 @@
element._changeNum = '42';
element._patchRange = {
basePatchNum: PARENT,
- patchNum: '10',
+ patchNum: 10,
};
+ // computeCommentThreadCount is an empty function hence stubbing
+ // function that depends on it's return value
+ sinon.stub(element, '_computeCommentString').returns('');
element._change = {_number: 42};
element._files = getFilesFromFileList(
['chell.go', 'glados.txt', 'wheatley.md',
@@ -748,7 +1004,7 @@
element._changeNum = '42';
element._patchRange = {
basePatchNum: PARENT,
- patchNum: '10',
+ patchNum: 10,
};
element._change = {
_number: 42,
@@ -759,27 +1015,27 @@
element._files = getFilesFromFileList(
['chell.go', 'glados.txt', 'wheatley.md']);
element._path = 'glados.txt';
- flushAsynchronousOperations();
- const linkEls = dom(element.root).querySelectorAll('.navLink');
+ flush();
+ const linkEls = element.root.querySelectorAll('.navLink');
assert.equal(linkEls.length, 3);
assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
assert.equal(linkEls[2].getAttribute('href'),
'42-wheatley.md-10-PARENT');
element._path = 'wheatley.md';
- flushAsynchronousOperations();
+ flush();
assert.equal(linkEls[0].getAttribute('href'),
'42-glados.txt-10-PARENT');
assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
assert.isFalse(linkEls[2].hasAttribute('href'));
element._path = 'chell.go';
- flushAsynchronousOperations();
+ flush();
assert.isFalse(linkEls[0].hasAttribute('href'));
assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
assert.equal(linkEls[2].getAttribute('href'),
'42-glados.txt-10-PARENT');
element._path = 'not_a_real_file';
- flushAsynchronousOperations();
+ flush();
assert.equal(linkEls[0].getAttribute('href'),
'42-wheatley.md-10-PARENT');
assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
@@ -789,8 +1045,8 @@
test('prev/up/next links with patch range', () => {
element._changeNum = '42';
element._patchRange = {
- basePatchNum: '5',
- patchNum: '10',
+ basePatchNum: 5,
+ patchNum: 10,
};
element._change = {
_number: 42,
@@ -802,19 +1058,19 @@
element._files = getFilesFromFileList(
['chell.go', 'glados.txt', 'wheatley.md']);
element._path = 'glados.txt';
- flushAsynchronousOperations();
- const linkEls = dom(element.root).querySelectorAll('.navLink');
+ flush();
+ const linkEls = element.root.querySelectorAll('.navLink');
assert.equal(linkEls.length, 3);
assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
element._path = 'wheatley.md';
- flushAsynchronousOperations();
+ flush();
assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
assert.isFalse(linkEls[2].hasAttribute('href'));
element._path = 'chell.go';
- flushAsynchronousOperations();
+ flush();
assert.isFalse(linkEls[0].hasAttribute('href'));
assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
@@ -828,19 +1084,19 @@
element._patchRange = {
basePatchNum: 'PARENT',
- patchNum: '3',
+ patchNum: 3,
};
const detail = {
basePatchNum: 'PARENT',
- patchNum: '1',
+ patchNum: 1,
};
element.$.rangeSelect.dispatchEvent(
new CustomEvent('patch-range-change', {detail, bubbles: false}));
assert(navigateStub.lastCall.calledWithExactly(element._change,
- element._path, '1', 'PARENT'));
+ element._path, 1, 'PARENT'));
});
test('_prefs.manual_review is respected', () => {
@@ -854,18 +1110,22 @@
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
- patchNum: '2',
- basePatchNum: '1',
+ patchNum: 2,
+ basePatchNum: 1,
path: '/COMMIT_MSG',
};
+ element._patchRange = {
+ patchNum: 2,
+ basePatchNum: 1,
+ };
element._prefs = {manual_review: true};
- flushAsynchronousOperations();
+ flush();
assert.isFalse(saveReviewedStub.called);
assert.isTrue(getReviewedStub.called);
element._prefs = {};
- flushAsynchronousOperations();
+ flush();
assert.isTrue(saveReviewedStub.called);
assert.isTrue(getReviewedStub.calledOnce);
@@ -880,14 +1140,18 @@
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
- patchNum: '2',
- basePatchNum: '1',
+ patchNum: 2,
+ basePatchNum: 1,
path: '/COMMIT_MSG',
};
+ element._patchRange = {
+ patchNum: 2,
+ basePatchNum: 1,
+ };
element._prefs = {};
- flushAsynchronousOperations();
+ flush();
- const commitMsg = dom(element.root).querySelector(
+ const commitMsg = element.root.querySelector(
'input[type="checkbox"]');
assert.isTrue(commitMsg.checked);
@@ -901,7 +1165,7 @@
const callCount = saveReviewedStub.callCount;
element.set('params.view', GerritNav.View.CHANGE);
- flushAsynchronousOperations();
+ flush();
// saveReviewedState observer observes params, but should not fire when
// view !== GerritNav.View.DIFF.
@@ -912,7 +1176,7 @@
const saveReviewedStub = sinon.stub(element, '_saveReviewedState');
element._patchRange = {patchNum: SPECIAL_PATCH_SET_NUM.EDIT};
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element._editMode);
element._setReviewed();
@@ -921,20 +1185,20 @@
test('hash is determined from params', done => {
sinon.stub(element.$.diffHost, 'reload');
- sinon.stub(element, '_initCursor');
+ sinon.stub(element, '_initLineOfInterestAndCursor');
element._loggedIn = true;
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
- patchNum: '2',
- basePatchNum: '1',
+ patchNum: 2,
+ basePatchNum: 1,
path: '/COMMIT_MSG',
hash: 10,
};
flush(() => {
- assert.isTrue(element._initCursor.calledOnce);
+ assert.isTrue(element._initLineOfInterestAndCursor.calledOnce);
done();
});
});
@@ -974,14 +1238,14 @@
// Attach a new gr-diff-view so we can intercept the preferences fetch.
const view = document.createElement('gr-diff-view');
blankFixture.instantiate().appendChild(view);
- flushAsynchronousOperations();
+ flush();
// At this point the diff mode doesn't yet have the user's preference.
assert.equal(view._getDiffViewMode(), 'SIDE_BY_SIDE');
// Receive the overriding preference.
resolvePrefs({default_diff_view: 'UNIFIED'});
- flushAsynchronousOperations();
+ flush();
assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
});
@@ -997,39 +1261,43 @@
});
suite('_commitRange', () => {
+ const change = {
+ _number: 42,
+ revisions: {
+ 'commit-sha-1': {
+ _number: 1,
+ commit: {
+ parents: [{commit: 'sha-1-parent'}],
+ },
+ },
+ 'commit-sha-2': {_number: 2, commit: {parents: []}},
+ 'commit-sha-3': {_number: 3, commit: {parents: []}},
+ 'commit-sha-4': {_number: 4, commit: {parents: []}},
+ 'commit-sha-5': {
+ _number: 5,
+ commit: {
+ parents: [{commit: 'sha-5-parent'}],
+ },
+ },
+ },
+ };
setup(() => {
sinon.stub(element.$.diffHost, 'reload');
sinon.stub(element, '_initCursor');
- sinon.stub(element, '_getChangeDetail').returns(Promise.resolve({
- _number: 42,
- revisions: {
- 'commit-sha-1': {
- _number: 1,
- commit: {
- parents: [{commit: 'sha-1-parent'}],
- },
- },
- 'commit-sha-2': {_number: 2},
- 'commit-sha-3': {_number: 3},
- 'commit-sha-4': {_number: 4},
- 'commit-sha-5': {
- _number: 5,
- commit: {
- parents: [{commit: 'sha-5-parent'}],
- },
- },
- },
- }));
+ element._change = change;
+ sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(
+ change));
});
test('uses the patchNum and basePatchNum ', done => {
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
- patchNum: '4',
- basePatchNum: '2',
+ patchNum: 4,
+ basePatchNum: 2,
path: '/COMMIT_MSG',
};
+ element._change = change;
flush(() => {
assert.deepEqual(element._commitRange, {
baseCommit: 'commit-sha-2',
@@ -1043,9 +1311,10 @@
element.params = {
view: GerritNav.View.DIFF,
changeNum: '42',
- patchNum: '5',
+ patchNum: 5,
path: '/COMMIT_MSG',
};
+ element._change = change;
flush(() => {
assert.deepEqual(element._commitRange, {
commit: 'commit-sha-5',
@@ -1060,37 +1329,42 @@
assert.isNotOk(element.$.cursor.initialLineNumber);
// Does nothing when params specify no cursor address:
- element._initCursor({});
+ element._initCursor(false);
assert.isNotOk(element.$.cursor.initialLineNumber);
// Does nothing when params specify side but no number:
- element._initCursor({leftSide: true});
+ element._initCursor(true);
assert.isNotOk(element.$.cursor.initialLineNumber);
// Revision hash: specifies lineNum but not side.
- element._initCursor({lineNum: 234});
+
+ element._focusLineNum = 234;
+ element._initCursor(false);
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(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(false);
assert.equal(element.$.cursor.initialLineNumber, 123);
assert.equal(element.$.cursor.side, 'right');
});
test('_getLineOfInterest', () => {
- assert.isNull(element._getLineOfInterest({}));
+ assert.isUndefined(element._getLineOfInterest(false));
- let result = element._getLineOfInterest({lineNum: 12});
+ element._focusLineNum = 12;
+ let result = element._getLineOfInterest(false);
assert.equal(result.number, 12);
assert.isNotOk(result.leftSide);
- result = element._getLineOfInterest({lineNum: 12, leftSide: true});
+ result = element._getLineOfInterest(true);
assert.equal(result.number, 12);
assert.isOk(result.leftSide);
});
@@ -1104,8 +1378,8 @@
element._changeNum = 321;
element._change = {_number: 321, project: 'foo/bar'};
element._patchRange = {
- basePatchNum: '3',
- patchNum: '5',
+ basePatchNum: 3,
+ patchNum: 5,
};
const e = {};
const detail = {number: 123, side: 'right'};
@@ -1114,20 +1388,29 @@
assert.isTrue(replaceStateStub.called);
assert.isTrue(getUrlStub.called);
+ assert.isFalse(getUrlStub.lastCall.args[6]);
});
- test('_onLineSelected w/o line address', () => {
+ test('line selected on left side', () => {
const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
- sinon.stub(history, 'replaceState');
- sinon.stub(element.$.cursor, 'moveToLineNumber');
- sinon.stub(element.$.cursor, 'getAddress').returns(null);
+ const replaceStateStub = sinon.stub(history, 'replaceState');
+ sinon.stub(element.$.cursor, 'getAddress')
+ .returns({number: 123, isLeftSide: true});
+
element._changeNum = 321;
element._change = {_number: 321, project: 'foo/bar'};
- element._patchRange = {basePatchNum: '3', patchNum: '5'};
- element._onLineSelected({}, {number: 123, side: 'right'});
- assert.isTrue(getUrlStub.calledOnce);
- assert.isUndefined(getUrlStub.lastCall.args[5]);
- assert.isUndefined(getUrlStub.lastCall.args[6]);
+ element._patchRange = {
+ basePatchNum: 3,
+ patchNum: 5,
+ };
+ const e = {};
+ const detail = {number: 123, side: 'left'};
+
+ element._onLineSelected(e, detail);
+
+ assert.isTrue(replaceStateStub.called);
+ assert.isTrue(getUrlStub.called);
+ assert.isTrue(getUrlStub.lastCall.args[6]);
});
test('_getDiffViewMode', () => {
@@ -1156,15 +1439,24 @@
assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
});
- suite('_loadComments', () => {
- test('empty', done => {
- element._loadComments().then(() => {
- assert.equal(Object.keys(element._commentMap).length, 0);
- done();
- });
+ suite('_initPatchRange', () => {
+ setup(async () => {
+ element.params = {
+ view: GerritView.DIFF,
+ changeNum: '42',
+ patchNum: 3,
+ };
+ await flush();
+ });
+ test('empty', () => {
+ sinon.stub(element, '_getCommentsForPath');
+ sinon.stub(element, '_getPaths').returns(new Map());
+ element._initPatchRange();
+ assert.equal(Object.keys(element._commentMap).length, 0);
});
- test('has paths', done => {
+ test('has paths', () => {
+ sinon.stub(element, '_getFiles');
sinon.stub(element, '_getPaths').returns({
'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
@@ -1172,14 +1464,12 @@
sinon.stub(element, '_getCommentsForPath').returns({meta: {}});
element._changeNum = '42';
element._patchRange = {
- basePatchNum: '3',
- patchNum: '5',
+ basePatchNum: 3,
+ patchNum: 5,
};
- element._loadComments().then(() => {
- assert.deepEqual(Object.keys(element._commentMap),
- ['path/to/file/one.cpp', 'path-to/file/two.py']);
- done();
- });
+ element._initPatchRange();
+ assert.deepEqual(Object.keys(element._commentMap),
+ ['path/to/file/one.cpp', 'path-to/file/two.py']);
});
});
@@ -1237,7 +1527,7 @@
element._files = getFilesFromFileList([
'path/one.jpg', 'path/two.m4v', 'path/three.wav',
]);
- element._patchRange = {patchNum: '2', basePatchNum: '1'};
+ element._patchRange = {patchNum: 2, basePatchNum: 1};
});
suite('_moveToPreviousFileWithComment', () => {
@@ -1345,11 +1635,21 @@
.then(reviewed => assert.isFalse(reviewed)));
promises.push(element._getReviewedStatus(false, null, null, 'path')
+ .then(reviewed => assert.isFalse(reviewed)));
+
+ promises.push(element._getReviewedStatus(false, 3, 5, 'path')
.then(reviewed => assert.isTrue(reviewed)));
return Promise.all(promises);
});
+ test('f open file dropdown', () => {
+ assert.isFalse(element.$.dropdown.$.dropdown.opened);
+ MockInteractions.pressAndReleaseKeyOn(element, 70, null, 'f');
+ flush();
+ assert.isTrue(element.$.dropdown.$.dropdown.opened);
+ });
+
suite('blame', () => {
test('toggle blame with button', () => {
const toggleBlame = sinon.stub(
@@ -1378,19 +1678,18 @@
test('reviewed checkbox', () => {
sinon.stub(element, '_handlePatchChange');
- element._patchRange = {patchNum: '1'};
+ element._patchRange = {patchNum: 1};
// Reviewed checkbox should be shown.
assert.isTrue(isVisible(element.$.reviewed));
element.set('_patchRange.patchNum', SPECIAL_PATCH_SET_NUM.EDIT);
- flushAsynchronousOperations();
+ flush();
assert.isFalse(isVisible(element.$.reviewed));
});
});
test('_paramsChanged sets in projectLookup', () => {
- sinon.stub(element, '_getLineOfInterest');
- sinon.stub(element, '_initCursor');
+ sinon.stub(element, '_initLineOfInterestAndCursor');
const setStub = sinon.stub(element.$.restAPI, 'setInProjectLookup');
element._paramsChanged({
view: GerritNav.View.DIFF,
@@ -1409,7 +1708,7 @@
const reviewedStub = sinon.stub(element, '_setReviewed');
const navStub = sinon.stub(element, '_navToFile');
MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(reviewedStub.lastCall.args[0]);
assert.deepEqual(navStub.lastCall.args, [
@@ -1419,37 +1718,50 @@
]);
});
- test('File change should trigger navigateToDiff once', () => {
+ test('File change should trigger navigateToDiff once', done => {
element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
- sinon.stub(element, '_getLineOfInterest');
- sinon.stub(element, '_initCursor');
+ sinon.stub(element, '_initLineOfInterestAndCursor');
sinon.stub(GerritNav, 'navigateToDiff');
// Load file1
- element._paramsChanged({
+ element.params = {
view: GerritNav.View.DIFF,
patchNum: 1,
changeNum: 101,
project: 'test-project',
path: 'file1',
- });
+ };
+ element._patchRange = {
+ patchNum: 1,
+ basePatchNum: 'PARENT',
+ };
+ element._change = {
+ ...createChange(),
+ revisions: createRevisions(1),
+ };
+ flush();
assert.isTrue(GerritNav.navigateToDiff.notCalled);
// Switch to file2
- element.$.dropdown.value = 'file2';
+ element._handleFileChange({detail: {value: 'file2'}});
assert.isTrue(GerritNav.navigateToDiff.calledOnce);
// This is to mock the param change triggered by above navigate
- element._paramsChanged({
+ element.params = {
view: GerritNav.View.DIFF,
patchNum: 1,
changeNum: 101,
project: 'test-project',
path: 'file2',
- });
+ };
+ element._patchRange = {
+ patchNum: 1,
+ basePatchNum: 'PARENT',
+ };
// No extra call
assert.isTrue(GerritNav.navigateToDiff.calledOnce);
+ done();
});
test('_computeDownloadDropdownLinks', () => {
@@ -1568,14 +1880,15 @@
getReviewedFiles() { return Promise.resolve([]); },
});
element = basicFixture.instantiate();
+ element._changeNum = '42';
return element._loadComments();
});
test('_getFiles add files with comments without changes', () => {
const patchChangeRecord = {
base: {
- basePatchNum: '5',
- patchNum: '10',
+ basePatchNum: 5,
+ patchNum: 10,
},
};
const changeComments = {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
deleted file mode 100644
index bfd063a..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
+++ /dev/null
@@ -1,282 +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 {GrDiffLine} from './gr-diff-line.js';
-
-/**
- * A chunk of the diff that should be rendered together.
- *
- * @constructor
- * @param {!GrDiffGroup.Type} type
- * @param {!Array<!GrDiffLine>=} opt_lines
- */
-export function GrDiffGroup(type, opt_lines) {
- /** @type {!GrDiffGroup.Type} */
- this.type = type;
-
- /** @type {boolean} */
- this.dueToRebase = false;
-
- /**
- * True means all changes in this line are whitespace changes that should
- * not be highlighted as changed as per the user settings.
- *
- * @type{boolean}
- */
- this.ignoredWhitespaceOnly = false;
-
- /**
- * True means it should not be collapsed (because it was in the URL, or
- * there is a comment on that line)
- */
- this.keyLocation = false;
-
- /** @type {?HTMLElement} */
- this.element = null;
-
- /** @type {!Array<!GrDiffLine>} */
- this.lines = [];
- /** @type {!Array<!GrDiffLine>} */
- this.adds = [];
- /** @type {!Array<!GrDiffLine>} */
- this.removes = [];
-
- /** Both start and end line are inclusive. */
- this.lineRange = {
- left: {start: null, end: null},
- right: {start: null, end: null},
- };
-
- if (opt_lines) {
- opt_lines.forEach(this.addLine, this);
- }
-}
-
-/** @enum {string} */
-GrDiffGroup.Type = {
- /** Unchanged context. */
- BOTH: 'both',
-
- /** A widget used to show more context. */
- CONTEXT_CONTROL: 'contextControl',
-
- /** Added, removed or modified chunk. */
- DELTA: 'delta',
-};
-
-/**
- * Hides lines in the given range behind a context control group.
- *
- * Groups that would be partially visible are split into their visible and
- * hidden parts, respectively.
- * The groups need to be "common groups", meaning they have to have either
- * originated from an `ab` chunk, or from an `a`+`b` chunk with
- * `common: true`.
- *
- * If the hidden range is 1 line or less, nothing is hidden and no context
- * control group is created.
- *
- * @param {!Array<!GrDiffGroup>} groups Common groups, ordered by their line
- * ranges.
- * @param {number} hiddenStart The first element to be hidden, as a
- * non-negative line number offset relative to the first group's start
- * line, left and right respectively.
- * @param {number} hiddenEnd The first visible element after the hidden range,
- * as a non-negative line number offset relative to the first group's
- * start line, left and right respectively.
- * @return {!Array<!GrDiffGroup>}
- */
-GrDiffGroup.hideInContextControl = function(groups, hiddenStart, hiddenEnd) {
- if (groups.length === 0) return [];
- // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
- hiddenStart = Math.max(hiddenStart, 0);
- hiddenEnd = Math.max(hiddenEnd, hiddenStart);
-
- let before = [];
- let hidden = groups;
- let after = [];
-
- const numHidden = hiddenEnd - hiddenStart;
-
- // Only collapse if there is more than 1 line to be hidden.
- if (numHidden > 1) {
- if (hiddenStart) {
- [before, hidden] = GrDiffGroup._splitCommonGroups(hidden, hiddenStart);
- }
- if (hiddenEnd) {
- [hidden, after] = GrDiffGroup._splitCommonGroups(
- hidden, hiddenEnd - hiddenStart);
- }
- } else {
- [hidden, after] = [[], hidden];
- }
-
- const result = [...before];
- if (hidden.length) {
- const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
- ctxLine.contextGroups = hidden;
- const ctxGroup = new GrDiffGroup(
- GrDiffGroup.Type.CONTEXT_CONTROL, [ctxLine]);
- result.push(ctxGroup);
- }
- result.push(...after);
- return result;
-};
-
-/**
- * Splits a list of common groups into two lists of groups.
- *
- * Groups where all lines are before or all lines are after the split will be
- * retained as is and put into the first or second list respectively. Groups
- * with some lines before and some lines after the split will be split into
- * two groups, which will be put into the first and second list.
- *
- * @param {!Array<!GrDiffGroup>} groups
- * @param {number} split A line number offset relative to the first group's
- * start line at which the groups should be split.
- * @return {!Array<!Array<!GrDiffGroup>>} The outer array has 2 elements, the
- * list of groups before and the list of groups after the split.
- */
-GrDiffGroup._splitCommonGroups = function(groups, split) {
- if (groups.length === 0) return [[], []];
- const leftSplit = groups[0].lineRange.left.start + split;
- const rightSplit = groups[0].lineRange.right.start + split;
-
- const beforeGroups = [];
- const afterGroups = [];
- for (const group of groups) {
- if (group.lineRange.left.end < leftSplit ||
- group.lineRange.right.end < rightSplit) {
- beforeGroups.push(group);
- continue;
- }
- if (leftSplit <= group.lineRange.left.start ||
- rightSplit <= group.lineRange.right.start) {
- afterGroups.push(group);
- continue;
- }
-
- const before = [];
- const after = [];
- for (const line of group.lines) {
- if ((line.beforeNumber && line.beforeNumber < leftSplit) ||
- (line.afterNumber && line.afterNumber < rightSplit)) {
- before.push(line);
- } else {
- after.push(line);
- }
- }
-
- if (before.length) {
- beforeGroups.push(before.length === group.lines.length ?
- group : group.cloneWithLines(before));
- }
- if (after.length) {
- afterGroups.push(after.length === group.lines.length ?
- group : group.cloneWithLines(after));
- }
- }
- return [beforeGroups, afterGroups];
-};
-
-/**
- * Creates a new group with the same properties but different lines.
- *
- * The element property is not copied, because the original element is still a
- * rendering of the old lines, so that would not make sense.
- *
- * @param {!Array<!GrDiffLine>} lines
- * @return {!GrDiffGroup}
- */
-GrDiffGroup.prototype.cloneWithLines = function(lines) {
- const group = new GrDiffGroup(this.type, lines);
- group.dueToRebase = this.dueToRebase;
- group.ignoredWhitespaceOnly = this.ignoredWhitespaceOnly;
- return group;
-};
-
-/** @param {!GrDiffLine} line */
-GrDiffGroup.prototype.addLine = function(line) {
- this.lines.push(line);
-
- const notDelta = (this.type === GrDiffGroup.Type.BOTH ||
- this.type === GrDiffGroup.Type.CONTEXT_CONTROL);
- if (notDelta && (line.type === GrDiffLine.Type.ADD ||
- line.type === GrDiffLine.Type.REMOVE)) {
- throw Error('Cannot add delta line to a non-delta group.');
- }
-
- if (line.type === GrDiffLine.Type.ADD) {
- this.adds.push(line);
- } else if (line.type === GrDiffLine.Type.REMOVE) {
- this.removes.push(line);
- }
- this._updateRange(line);
-};
-
-/** @return {!Array<{left: GrDiffLine, right: GrDiffLine}>} */
-GrDiffGroup.prototype.getSideBySidePairs = function() {
- if (this.type === GrDiffGroup.Type.BOTH ||
- this.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
- return this.lines.map(line => {
- return {
- left: line,
- right: line,
- };
- });
- }
-
- const pairs = [];
- let i = 0;
- let j = 0;
- while (i < this.removes.length || j < this.adds.length) {
- pairs.push({
- left: this.removes[i] || GrDiffLine.BLANK_LINE,
- right: this.adds[j] || GrDiffLine.BLANK_LINE,
- });
- i++;
- j++;
- }
- return pairs;
-};
-
-GrDiffGroup.prototype._updateRange = function(line) {
- if (line.beforeNumber === 'FILE' || line.afterNumber === 'FILE') { return; }
-
- if (line.type === GrDiffLine.Type.ADD ||
- line.type === GrDiffLine.Type.BOTH) {
- if (this.lineRange.right.start === null ||
- line.afterNumber < this.lineRange.right.start) {
- this.lineRange.right.start = line.afterNumber;
- }
- if (this.lineRange.right.end === null ||
- line.afterNumber > this.lineRange.right.end) {
- this.lineRange.right.end = line.afterNumber;
- }
- }
-
- if (line.type === GrDiffLine.Type.REMOVE ||
- line.type === GrDiffLine.Type.BOTH) {
- if (this.lineRange.left.start === null ||
- line.beforeNumber < this.lineRange.left.start) {
- this.lineRange.left.start = line.beforeNumber;
- }
- if (this.lineRange.left.end === null ||
- line.beforeNumber > this.lineRange.left.end) {
- this.lineRange.left.end = line.beforeNumber;
- }
- }
-};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
new file mode 100644
index 0000000..588b9d1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.ts
@@ -0,0 +1,371 @@
+/**
+ * @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 {BLANK_LINE, GrDiffLine, GrDiffLineType} from './gr-diff-line';
+import {Side} from '../../../constants/constants';
+
+export enum GrDiffGroupType {
+ /** Unchanged context. */
+ BOTH = 'both',
+
+ /** A widget used to show more context. */
+ CONTEXT_CONTROL = 'contextControl',
+
+ /** Added, removed or modified chunk. */
+ DELTA = 'delta',
+}
+
+export interface GrDiffLinePair {
+ left: GrDiffLine;
+ right: GrDiffLine;
+}
+
+interface Range {
+ start: number | null;
+ end: number | null;
+}
+
+export interface GrDiffGroupRange {
+ left: Range;
+ right: Range;
+}
+
+export function rangeBySide(range: GrDiffGroupRange, side: Side): Range {
+ return side === Side.LEFT ? range.left : range.right;
+}
+
+/**
+ * Hides lines in the given range behind a context control group.
+ *
+ * Groups that would be partially visible are split into their visible and
+ * hidden parts, respectively.
+ * The groups need to be "common groups", meaning they have to have either
+ * originated from an `ab` chunk, or from an `a`+`b` chunk with
+ * `common: true`.
+ *
+ * If the hidden range is 1 line or less, nothing is hidden and no context
+ * control group is created.
+ *
+ * @param groups Common groups, ordered by their line ranges.
+ * @param hiddenStart The first element to be hidden, as a
+ * non-negative line number offset relative to the first group's start
+ * line, left and right respectively.
+ * @param hiddenEnd The first visible element after the hidden range,
+ * as a non-negative line number offset relative to the first group's
+ * start line, left and right respectively.
+ */
+export function hideInContextControl(
+ groups: GrDiffGroup[],
+ hiddenStart: number,
+ hiddenEnd: number
+): GrDiffGroup[] {
+ if (groups.length === 0) return [];
+ // Clamp hiddenStart and hiddenEnd - inspired by e.g. substring
+ hiddenStart = Math.max(hiddenStart, 0);
+ hiddenEnd = Math.max(hiddenEnd, hiddenStart);
+
+ let before: GrDiffGroup[] = [];
+ let hidden = groups;
+ let after: GrDiffGroup[] = [];
+
+ const numHidden = hiddenEnd - hiddenStart;
+
+ // Only collapse if there is more than 1 line to be hidden.
+ if (numHidden > 1) {
+ if (hiddenStart) {
+ [before, hidden] = _splitCommonGroups(hidden, hiddenStart);
+ }
+ if (hiddenEnd) {
+ let beforeLength = 0;
+ if (before.length > 0) {
+ const beforeStart = before[0].lineRange.left.start || 0;
+ const beforeEnd = before[before.length - 1].lineRange.left.end || 0;
+ beforeLength = beforeEnd - beforeStart + 1;
+ }
+ [hidden, after] = _splitCommonGroups(hidden, hiddenEnd - beforeLength);
+ }
+ } else {
+ [hidden, after] = [[], hidden];
+ }
+
+ const result = [...before];
+ if (hidden.length) {
+ const ctxGroup = new GrDiffGroup(GrDiffGroupType.CONTEXT_CONTROL, []);
+ ctxGroup.contextGroups = hidden;
+ result.push(ctxGroup);
+ }
+ result.push(...after);
+ return result;
+}
+
+/**
+ * Splits a group in two, defined by leftSplit and rightSplit. Primarily to be
+ * used in function _splitCommonGroups
+ * Groups with some lines before and some lines after the split will be split
+ * into two groups, which will be put into the first and second list.
+ *
+ * @param group The group to be split in two
+ * @param leftSplit The line number relative to the split on the left side
+ * @param rightSplit The line number relative to the split on the right side
+ * @return two new groups, one before the split and another after it
+ */
+function _splitGroupInTwo(
+ group: GrDiffGroup,
+ leftSplit: number,
+ rightSplit: number
+) {
+ let beforeSplit: GrDiffGroup | undefined;
+ let afterSplit: GrDiffGroup | undefined;
+ // split line is in the middle of a group, we need to break the group
+ // in lines before and after the split.
+ if (group.skip) {
+ // Currently we assume skip chunks "refuse" to be split. Expanding this
+ // group will in the future mean load more data - and therefore we want to
+ // fire an event when user wants to do it.
+ const closerToStartThanEnd =
+ leftSplit - (group.lineRange.left.start || 0) <
+ (group.lineRange.right.end || 0) - leftSplit;
+ if (closerToStartThanEnd) {
+ afterSplit = group;
+ } else {
+ beforeSplit = group;
+ }
+ } else {
+ const before = [];
+ const after = [];
+ for (const line of group.lines) {
+ if (
+ (line.beforeNumber && line.beforeNumber < leftSplit) ||
+ (line.afterNumber && line.afterNumber < rightSplit)
+ ) {
+ before.push(line);
+ } else {
+ after.push(line);
+ }
+ }
+ if (before.length) {
+ beforeSplit =
+ before.length === group.lines.length
+ ? group
+ : group.cloneWithLines(before);
+ }
+ if (after.length) {
+ afterSplit =
+ after.length === group.lines.length
+ ? group
+ : group.cloneWithLines(after);
+ }
+ }
+ return {beforeSplit, afterSplit};
+}
+
+/**
+ * Splits a list of common groups into two lists of groups.
+ *
+ * Groups where all lines are before or all lines are after the split will be
+ * retained as is and put into the first or second list respectively. Groups
+ * with some lines before and some lines after the split will be split into
+ * two groups, which will be put into the first and second list.
+ *
+ * @param split A line number offset relative to the first group's
+ * start line at which the groups should be split.
+ * @return The outer array has 2 elements, the
+ * list of groups before and the list of groups after the split.
+ */
+function _splitCommonGroups(
+ groups: GrDiffGroup[],
+ split: number
+): GrDiffGroup[][] {
+ if (groups.length === 0) return [[], []];
+ const leftSplit = (groups[0].lineRange.left.start || 0) + split;
+ const rightSplit = (groups[0].lineRange.right.start || 0) + split;
+
+ const beforeGroups = [];
+ const afterGroups = [];
+ for (const group of groups) {
+ const isCompletelyBefore =
+ (group.lineRange.left.end || 0) < leftSplit ||
+ (group.lineRange.right.end || 0) < rightSplit;
+ const isCompletelyAfter =
+ leftSplit <= (group.lineRange.left.start || 0) ||
+ rightSplit <= (group.lineRange.right.start || 0);
+ if (isCompletelyBefore) {
+ beforeGroups.push(group);
+ } else if (isCompletelyAfter) {
+ afterGroups.push(group);
+ } else {
+ const {beforeSplit, afterSplit} = _splitGroupInTwo(
+ group,
+ leftSplit,
+ rightSplit
+ );
+ if (beforeSplit) {
+ beforeGroups.push(beforeSplit);
+ }
+ if (afterSplit) {
+ afterGroups.push(afterSplit);
+ }
+ }
+ }
+ return [beforeGroups, afterGroups];
+}
+
+/**
+ * A chunk of the diff that should be rendered together.
+ *
+ * @constructor
+ * @param {!GrDiffGroupType} type
+ * @param {!Array<!GrDiffLine>=} opt_lines
+ */
+export class GrDiffGroup {
+ constructor(readonly type: GrDiffGroupType, lines: GrDiffLine[] = []) {
+ lines.forEach((line: GrDiffLine) => this.addLine(line));
+ }
+
+ dueToRebase = false;
+
+ dueToMove = false;
+
+ /**
+ * True means all changes in this line are whitespace changes that should
+ * not be highlighted as changed as per the user settings.
+ */
+ ignoredWhitespaceOnly = false;
+
+ /**
+ * True means it should not be collapsed (because it was in the URL, or
+ * there is a comment on that line)
+ */
+ keyLocation = false;
+
+ element?: HTMLElement;
+
+ lines: GrDiffLine[] = [];
+
+ adds: GrDiffLine[] = [];
+
+ removes: GrDiffLine[] = [];
+
+ contextGroups: GrDiffGroup[] = [];
+
+ skip?: number;
+
+ /** Both start and end line are inclusive. */
+ lineRange: GrDiffGroupRange = {
+ left: {start: null, end: null},
+ right: {start: null, end: null},
+ };
+
+ /**
+ * Creates a new group with the same properties but different lines.
+ *
+ * The element property is not copied, because the original element is still a
+ * rendering of the old lines, so that would not make sense.
+ */
+ cloneWithLines(lines: GrDiffLine[]): GrDiffGroup {
+ const group = new GrDiffGroup(this.type, lines);
+ group.dueToRebase = this.dueToRebase;
+ group.ignoredWhitespaceOnly = this.ignoredWhitespaceOnly;
+ return group;
+ }
+
+ addLine(line: GrDiffLine) {
+ this.lines.push(line);
+
+ const notDelta =
+ this.type === GrDiffGroupType.BOTH ||
+ this.type === GrDiffGroupType.CONTEXT_CONTROL;
+ if (
+ notDelta &&
+ (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.REMOVE)
+ ) {
+ throw Error('Cannot add delta line to a non-delta group.');
+ }
+
+ if (line.type === GrDiffLineType.ADD) {
+ this.adds.push(line);
+ } else if (line.type === GrDiffLineType.REMOVE) {
+ this.removes.push(line);
+ }
+ this._updateRange(line);
+ }
+
+ getSideBySidePairs(): GrDiffLinePair[] {
+ if (
+ this.type === GrDiffGroupType.BOTH ||
+ this.type === GrDiffGroupType.CONTEXT_CONTROL
+ ) {
+ return this.lines.map(line => {
+ return {
+ left: line,
+ right: line,
+ };
+ });
+ }
+
+ const pairs: GrDiffLinePair[] = [];
+ let i = 0;
+ let j = 0;
+ while (i < this.removes.length || j < this.adds.length) {
+ pairs.push({
+ left: this.removes[i] || BLANK_LINE,
+ right: this.adds[j] || BLANK_LINE,
+ });
+ i++;
+ j++;
+ }
+ return pairs;
+ }
+
+ _updateRange(line: GrDiffLine) {
+ if (line.beforeNumber === 'FILE' || line.afterNumber === 'FILE') {
+ return;
+ }
+
+ if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
+ if (
+ this.lineRange.right.start === null ||
+ line.afterNumber < this.lineRange.right.start
+ ) {
+ this.lineRange.right.start = line.afterNumber;
+ }
+ if (
+ this.lineRange.right.end === null ||
+ line.afterNumber > this.lineRange.right.end
+ ) {
+ this.lineRange.right.end = line.afterNumber;
+ }
+ }
+
+ if (
+ line.type === GrDiffLineType.REMOVE ||
+ line.type === GrDiffLineType.BOTH
+ ) {
+ if (
+ this.lineRange.left.start === null ||
+ line.beforeNumber < this.lineRange.left.start
+ ) {
+ this.lineRange.left.start = line.beforeNumber;
+ }
+ if (
+ this.lineRange.left.end === null ||
+ line.beforeNumber > this.lineRange.left.end
+ ) {
+ this.lineRange.left.end = line.beforeNumber;
+ }
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
index d72f981..3423834 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.js
@@ -16,15 +16,15 @@
*/
import '../../../test/common-test-setup-karma.js';
-import {GrDiffLine} from './gr-diff-line.js';
-import {GrDiffGroup} from './gr-diff-group.js';
+import {GrDiffLine, GrDiffLineType, BLANK_LINE} from './gr-diff-line.js';
+import {GrDiffGroup, GrDiffGroupType, hideInContextControl} from './gr-diff-group.js';
suite('gr-diff-group tests', () => {
test('delta line pairs', () => {
- let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
- const l1 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 128);
- const l2 = new GrDiffLine(GrDiffLine.Type.ADD, 0, 129);
- const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE, 64, 0);
+ let group = new GrDiffGroup(GrDiffGroupType.DELTA);
+ const l1 = new GrDiffLine(GrDiffLineType.ADD, 0, 128);
+ const l2 = new GrDiffLine(GrDiffLineType.ADD, 0, 129);
+ const l3 = new GrDiffLine(GrDiffLineType.REMOVE, 64, 0);
group.addLine(l1);
group.addLine(l2);
group.addLine(l3);
@@ -39,10 +39,10 @@
let pairs = group.getSideBySidePairs();
assert.deepEqual(pairs, [
{left: l3, right: l1},
- {left: GrDiffLine.BLANK_LINE, right: l2},
+ {left: BLANK_LINE, right: l2},
]);
- group = new GrDiffGroup(GrDiffGroup.Type.DELTA, [l1, l2, l3]);
+ group = new GrDiffGroup(GrDiffGroupType.DELTA, [l1, l2, l3]);
assert.deepEqual(group.lines, [l1, l2, l3]);
assert.deepEqual(group.adds, [l1, l2]);
assert.deepEqual(group.removes, [l3]);
@@ -50,16 +50,16 @@
pairs = group.getSideBySidePairs();
assert.deepEqual(pairs, [
{left: l3, right: l1},
- {left: GrDiffLine.BLANK_LINE, right: l2},
+ {left: BLANK_LINE, right: l2},
]);
});
test('group/header line pairs', () => {
- const l1 = new GrDiffLine(GrDiffLine.Type.BOTH, 64, 128);
- const l2 = new GrDiffLine(GrDiffLine.Type.BOTH, 65, 129);
- const l3 = new GrDiffLine(GrDiffLine.Type.BOTH, 66, 130);
+ const l1 = new GrDiffLine(GrDiffLineType.BOTH, 64, 128);
+ const l2 = new GrDiffLine(GrDiffLineType.BOTH, 65, 129);
+ const l3 = new GrDiffLine(GrDiffLineType.BOTH, 66, 130);
- let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
+ let group = new GrDiffGroup(GrDiffGroupType.BOTH, [l1, l2, l3]);
assert.deepEqual(group.lines, [l1, l2, l3]);
assert.deepEqual(group.adds, []);
@@ -77,7 +77,7 @@
{left: l3, right: l3},
]);
- group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL, [l1, l2, l3]);
+ group = new GrDiffGroup(GrDiffGroupType.CONTEXT_CONTROL, [l1, l2, l3]);
assert.deepEqual(group.lines, [l1, l2, l3]);
assert.deepEqual(group.adds, []);
assert.deepEqual(group.removes, []);
@@ -91,16 +91,16 @@
});
test('adding delta lines to non-delta group', () => {
- const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
- const l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
- const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+ const l1 = new GrDiffLine(GrDiffLineType.ADD);
+ const l2 = new GrDiffLine(GrDiffLineType.REMOVE);
+ const l3 = new GrDiffLine(GrDiffLineType.BOTH);
- let group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+ let group = new GrDiffGroup(GrDiffGroupType.BOTH);
assert.throws(group.addLine.bind(group, l1));
assert.throws(group.addLine.bind(group, l2));
assert.doesNotThrow(group.addLine.bind(group, l3));
- group = new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL);
+ group = new GrDiffGroup(GrDiffGroupType.CONTEXT_CONTROL);
assert.throws(group.addLine.bind(group, l1));
assert.throws(group.addLine.bind(group, l2));
assert.doesNotThrow(group.addLine.bind(group, l3));
@@ -110,90 +110,110 @@
let groups;
setup(() => {
groups = [
- new GrDiffGroup(GrDiffGroup.Type.BOTH, [
- new GrDiffLine(GrDiffLine.Type.BOTH, 5, 7),
- new GrDiffLine(GrDiffLine.Type.BOTH, 6, 8),
- new GrDiffLine(GrDiffLine.Type.BOTH, 7, 9),
+ new GrDiffGroup(GrDiffGroupType.BOTH, [
+ new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+ new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+ new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
]),
- new GrDiffGroup(GrDiffGroup.Type.DELTA, [
- new GrDiffLine(GrDiffLine.Type.REMOVE, 8),
- new GrDiffLine(GrDiffLine.Type.ADD, 0, 10),
- new GrDiffLine(GrDiffLine.Type.REMOVE, 9),
- new GrDiffLine(GrDiffLine.Type.ADD, 0, 11),
- new GrDiffLine(GrDiffLine.Type.REMOVE, 10),
- new GrDiffLine(GrDiffLine.Type.ADD, 0, 12),
+ new GrDiffGroup(GrDiffGroupType.DELTA, [
+ new GrDiffLine(GrDiffLineType.REMOVE, 8),
+ new GrDiffLine(GrDiffLineType.ADD, 0, 10),
+ new GrDiffLine(GrDiffLineType.REMOVE, 9),
+ new GrDiffLine(GrDiffLineType.ADD, 0, 11),
+ new GrDiffLine(GrDiffLineType.REMOVE, 10),
+ new GrDiffLine(GrDiffLineType.ADD, 0, 12),
]),
- new GrDiffGroup(GrDiffGroup.Type.BOTH, [
- new GrDiffLine(GrDiffLine.Type.BOTH, 11, 13),
- new GrDiffLine(GrDiffLine.Type.BOTH, 12, 14),
- new GrDiffLine(GrDiffLine.Type.BOTH, 13, 15),
+ new GrDiffGroup(GrDiffGroupType.BOTH, [
+ new GrDiffLine(GrDiffLineType.BOTH, 11, 13),
+ new GrDiffLine(GrDiffLineType.BOTH, 12, 14),
+ new GrDiffLine(GrDiffLineType.BOTH, 13, 15),
]),
];
});
test('hides hidden groups in context control', () => {
- const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 3, 6);
+ const collapsedGroups = hideInContextControl(groups, 3, 6);
assert.equal(collapsedGroups.length, 3);
assert.equal(collapsedGroups[0], groups[0]);
- assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
- assert.equal(collapsedGroups[1].lines.length, 1);
- assert.equal(
- collapsedGroups[1].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
- assert.equal(
- collapsedGroups[1].lines[0].contextGroups.length, 1);
- assert.equal(
- collapsedGroups[1].lines[0].contextGroups[0], groups[1]);
+ assert.equal(collapsedGroups[1].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.equal(collapsedGroups[1].contextGroups.length, 1);
+ assert.equal(collapsedGroups[1].contextGroups[0], groups[1]);
assert.equal(collapsedGroups[2], groups[2]);
});
test('splits partially hidden groups', () => {
- const collapsedGroups = GrDiffGroup.hideInContextControl(groups, 4, 7);
+ const collapsedGroups = hideInContextControl(groups, 4, 7);
assert.equal(collapsedGroups.length, 4);
assert.equal(collapsedGroups[0], groups[0]);
- assert.equal(collapsedGroups[1].type, GrDiffGroup.Type.DELTA);
+ assert.equal(collapsedGroups[1].type, GrDiffGroupType.DELTA);
assert.deepEqual(collapsedGroups[1].adds, [groups[1].adds[0]]);
assert.deepEqual(collapsedGroups[1].removes, [groups[1].removes[0]]);
- assert.equal(collapsedGroups[2].type, GrDiffGroup.Type.CONTEXT_CONTROL);
- assert.equal(collapsedGroups[2].lines.length, 1);
- assert.equal(
- collapsedGroups[2].lines[0].type, GrDiffLine.Type.CONTEXT_CONTROL);
- assert.equal(
- collapsedGroups[2].lines[0].contextGroups.length, 2);
+ assert.equal(collapsedGroups[2].type, GrDiffGroupType.CONTEXT_CONTROL);
+ assert.equal(collapsedGroups[2].contextGroups.length, 2);
assert.equal(
- collapsedGroups[2].lines[0].contextGroups[0].type,
- GrDiffGroup.Type.DELTA);
+ collapsedGroups[2].contextGroups[0].type,
+ GrDiffGroupType.DELTA);
assert.deepEqual(
- collapsedGroups[2].lines[0].contextGroups[0].adds,
+ collapsedGroups[2].contextGroups[0].adds,
groups[1].adds.slice(1));
assert.deepEqual(
- collapsedGroups[2].lines[0].contextGroups[0].removes,
+ collapsedGroups[2].contextGroups[0].removes,
groups[1].removes.slice(1));
assert.equal(
- collapsedGroups[2].lines[0].contextGroups[1].type,
- GrDiffGroup.Type.BOTH);
+ collapsedGroups[2].contextGroups[1].type,
+ GrDiffGroupType.BOTH);
assert.deepEqual(
- collapsedGroups[2].lines[0].contextGroups[1].lines,
+ collapsedGroups[2].contextGroups[1].lines,
[groups[2].lines[0]]);
- assert.equal(collapsedGroups[3].type, GrDiffGroup.Type.BOTH);
+ assert.equal(collapsedGroups[3].type, GrDiffGroupType.BOTH);
assert.deepEqual(collapsedGroups[3].lines, groups[2].lines.slice(1));
});
+ suite('with skip chunks', () => {
+ setup(() => {
+ const skipGroup = new GrDiffGroup(GrDiffGroupType.BOTH);
+ skipGroup.skip = 60;
+ skipGroup.lineRange = {
+ left: {start: 8, end: 67},
+ right: {start: 10, end: 69},
+ };
+ groups = [
+ new GrDiffGroup(GrDiffGroupType.BOTH, [
+ new GrDiffLine(GrDiffLineType.BOTH, 5, 7),
+ new GrDiffLine(GrDiffLineType.BOTH, 6, 8),
+ new GrDiffLine(GrDiffLineType.BOTH, 7, 9),
+ ]),
+ skipGroup,
+ new GrDiffGroup(GrDiffGroupType.BOTH, [
+ new GrDiffLine(GrDiffLineType.BOTH, 68, 70),
+ new GrDiffLine(GrDiffLineType.BOTH, 69, 71),
+ new GrDiffLine(GrDiffLineType.BOTH, 70, 72),
+ ]),
+ ];
+ });
+
+ test('refuses to split skip group when closer to before', () => {
+ const collapsedGroups = hideInContextControl(groups, 4, 10);
+ assert.deepEqual(groups, collapsedGroups);
+ });
+ });
+
test('groups unchanged if the hidden range is empty', () => {
assert.deepEqual(
- GrDiffGroup.hideInContextControl(groups, 0, 0), groups);
+ hideInContextControl(groups, 0, 0), groups);
});
test('groups unchanged if there is only 1 line to hide', () => {
assert.deepEqual(
- GrDiffGroup.hideInContextControl(groups, 3, 4), groups);
+ hideInContextControl(groups, 3, 4), groups);
});
});
});
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
deleted file mode 100644
index 70387ca..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ /dev/null
@@ -1,73 +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.
- */
-
-/**
- * @constructor
- * @param {GrDiffLine.Type} type
- * @param {number|string=} opt_beforeLine
- * @param {number|string=} opt_afterLine
- */
-export function GrDiffLine(type, opt_beforeLine, opt_afterLine) {
- this.type = type;
-
- /** @type {number|string} */
- this.beforeNumber = opt_beforeLine || 0;
-
- /** @type {number|string} */
- this.afterNumber = opt_afterLine || 0;
-
- /** @type {boolean} */
- this.hasIntralineInfo = false;
-
- /** @type {!Array<GrDiffLine.Highlights>} */
- this.highlights = [];
-
- /** @type {?Array<Object>} ?Array<!GrDiffGroup> */
- this.contextGroups = null;
-
- this.text = '';
-}
-
-/** @enum {string} */
-GrDiffLine.Type = {
- ADD: 'add',
- BOTH: 'both',
- BLANK: 'blank',
- CONTEXT_CONTROL: 'contextControl',
- REMOVE: 'remove',
-};
-
-/**
- * A line highlight object consists of three fields:
- * - contentIndex: The index of the chunk `content` field (the line
- * being referred to).
- * - startIndex: Index of the character where the highlight should begin.
- * - endIndex: (optional) Index of the character where the highlight should
- * end. If omitted, the highlight is meant to be a continuation onto the
- * next line.
- *
- * @typedef {{
- * contentIndex: number,
- * startIndex: number,
- * endIndex: number
- * }}
- */
-GrDiffLine.Highlights;
-
-GrDiffLine.FILE = 'FILE';
-
-GrDiffLine.BLANK_LINE = new GrDiffLine(GrDiffLine.Type.BLANK);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
new file mode 100644
index 0000000..2d80213
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.ts
@@ -0,0 +1,62 @@
+/**
+ * @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.
+ */
+
+export const FILE = 'FILE';
+export type LineNumber = number | 'FILE';
+
+export enum GrDiffLineType {
+ ADD = 'add',
+ BOTH = 'both',
+ BLANK = 'blank',
+ REMOVE = 'remove',
+}
+
+export class GrDiffLine {
+ constructor(
+ readonly type: GrDiffLineType,
+ public beforeNumber: LineNumber = 0,
+ public afterNumber: LineNumber = 0
+ ) {}
+
+ hasIntralineInfo = false;
+
+ highlights: Highlights[] = [];
+
+ text = '';
+
+ // TODO(TS): remove this properties
+ static readonly Type = GrDiffLineType;
+
+ static readonly File = FILE;
+}
+
+/**
+ * A line highlight object consists of three fields:
+ * - contentIndex: The index of the chunk `content` field (the line
+ * being referred to).
+ * - startIndex: Index of the character where the highlight should begin.
+ * - endIndex: (optional) Index of the character where the highlight should
+ * end. If omitted, the highlight is meant to be a continuation onto the
+ * next line.
+ */
+export interface Highlights {
+ contentIndex: number;
+ startIndex: number;
+ endIndex?: number;
+}
+
+export const BLANK_LINE = new GrDiffLine(GrDiffLineType.BLANK);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.js
deleted file mode 100644
index 7eee071..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.js
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-/** @enum {string} */
-export const DiffSide = {
- LEFT: 'left',
- RIGHT: 'right',
-};
-
-/**
- * Compare two ranges. Either argument may be falsy, but will only return
- * true if both are falsy or if neither are falsy and have the same position
- * values.
- *
- * @param {Range=} a range 1
- * @param {Range=} b range 2
- * @return {boolean}
- */
-export function rangesEqual(a, b) {
- if (!a && !b) { return true; }
- if (!a || !b) { return false; }
- return a.start_line === b.start_line &&
- a.start_character === b.start_character &&
- a.end_line === b.end_line &&
- a.end_character === b.end_character;
-}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
new file mode 100644
index 0000000..8984dc8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-utils.ts
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {CommentRange} from '../../../types/common';
+import {FILE, LineNumber} from './gr-diff-line';
+
+/**
+ * Compare two ranges. Either argument may be falsy, but will only return
+ * true if both are falsy or if neither are falsy and have the same position
+ * values.
+ */
+export function rangesEqual(a?: CommentRange, b?: CommentRange): boolean {
+ if (!a && !b) {
+ return true;
+ }
+ if (!a || !b) {
+ return false;
+ }
+ return (
+ a.start_line === b.start_line &&
+ a.start_character === b.start_character &&
+ a.end_line === b.end_line &&
+ a.end_character === b.end_character
+ );
+}
+
+export function getLineNumber(lineEl?: Element | null): LineNumber | null {
+ if (!lineEl) return null;
+ const lineNumberStr = lineEl.getAttribute('data-value');
+ if (!lineNumberStr) return null;
+ if (lineNumberStr === FILE) return FILE;
+ const lineNumber = Number(lineNumberStr);
+ return Number.isInteger(lineNumber) ? lineNumber : null;
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
deleted file mode 100644
index 8e45b62..0000000
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ /dev/null
@@ -1,985 +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/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../gr-diff-builder/gr-diff-builder-element.js';
-import '../gr-diff-highlight/gr-diff-highlight.js';
-import '../gr-diff-selection/gr-diff-selection.js';
-import '../gr-syntax-themes/gr-syntax-theme.js';
-import '../gr-ranged-comment-themes/gr-ranged-comment-theme.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import {htmlTemplate} from './gr-diff_html.js';
-import {GrDiffLine} from './gr-diff-line.js';
-import {DiffSide, rangesEqual} from './gr-diff-utils.js';
-import {getHiddenScroll} from '../../../scripts/hiddenscroll.js';
-import {
- isMergeParent,
- patchNumEquals,
- SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-
-const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
-const ERR_COMMENT_ON_EDIT_BASE = 'You cannot comment on the base patch set ' +
- 'of an edit.';
-const ERR_INVALID_LINE = 'Invalid line number: ';
-
-const NO_NEWLINE_BASE = 'No newline at end of base file.';
-const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
-
-const DiffViewMode = {
- SIDE_BY_SIDE: 'SIDE_BY_SIDE',
- UNIFIED: 'UNIFIED_DIFF',
-};
-
-const LARGE_DIFF_THRESHOLD_LINES = 10000;
-const FULL_CONTEXT = -1;
-const LIMITED_CONTEXT = 10;
-
-function isThreadEl(node) {
- return node.nodeType === Node.ELEMENT_NODE &&
- node.classList.contains('comment-thread');
-}
-
-const COMMIT_MSG_PATH = '/COMMIT_MSG';
-/**
- * 72 is the unofficial length standard for git commit messages.
- * Derived from the fact that git log/show appends 4 ws in the beginning of
- * each line when displaying commit messages. To center the commit message
- * in an 80 char terminal a 4 ws border is added to the rightmost side:
- * 4 + 72 + 4
- */
-const COMMIT_MSG_LINE_LENGTH = 72;
-
-const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
-
-/**
- * @extends PolymerElement
- */
-class GrDiff extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff'; }
- /**
- * Fired when the user selects a line.
- *
- * @event line-selected
- */
-
- /**
- * Fired if being logged in is required.
- *
- * @event show-auth-required
- */
-
- /**
- * Fired when a comment is created
- *
- * @event create-comment
- */
-
- /**
- * Fired when rendering, including syntax highlighting, is done. Also fired
- * when no rendering can be done because required preferences are not set.
- *
- * @event render
- */
-
- /**
- * Fired for interaction reporting when a diff context is expanded.
- * Contains an event.detail with numLines about the number of lines that
- * were expanded.
- *
- * @event diff-context-expanded
- */
-
- static get properties() {
- return {
- changeNum: String,
- noAutoRender: {
- type: Boolean,
- value: false,
- },
- /** @type {?} */
- patchRange: Object,
- path: {
- type: String,
- observer: '_pathObserver',
- },
- prefs: {
- type: Object,
- observer: '_prefsObserver',
- },
- projectName: String,
- displayLine: {
- type: Boolean,
- value: false,
- },
- isImageDiff: {
- type: Boolean,
- },
- commitRange: Object,
- hidden: {
- type: Boolean,
- reflectToAttribute: true,
- },
- noRenderOnPrefsChange: Boolean,
- /** @type {!Array<!Gerrit.HoveredRange>} */
- _commentRanges: {
- type: Array,
- value: () => [],
- },
- /** @type {!Array<!Gerrit.CoverageRange>} */
- coverageRanges: {
- type: Array,
- value: () => [],
- },
- lineWrapping: {
- type: Boolean,
- value: false,
- observer: '_lineWrappingObserver',
- },
- viewMode: {
- type: String,
- value: DiffViewMode.SIDE_BY_SIDE,
- observer: '_viewModeObserver',
- },
-
- /** @type {?Gerrit.LineOfInterest} */
- lineOfInterest: Object,
-
- loading: {
- type: Boolean,
- value: false,
- observer: '_loadingChanged',
- },
-
- loggedIn: {
- type: Boolean,
- value: false,
- },
- diff: {
- type: Object,
- observer: '_diffChanged',
- },
- _diffHeaderItems: {
- type: Array,
- value: [],
- computed: '_computeDiffHeaderItems(diff.*)',
- },
- _diffTableClass: {
- type: String,
- value: '',
- },
- /** @type {?Object} */
- baseImage: Object,
- /** @type {?Object} */
- revisionImage: Object,
-
- /**
- * Whether the safety check for large diffs when whole-file is set has
- * been bypassed. If the value is null, then the safety has not been
- * bypassed. If the value is a number, then that number represents the
- * context preference to use when rendering the bypassed diff.
- *
- * @type {number|null}
- */
- _safetyBypass: {
- type: Number,
- value: null,
- },
-
- _showWarning: Boolean,
-
- /** @type {?string} */
- errorMessage: {
- type: String,
- value: null,
- },
-
- /** @type {?Object} */
- blame: {
- type: Object,
- value: null,
- observer: '_blameChanged',
- },
-
- parentIndex: Number,
-
- showNewlineWarningLeft: {
- type: Boolean,
- value: false,
- },
- showNewlineWarningRight: {
- type: Boolean,
- value: false,
- },
-
- _newlineWarning: {
- type: String,
- computed: '_computeNewlineWarning(' +
- 'showNewlineWarningLeft, showNewlineWarningRight)',
- },
-
- _diffLength: Number,
-
- /**
- * Observes comment nodes added or removed after the initial render.
- * Can be used to unregister when the entire diff is (re-)rendered or upon
- * detachment.
- *
- * @type {?PolymerDomApi.ObserveHandle}
- */
- _incrementalNodeObserver: Object,
-
- /**
- * Observes comment nodes added or removed at any point.
- * Can be used to unregister upon detachment.
- *
- * @type {?PolymerDomApi.ObserveHandle}
- */
- _nodeObserver: Object,
-
- /** Set by Polymer. */
- isAttached: Boolean,
- layers: Array,
- };
- }
-
- static get observers() {
- return [
- '_enableSelectionObserver(loggedIn, isAttached)',
- ];
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('create-range-comment',
- e => this._handleCreateRangeComment(e));
- this.addEventListener('render-content',
- () => this._handleRenderContent());
- }
-
- /** @override */
- attached() {
- super.attached();
- this._observeNodes();
- }
-
- /** @override */
- detached() {
- super.detached();
- this._unobserveIncrementalNodes();
- this._unobserveNodes();
- }
-
- showNoChangeMessage(loading, prefs, diffLength, diff) {
- return !loading &&
- diff && !diff.binary &&
- prefs && prefs.ignore_whitespace !== 'IGNORE_NONE' &&
- diffLength === 0;
- }
-
- _enableSelectionObserver(loggedIn, isAttached) {
- // Polymer 2: check for undefined
- if ([loggedIn, isAttached].includes(undefined)) {
- return;
- }
-
- if (loggedIn && isAttached) {
- this.listen(document, 'selectionchange', '_handleSelectionChange');
- this.listen(document, 'mouseup', '_handleMouseUp');
- } else {
- this.unlisten(document, 'selectionchange', '_handleSelectionChange');
- this.unlisten(document, 'mouseup', '_handleMouseUp');
- }
- }
-
- _handleSelectionChange() {
- // Because of shadow DOM selections, we handle the selectionchange here,
- // and pass the shadow DOM selection into gr-diff-highlight, where the
- // corresponding range is determined and normalized.
- const selection = this._getShadowOrDocumentSelection();
- this.$.highlights.handleSelectionChange(selection, false);
- }
-
- _handleMouseUp(e) {
- // To handle double-click outside of text creating comments, we check on
- // mouse-up if there's a selection that just covers a line change. We
- // can't do that on selection change since the user may still be dragging.
- const selection = this._getShadowOrDocumentSelection();
- this.$.highlights.handleSelectionChange(selection, true);
- }
-
- /** Gets the current selection, preferring the shadow DOM selection. */
- _getShadowOrDocumentSelection() {
- // When using native shadow DOM, the selection returned by
- // document.getSelection() cannot reference the actual DOM elements making
- // up the diff, because they are in the shadow DOM of the gr-diff element.
- // This takes the shadow DOM selection if one exists.
- return this.root.getSelection ?
- this.root.getSelection() :
- document.getSelection();
- }
-
- _observeNodes() {
- this._nodeObserver = dom(this).observeNodes(info => {
- const addedThreadEls = info.addedNodes.filter(isThreadEl);
- const removedThreadEls = info.removedNodes.filter(isThreadEl);
- this._updateRanges(addedThreadEls, removedThreadEls);
- this._redispatchHoverEvents(addedThreadEls);
- });
- }
-
- _updateRanges(addedThreadEls, removedThreadEls) {
- function commentRangeFromThreadEl(threadEl) {
- const side = threadEl.getAttribute('comment-side');
- const range = JSON.parse(threadEl.getAttribute('range'));
- return {side, range, hovering: false, rootId: threadEl.rootId};
- }
-
- const addedCommentRanges = addedThreadEls
- .map(commentRangeFromThreadEl)
- .filter(({range}) => range);
- const removedCommentRanges = removedThreadEls
- .map(commentRangeFromThreadEl)
- .filter(({range}) => range);
- for (const removedCommentRange of removedCommentRanges) {
- const i = this._commentRanges
- .findIndex(
- cr => cr.side === removedCommentRange.side &&
- rangesEqual(cr.range, removedCommentRange.range)
- );
- this.splice('_commentRanges', i, 1);
- }
-
- if (addedCommentRanges && addedCommentRanges.length) {
- this.push('_commentRanges', ...addedCommentRanges);
- }
- }
-
- /**
- * The key locations based on the comments and line of interests,
- * where lines should not be collapsed.
- *
- * @return {{left: Object<(string|number), boolean>,
- * right: Object<(string|number), boolean>}}
- */
- _computeKeyLocations() {
- const keyLocations = {left: {}, right: {}};
- if (this.lineOfInterest) {
- const side = this.lineOfInterest.leftSide ? 'left' : 'right';
- keyLocations[side][this.lineOfInterest.number] = true;
- }
- const threadEls = dom(this).getEffectiveChildNodes()
- .filter(isThreadEl);
-
- for (const threadEl of threadEls) {
- const commentSide = threadEl.getAttribute('comment-side');
- const lineNum = Number(threadEl.getAttribute('line-num')) ||
- GrDiffLine.FILE;
- const commentRange = threadEl.range || {};
- keyLocations[commentSide][lineNum] = true;
- // Add start_line as well if exists,
- // the being and end of the range should not be collapsed.
- if (commentRange.start_line) {
- keyLocations[commentSide][commentRange.start_line] = true;
- }
- }
- return keyLocations;
- }
-
- // Dispatch events that are handled by the gr-diff-highlight.
- _redispatchHoverEvents(addedThreadEls) {
- for (const threadEl of addedThreadEls) {
- threadEl.addEventListener('mouseenter', () => {
- threadEl.dispatchEvent(new CustomEvent(
- 'comment-thread-mouseenter', {bubbles: true, composed: true}));
- });
- threadEl.addEventListener('mouseleave', () => {
- threadEl.dispatchEvent(new CustomEvent(
- 'comment-thread-mouseleave', {bubbles: true, composed: true}));
- });
- }
- }
-
- /** Cancel any remaining diff builder rendering work. */
- cancel() {
- this.$.diffBuilder.cancel();
- this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
- }
-
- /** @return {!Array<!HTMLElement>} */
- getCursorStops() {
- if (this.hidden && this.noAutoRender) {
- return [];
- }
-
- return Array.from(
- dom(this.root).querySelectorAll(':not(.contextControl) > .diff-row'))
- .filter(tr => tr.querySelector('button'));
- }
-
- /** @return {boolean} */
- isRangeSelected() {
- return !!this.$.highlights.selectedRange;
- }
-
- toggleLeftDiff() {
- this.toggleClass('no-left');
- }
-
- _blameChanged(newValue) {
- this.$.diffBuilder.setBlame(newValue);
- if (newValue) {
- this.classList.add('showBlame');
- } else {
- this.classList.remove('showBlame');
- }
- }
-
- /** @return {string} */
- _computeContainerClass(loggedIn, viewMode, displayLine) {
- const classes = ['diffContainer'];
- switch (viewMode) {
- case DiffViewMode.UNIFIED:
- classes.push('unified');
- break;
- case DiffViewMode.SIDE_BY_SIDE:
- classes.push('sideBySide');
- break;
- default:
- throw Error('Invalid view mode: ', viewMode);
- }
- if (getHiddenScroll()) {
- classes.push('hiddenscroll');
- }
- if (loggedIn) {
- classes.push('canComment');
- }
- if (displayLine) {
- classes.push('displayLine');
- }
- return classes.join(' ');
- }
-
- _handleTap(e) {
- const el = dom(e).localTarget;
-
- if (el.classList.contains('showContext')) {
- this.dispatchEvent(new CustomEvent('diff-context-expanded', {
- detail: {
- numLines: e.detail.numLines,
- },
- composed: true, bubbles: true,
- }));
- this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
- } else if (el.classList.contains('lineNum') ||
- el.classList.contains('lineNumButton')) {
- this.addDraftAtLine(el);
- } else if (el.tagName === 'HL' ||
- el.classList.contains('content') ||
- el.classList.contains('contentText')) {
- const target = this.$.diffBuilder.getLineElByChild(el);
- if (target) { this._selectLine(target); }
- }
- }
-
- _selectLine(el) {
- this.dispatchEvent(new CustomEvent('line-selected', {
- detail: {
- side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
- number: el.getAttribute('data-value'),
- path: this.path,
- },
- composed: true, bubbles: true,
- }));
- }
-
- addDraftAtLine(el) {
- this._selectLine(el);
- if (!this._isValidElForComment(el)) { return; }
-
- const value = el.getAttribute('data-value');
- let lineNum;
- if (value !== GrDiffLine.FILE) {
- lineNum = parseInt(value, 10);
- if (isNaN(lineNum)) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_INVALID_LINE + value},
- composed: true, bubbles: true,
- }));
- return;
- }
- }
- this._createComment(el, lineNum);
- }
-
- createRangeComment() {
- if (!this.isRangeSelected()) {
- throw Error('Selection is needed for new range comment');
- }
- const {side, range} = this.$.highlights.selectedRange;
- this._createCommentForSelection(side, range);
- }
-
- _createCommentForSelection(side, range) {
- const lineNum = range.end_line;
- const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
- if (this._isValidElForComment(lineEl)) {
- this._createComment(lineEl, lineNum, side, range);
- }
- }
-
- _handleCreateRangeComment(e) {
- const range = e.detail.range;
- const side = e.detail.side;
- this._createCommentForSelection(side, range);
- }
-
- /** @return {boolean} */
- _isValidElForComment(el) {
- if (!this.loggedIn) {
- this.dispatchEvent(new CustomEvent('show-auth-required', {
- composed: true, bubbles: true,
- }));
- return false;
- }
- const patchNum = el.classList.contains(DiffSide.LEFT) ?
- this.patchRange.basePatchNum :
- this.patchRange.patchNum;
-
- const isEdit = patchNumEquals(patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
- const isEditBase = patchNumEquals(patchNum, SPECIAL_PATCH_SET_NUM.PARENT) &&
- patchNumEquals(this.patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT);
-
- if (isEdit) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_COMMENT_ON_EDIT},
- composed: true, bubbles: true,
- }));
- return false;
- } else if (isEditBase) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: ERR_COMMENT_ON_EDIT_BASE},
- composed: true, bubbles: true,
- }));
- return false;
- }
- return true;
- }
-
- /**
- * @param {!Object} lineEl
- * @param {number=} lineNum
- * @param {string=} side
- * @param {!Object=} range
- */
- _createComment(lineEl, lineNum, side, range) {
- const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
- side = side ||
- this._getCommentSideByLineAndContent(lineEl, contentEl);
- const patchForNewThreads = this._getPatchNumByLineAndContent(
- lineEl, contentEl);
- const isOnParent =
- this._getIsParentCommentByLineAndContent(lineEl, contentEl);
- this.dispatchEvent(new CustomEvent('create-comment', {
- bubbles: true,
- composed: true,
- detail: {
- lineNum,
- side,
- patchNum: patchForNewThreads,
- isOnParent,
- range,
- },
- }));
- }
-
- _getThreadGroupForLine(contentEl) {
- return contentEl.querySelector('.thread-group');
- }
-
- /**
- * Gets or creates a comment thread group for a specific line and side on a
- * diff.
- *
- * @param {!Object} contentEl
- * @param {!DiffSide} commentSide
- * @return {!Node}
- */
- _getOrCreateThreadGroup(contentEl, commentSide) {
- // Check if thread group exists.
- let threadGroupEl = this._getThreadGroupForLine(contentEl);
- if (!threadGroupEl) {
- threadGroupEl = document.createElement('div');
- threadGroupEl.className = 'thread-group';
- threadGroupEl.setAttribute('data-side', commentSide);
- contentEl.appendChild(threadGroupEl);
- }
- return threadGroupEl;
- }
-
- /**
- * The value to be used for the patch number of new comments created at the
- * given line and content elements.
- *
- * In two cases of creating a comment on the left side, the patch number to
- * be used should actually be right side of the patch range:
- * - When the patch range is against the parent comment of a normal change.
- * Such comments declare themmselves to be on the left using side=PARENT.
- * - If the patch range is against the indexed parent of a merge change.
- * Such comments declare themselves to be on the given parent by
- * specifying the parent index via parent=i.
- *
- * @return {number}
- */
- _getPatchNumByLineAndContent(lineEl, contentEl) {
- let patchNum = this.patchRange.patchNum;
-
- if ((lineEl.classList.contains(DiffSide.LEFT) ||
- contentEl.classList.contains('remove')) &&
- this.patchRange.basePatchNum !== 'PARENT' &&
- !isMergeParent(this.patchRange.basePatchNum)) {
- patchNum = this.patchRange.basePatchNum;
- }
- return patchNum;
- }
-
- /** @return {boolean} */
- _getIsParentCommentByLineAndContent(lineEl, contentEl) {
- return (lineEl.classList.contains(DiffSide.LEFT) ||
- contentEl.classList.contains('remove')) &&
- (this.patchRange.basePatchNum === 'PARENT' ||
- isMergeParent(this.patchRange.basePatchNum));
- }
-
- /** @return {string} */
- _getCommentSideByLineAndContent(lineEl, contentEl) {
- let side = 'right';
- if (lineEl.classList.contains(DiffSide.LEFT) ||
- contentEl.classList.contains('remove')) {
- side = 'left';
- }
- return side;
- }
-
- _prefsObserver(newPrefs, oldPrefs) {
- if (!this._prefsEqual(newPrefs, oldPrefs)) {
- this._prefsChanged(newPrefs);
- }
- }
-
- _prefsEqual(prefs1, prefs2) {
- if (prefs1 === prefs2) {
- return true;
- }
- if (!prefs1 || !prefs2) {
- return false;
- }
- // Scan the preference objects one level deep to see if they differ.
- const keys1 = Object.keys(prefs1);
- const keys2 = Object.keys(prefs2);
- return keys1.length === keys2.length &&
- keys1.every(key => prefs1[key] === prefs2[key]) &&
- keys2.every(key => prefs1[key] === prefs2[key]);
- }
-
- _pathObserver() {
- // Call _prefsChanged(), because line-limit style value depends on path.
- this._prefsChanged(this.prefs);
- }
-
- _viewModeObserver() {
- this._prefsChanged(this.prefs);
- }
-
- _cleanup() {
- this.cancel();
- this._blame = null;
- this._safetyBypass = null;
- this._showWarning = false;
- this.clearDiffContent();
- }
-
- /** @param {boolean} newValue */
- _loadingChanged(newValue) {
- if (newValue) {
- this._cleanup();
- }
- }
-
- _lineWrappingObserver() {
- this._prefsChanged(this.prefs);
- }
-
- _prefsChanged(prefs) {
- if (!prefs) { return; }
-
- this._blame = null;
-
- const lineLength = this.path === COMMIT_MSG_PATH ?
- COMMIT_MSG_LINE_LENGTH : prefs.line_length;
- const stylesToUpdate = {};
-
- if (prefs.line_wrapping) {
- this._diffTableClass = 'full-width';
- if (this.viewMode === 'SIDE_BY_SIDE') {
- stylesToUpdate['--content-width'] = 'none';
- stylesToUpdate['--line-limit'] = lineLength + 'ch';
- }
- } else {
- this._diffTableClass = '';
- stylesToUpdate['--content-width'] = lineLength + 'ch';
- }
-
- if (prefs.font_size) {
- stylesToUpdate['--font-size'] = prefs.font_size + 'px';
- }
-
- this.updateStyles(stylesToUpdate);
-
- if (this.diff && !this.noRenderOnPrefsChange) {
- this._debounceRenderDiffTable();
- }
- }
-
- _diffChanged(newValue) {
- if (newValue) {
- this._cleanup();
- this._diffLength = this.getDiffLength(newValue);
- this._debounceRenderDiffTable();
- }
- }
-
- /**
- * When called multiple times from the same microtask, will call
- * _renderDiffTable only once, in the next microtask, unless it is cancelled
- * before that microtask runs.
- *
- * This should be used instead of calling _renderDiffTable directly to
- * render the diff in response to an input change, because there may be
- * multiple inputs changing in the same microtask, but we only want to
- * render once.
- */
- _debounceRenderDiffTable() {
- this.debounce(
- RENDER_DIFF_TABLE_DEBOUNCE_NAME, () => this._renderDiffTable());
- }
-
- _renderDiffTable() {
- if (!this.prefs) {
- this.dispatchEvent(
- new CustomEvent('render', {bubbles: true, composed: true}));
- return;
- }
- if (this.prefs.context === -1 &&
- this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
- this._safetyBypass === null) {
- this._showWarning = true;
- this.dispatchEvent(
- new CustomEvent('render', {bubbles: true, composed: true}));
- return;
- }
-
- this._showWarning = false;
-
- const keyLocations = this._computeKeyLocations();
- this.$.diffBuilder.render(keyLocations, this._getBypassPrefs())
- .then(() => {
- this.dispatchEvent(
- new CustomEvent('render', {
- bubbles: true,
- composed: true,
- detail: {contentRendered: true},
- }));
- });
- }
-
- _handleRenderContent() {
- this._unobserveIncrementalNodes();
- this._incrementalNodeObserver = dom(this).observeNodes(info => {
- const addedThreadEls = info.addedNodes.filter(isThreadEl);
- // Removed nodes do not need to be handled because all this code does is
- // adding a slot for the added thread elements, and the extra slots do
- // not hurt. It's probably a bigger performance cost to remove them than
- // to keep them around. Medium term we can even consider to add one slot
- // for each line from the start.
- let lastEl;
- for (const threadEl of addedThreadEls) {
- const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
- const commentSide = threadEl.getAttribute('comment-side');
- const lineEl = this.$.diffBuilder.getLineElByNumber(
- lineNumString, commentSide);
- // When the line the comment refers to does not exist, log an error
- // but don't crash. This can happen e.g. if the API does not fully
- // validate e.g. (robot) comments
- if (lineEl == undefined) {
- console.error(
- 'thread attached to line ', commentSide, lineNumString,
- ' which does not exist.');
- continue;
- }
- const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
- const threadGroupEl = this._getOrCreateThreadGroup(
- contentEl, commentSide);
- // Create a slot for the thread and attach it to the thread group.
- // The Polyfill has some bugs and this only works if the slot is
- // attached to the group after the group is attached to the DOM.
- // The thread group may already have a slot with the right name, but
- // that is okay because the first matching slot is used and the rest
- // are ignored.
- const slot = document.createElement('slot');
- slot.name = threadEl.getAttribute('slot');
- dom(threadGroupEl).appendChild(slot);
- lastEl = threadEl;
- }
-
- // Safari is not binding newly created comment-thread
- // with the slot somehow, replace itself will rebind it
- // @see Issue 11182
- if (lastEl && lastEl.replaceWith) {
- lastEl.replaceWith(lastEl);
- }
- });
- }
-
- _unobserveIncrementalNodes() {
- if (this._incrementalNodeObserver) {
- dom(this).unobserveNodes(this._incrementalNodeObserver);
- }
- }
-
- _unobserveNodes() {
- if (this._nodeObserver) {
- dom(this).unobserveNodes(this._nodeObserver);
- }
- }
-
- /**
- * Get the preferences object including the safety bypass context (if any).
- */
- _getBypassPrefs() {
- if (this._safetyBypass !== null) {
- return Object.assign({}, this.prefs, {context: this._safetyBypass});
- }
- return this.prefs;
- }
-
- clearDiffContent() {
- this._unobserveIncrementalNodes();
- this.$.diffTable.innerHTML = null;
- }
-
- /** @return {!Array} */
- _computeDiffHeaderItems(diffInfoRecord) {
- const diffInfo = diffInfoRecord.base;
- if (!diffInfo || !diffInfo.diff_header) { return []; }
- return diffInfo.diff_header
- .filter(item => !(item.startsWith('diff --git ') ||
- item.startsWith('index ') ||
- item.startsWith('+++ ') ||
- item.startsWith('--- ') ||
- item === 'Binary files differ'));
- }
-
- /** @return {boolean} */
- _computeDiffHeaderHidden(items) {
- return items.length === 0;
- }
-
- _handleFullBypass() {
- this._safetyBypass = FULL_CONTEXT;
- this._debounceRenderDiffTable();
- }
-
- _handleLimitedBypass() {
- this._safetyBypass = LIMITED_CONTEXT;
- this._debounceRenderDiffTable();
- }
-
- /** @return {string} */
- _computeWarningClass(showWarning) {
- return showWarning ? 'warn' : '';
- }
-
- /**
- * @param {string} errorMessage
- * @return {string}
- */
- _computeErrorClass(errorMessage) {
- return errorMessage ? 'showError' : '';
- }
-
- expandAllContext() {
- this._handleFullBypass();
- }
-
- /**
- * @param {!boolean} warnLeft
- * @param {!boolean} warnRight
- * @return {string|null}
- */
- _computeNewlineWarning(warnLeft, warnRight) {
- const messages = [];
- if (warnLeft) {
- messages.push(NO_NEWLINE_BASE);
- }
- if (warnRight) {
- messages.push(NO_NEWLINE_REVISION);
- }
- if (!messages.length) { return null; }
- return messages.join(' \u2014 ');// \u2014 - '—'
- }
-
- /**
- * @param {string} warning
- * @param {boolean} loading
- * @return {string}
- */
- _computeNewlineWarningClass(warning, loading) {
- if (loading || !warning) { return 'newlineWarning hidden'; }
- return 'newlineWarning';
- }
-
- /**
- * Get the approximate length of the diff as the sum of the maximum
- * length of the chunks.
- *
- * @param {Object} diff object
- * @return {number}
- */
- getDiffLength(diff) {
- if (!diff) return 0;
- return diff.content.reduce((sum, sec) => {
- if (sec.hasOwnProperty('ab')) {
- return sum + sec.ab.length;
- } else {
- return sum + Math.max(
- sec.hasOwnProperty('a') ? sec.a.length : 0,
- sec.hasOwnProperty('b') ? sec.b.length : 0);
- }
- }, 0);
- }
-}
-
-customElements.define(GrDiff.is, GrDiff);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
new file mode 100644
index 0000000..14b8666
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -0,0 +1,1074 @@
+/**
+ * @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/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../gr-diff-builder/gr-diff-builder-element';
+import '../gr-diff-highlight/gr-diff-highlight';
+import '../gr-diff-selection/gr-diff-selection';
+import '../gr-syntax-themes/gr-syntax-theme';
+import '../gr-ranged-comment-themes/gr-ranged-comment-theme';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import {htmlTemplate} from './gr-diff_html';
+import {FILE, LineNumber} from './gr-diff-line';
+import {getLineNumber, rangesEqual} from './gr-diff-utils';
+import {getHiddenScroll} from '../../../scripts/hiddenscroll';
+import {isMergeParent, patchNumEquals} from '../../../utils/patch-set-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {
+ BlameInfo,
+ CommentRange,
+ DiffInfo,
+ DiffPreferencesInfo,
+ DiffPreferencesInfoKey,
+ EditPatchSetNum,
+ ImageInfo,
+ ParentPatchSetNum,
+ PatchRange,
+} from '../../../types/common';
+import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
+import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+import {
+ CoverageRange,
+ DiffLayer,
+ PolymerDomWrapper,
+} from '../../../types/types';
+import {CommentRangeLayer} from '../gr-ranged-comment-layer/gr-ranged-comment-layer';
+import {DiffViewMode, Side} from '../../../constants/constants';
+import {KeyLocations} from '../gr-diff-processor/gr-diff-processor';
+import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {AbortStop} from '../../shared/gr-cursor-manager/gr-cursor-manager';
+
+const NO_NEWLINE_BASE = 'No newline at end of base file.';
+const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
+
+const LARGE_DIFF_THRESHOLD_LINES = 10000;
+const FULL_CONTEXT = -1;
+const LIMITED_CONTEXT = 10;
+
+function getSide(threadEl: GrCommentThread): Side {
+ const sideAtt = threadEl.getAttribute('comment-side');
+ if (!sideAtt) throw Error('comment thread without side');
+ if (sideAtt !== 'left' && sideAtt !== 'right')
+ throw Error(`unexpected value for side: ${sideAtt}`);
+ return sideAtt as Side;
+}
+
+function isThreadEl(node: Node): node is GrCommentThread {
+ return (
+ node.nodeType === Node.ELEMENT_NODE &&
+ (node as Element).classList.contains('comment-thread')
+ );
+}
+
+// TODO(TS): Replace by proper GrCommentThread once converted.
+type GrCommentThread = PolymerElement & {
+ rootId: string;
+ range: CommentRange;
+};
+
+const COMMIT_MSG_PATH = '/COMMIT_MSG';
+/**
+ * 72 is the unofficial length standard for git commit messages.
+ * Derived from the fact that git log/show appends 4 ws in the beginning of
+ * each line when displaying commit messages. To center the commit message
+ * in an 80 char terminal a 4 ws border is added to the rightmost side:
+ * 4 + 72 + 4
+ */
+const COMMIT_MSG_LINE_LENGTH = 72;
+
+const RENDER_DIFF_TABLE_DEBOUNCE_NAME = 'renderDiffTable';
+
+export interface LineOfInterest {
+ number: number;
+ leftSide: boolean;
+}
+
+export interface GrDiff {
+ $: {
+ highlights: GrDiffHighlight;
+ diffBuilder: GrDiffBuilderElement;
+ diffTable: HTMLTableElement;
+ };
+}
+
+@customElement('gr-diff')
+export class GrDiff extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the user selects a line.
+ *
+ * @event line-selected
+ */
+
+ /**
+ * Fired if being logged in is required.
+ *
+ * @event show-auth-required
+ */
+
+ /**
+ * Fired when a comment is created
+ *
+ * @event create-comment
+ */
+
+ /**
+ * Fired when rendering, including syntax highlighting, is done. Also fired
+ * when no rendering can be done because required preferences are not set.
+ *
+ * @event render
+ */
+
+ /**
+ * Fired for interaction reporting when a diff context is expanded.
+ * Contains an event.detail with numLines about the number of lines that
+ * were expanded.
+ *
+ * @event diff-context-expanded
+ */
+
+ @property({type: String})
+ changeNum?: string;
+
+ @property({type: Boolean})
+ noAutoRender = false;
+
+ @property({type: Object})
+ patchRange?: PatchRange;
+
+ @property({type: String, observer: '_pathObserver'})
+ path?: string;
+
+ @property({type: Object, observer: '_prefsObserver'})
+ prefs?: DiffPreferencesInfo;
+
+ @property({type: Boolean})
+ displayLine = false;
+
+ @property({type: Boolean})
+ isImageDiff?: boolean;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ hidden = false;
+
+ @property({type: Boolean})
+ noRenderOnPrefsChange?: boolean;
+
+ @property({type: Array})
+ _commentRanges: CommentRangeLayer[] = [];
+
+ @property({type: Array})
+ coverageRanges: CoverageRange[] = [];
+
+ @property({type: Boolean, observer: '_lineWrappingObserver'})
+ lineWrapping = false;
+
+ @property({type: String, observer: '_viewModeObserver'})
+ viewMode = DiffViewMode.SIDE_BY_SIDE;
+
+ @property({type: Object})
+ lineOfInterest?: LineOfInterest;
+
+ /** True when diff is changed, until the content is done rendering. */
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Boolean})
+ loggedIn = false;
+
+ @property({type: Object, observer: '_diffChanged'})
+ diff?: DiffInfo;
+
+ @property({type: Array, computed: '_computeDiffHeaderItems(diff.*)'})
+ _diffHeaderItems: unknown[] = [];
+
+ @property({type: String})
+ _diffTableClass = '';
+
+ @property({type: Object})
+ baseImage?: ImageInfo;
+
+ @property({type: Object})
+ revisionImage?: ImageInfo;
+
+ /**
+ * Whether the safety check for large diffs when whole-file is set has
+ * been bypassed. If the value is null, then the safety has not been
+ * bypassed. If the value is a number, then that number represents the
+ * context preference to use when rendering the bypassed diff.
+ */
+ @property({type: Number})
+ _safetyBypass: number | null = null;
+
+ @property({type: Boolean})
+ _showWarning?: boolean;
+
+ @property({type: String})
+ errorMessage: string | null = null;
+
+ @property({type: Object, observer: '_blameChanged'})
+ blame: BlameInfo[] | null = null;
+
+ @property({type: Number})
+ parentIndex?: number;
+
+ @property({type: Boolean})
+ showNewlineWarningLeft = false;
+
+ @property({type: Boolean})
+ showNewlineWarningRight = false;
+
+ @property({type: Boolean})
+ useNewContextControls = false;
+
+ @property({
+ type: String,
+ computed:
+ '_computeNewlineWarning(' +
+ 'showNewlineWarningLeft, showNewlineWarningRight)',
+ })
+ _newlineWarning: string | null = null;
+
+ @property({type: Number})
+ _diffLength?: number;
+
+ /**
+ * Observes comment nodes added or removed after the initial render.
+ * Can be used to unregister when the entire diff is (re-)rendered or upon
+ * detachment.
+ */
+ @property({type: Object})
+ _incrementalNodeObserver?: FlattenedNodesObserver;
+
+ /**
+ * Observes comment nodes added or removed at any point.
+ * Can be used to unregister upon detachment.
+ */
+ @property({type: Object})
+ _nodeObserver?: FlattenedNodesObserver;
+
+ @property({type: Array})
+ layers?: DiffLayer[];
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('create-range-comment', (e: Event) =>
+ this._handleCreateRangeComment(e as CustomEvent)
+ );
+ this.addEventListener('render-content', () => this._handleRenderContent());
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._observeNodes();
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this._unobserveIncrementalNodes();
+ this._unobserveNodes();
+ }
+
+ showNoChangeMessage(
+ loading?: boolean,
+ prefs?: DiffPreferencesInfo,
+ diffLength?: number,
+ diff?: DiffInfo
+ ) {
+ return (
+ !loading &&
+ diff &&
+ !diff.binary &&
+ prefs &&
+ prefs.ignore_whitespace !== 'IGNORE_NONE' &&
+ diffLength === 0
+ );
+ }
+
+ @observe('loggedIn', 'isAttached')
+ _enableSelectionObserver(loggedIn: boolean, isAttached?: boolean) {
+ // Polymer 2: check for undefined
+ if ([loggedIn, isAttached].includes(undefined)) {
+ return;
+ }
+
+ if (loggedIn && isAttached) {
+ this.listen(document, 'selectionchange', '_handleSelectionChange');
+ this.listen(document, 'mouseup', '_handleMouseUp');
+ } else {
+ this.unlisten(document, 'selectionchange', '_handleSelectionChange');
+ this.unlisten(document, 'mouseup', '_handleMouseUp');
+ }
+ }
+
+ _handleSelectionChange() {
+ // Because of shadow DOM selections, we handle the selectionchange here,
+ // and pass the shadow DOM selection into gr-diff-highlight, where the
+ // corresponding range is determined and normalized.
+ const selection = this._getShadowOrDocumentSelection();
+ this.$.highlights.handleSelectionChange(selection, false);
+ }
+
+ _handleMouseUp() {
+ // To handle double-click outside of text creating comments, we check on
+ // mouse-up if there's a selection that just covers a line change. We
+ // can't do that on selection change since the user may still be dragging.
+ const selection = this._getShadowOrDocumentSelection();
+ this.$.highlights.handleSelectionChange(selection, true);
+ }
+
+ /** Gets the current selection, preferring the shadow DOM selection. */
+ _getShadowOrDocumentSelection() {
+ // When using native shadow DOM, the selection returned by
+ // document.getSelection() cannot reference the actual DOM elements making
+ // up the diff, because they are in the shadow DOM of the gr-diff element.
+ // This takes the shadow DOM selection if one exists.
+ return this.root instanceof ShadowRoot && this.root.getSelection
+ ? this.root.getSelection()
+ : document.getSelection();
+ }
+
+ _observeNodes() {
+ this._nodeObserver = (dom(this) as PolymerDomWrapper).observeNodes(info => {
+ const addedThreadEls = info.addedNodes.filter(isThreadEl);
+ const removedThreadEls = info.removedNodes.filter(isThreadEl);
+ this._updateRanges(addedThreadEls, removedThreadEls);
+ this._redispatchHoverEvents(addedThreadEls);
+ });
+ }
+
+ // TODO(brohlfs): Rewrite gr-diff to be agnostic of GrCommentThread, because
+ // other users of gr-diff may use different comment widgets.
+ _updateRanges(
+ addedThreadEls: GrCommentThread[],
+ removedThreadEls: GrCommentThread[]
+ ) {
+ function commentRangeFromThreadEl(
+ threadEl: GrCommentThread
+ ): CommentRangeLayer | undefined {
+ const side = getSide(threadEl);
+
+ const rangeAtt = threadEl.getAttribute('range');
+ if (!rangeAtt) return undefined;
+ const range = JSON.parse(rangeAtt) as CommentRange;
+
+ return {side, range, hovering: false, rootId: threadEl.rootId};
+ }
+
+ // TODO(brohlfs): Rewrite `.map().filter() as ...` with `.reduce()` instead.
+ const addedCommentRanges = addedThreadEls
+ .map(commentRangeFromThreadEl)
+ .filter(range => !!range) as CommentRangeLayer[];
+ const removedCommentRanges = removedThreadEls
+ .map(commentRangeFromThreadEl)
+ .filter(range => !!range) as CommentRangeLayer[];
+ for (const removedCommentRange of removedCommentRanges) {
+ const i = this._commentRanges.findIndex(
+ cr =>
+ cr.side === removedCommentRange.side &&
+ rangesEqual(cr.range, removedCommentRange.range)
+ );
+ this.splice('_commentRanges', i, 1);
+ }
+
+ if (addedCommentRanges && addedCommentRanges.length) {
+ this.push('_commentRanges', ...addedCommentRanges);
+ }
+ }
+
+ /**
+ * The key locations based on the comments and line of interests,
+ * where lines should not be collapsed.
+ *
+ * @return
+ */
+ _computeKeyLocations() {
+ const keyLocations: KeyLocations = {left: {}, right: {}};
+ if (this.lineOfInterest) {
+ const side = this.lineOfInterest.leftSide ? Side.LEFT : Side.RIGHT;
+ keyLocations[side][this.lineOfInterest.number] = true;
+ }
+ const threadEls = (dom(this) as PolymerDomWrapper)
+ .getEffectiveChildNodes()
+ .filter(isThreadEl);
+
+ for (const threadEl of threadEls) {
+ const side = getSide(threadEl);
+ const lineNum = Number(threadEl.getAttribute('line-num')) || FILE;
+ const commentRange = threadEl.range || {};
+ keyLocations[side][lineNum] = true;
+ // Add start_line as well if exists,
+ // the being and end of the range should not be collapsed.
+ if (commentRange.start_line) {
+ keyLocations[side][commentRange.start_line] = true;
+ }
+ }
+ return keyLocations;
+ }
+
+ // Dispatch events that are handled by the gr-diff-highlight.
+ _redispatchHoverEvents(addedThreadEls: GrCommentThread[]) {
+ for (const threadEl of addedThreadEls) {
+ threadEl.addEventListener('mouseenter', () => {
+ threadEl.dispatchEvent(
+ new CustomEvent('comment-thread-mouseenter', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ });
+ threadEl.addEventListener('mouseleave', () => {
+ threadEl.dispatchEvent(
+ new CustomEvent('comment-thread-mouseleave', {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ });
+ }
+ }
+
+ /** Cancel any remaining diff builder rendering work. */
+ cancel() {
+ this.$.diffBuilder.cancel();
+ this.cancelDebouncer(RENDER_DIFF_TABLE_DEBOUNCE_NAME);
+ }
+
+ getCursorStops(): Array<HTMLElement | AbortStop> {
+ if (this.hidden && this.noAutoRender) return [];
+
+ if (this._loading) {
+ return [new AbortStop()];
+ }
+
+ return Array.from(
+ this.root?.querySelectorAll<HTMLElement>(
+ ':not(.contextControl) > .diff-row'
+ ) || []
+ ).filter(tr => tr.querySelector('button'));
+ }
+
+ isRangeSelected() {
+ return !!this.$.highlights.selectedRange;
+ }
+
+ toggleLeftDiff() {
+ this.toggleClass('no-left');
+ }
+
+ _blameChanged(newValue?: BlameInfo[] | null) {
+ if (newValue === undefined) return;
+ this.$.diffBuilder.setBlame(newValue);
+ if (newValue) {
+ this.classList.add('showBlame');
+ } else {
+ this.classList.remove('showBlame');
+ }
+ }
+
+ _computeContainerClass(
+ loggedIn: boolean,
+ viewMode: DiffViewMode,
+ displayLine: boolean
+ ) {
+ const classes = ['diffContainer'];
+ if (viewMode === DiffViewMode.UNIFIED) classes.push('unified');
+ if (viewMode === DiffViewMode.SIDE_BY_SIDE) classes.push('sideBySide');
+ if (getHiddenScroll()) classes.push('hiddenscroll');
+ if (loggedIn) classes.push('canComment');
+ if (displayLine) classes.push('displayLine');
+ return classes.join(' ');
+ }
+
+ _handleTap(e: CustomEvent) {
+ const el = (dom(e) as EventApi).localTarget as Element;
+
+ if (el.classList.contains('showContext')) {
+ this.dispatchEvent(
+ new CustomEvent('diff-context-expanded', {
+ detail: {
+ numLines: e.detail.numLines,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
+ } else if (
+ el.classList.contains('lineNum') ||
+ el.classList.contains('lineNumButton')
+ ) {
+ this.addDraftAtLine(el);
+ } else if (
+ el.tagName === 'HL' ||
+ el.classList.contains('content') ||
+ el.classList.contains('contentText')
+ ) {
+ const target = this.$.diffBuilder.getLineElByChild(el);
+ if (target) {
+ this._selectLine(target);
+ }
+ }
+ }
+
+ _selectLine(el: Element) {
+ this.dispatchEvent(
+ new CustomEvent('line-selected', {
+ detail: {
+ side: el.classList.contains('left') ? Side.LEFT : Side.RIGHT,
+ number: el.getAttribute('data-value'),
+ path: this.path,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ addDraftAtLine(el: Element) {
+ this._selectLine(el);
+ if (!this._isValidElForComment(el)) {
+ return;
+ }
+
+ const lineNum = getLineNumber(el);
+ if (lineNum === null) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: 'Invalid line number'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+
+ // TODO(TS): existing logic always pass undefined lineNum
+ // for file level comment, the drafts API will reject the
+ // request if file level draft contains the `line: 'FILE'` field
+ // probably should do this inside of the _createComment, this
+ // is just to keep existing behavior.
+ this._createComment(el, lineNum === FILE ? undefined : lineNum);
+ }
+
+ createRangeComment() {
+ if (!this.isRangeSelected()) {
+ throw Error('Selection is needed for new range comment');
+ }
+ const selectedRange = this.$.highlights.selectedRange;
+ if (!selectedRange) throw Error('selected range not set');
+ const {side, range} = selectedRange;
+ this._createCommentForSelection(side, range);
+ }
+
+ _createCommentForSelection(side: Side, range: CommentRange) {
+ const lineNum = range.end_line;
+ const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+ if (lineEl && this._isValidElForComment(lineEl)) {
+ this._createComment(lineEl, lineNum, side, range);
+ }
+ }
+
+ _handleCreateRangeComment(e: CustomEvent) {
+ const range = e.detail.range;
+ const side = e.detail.side;
+ this._createCommentForSelection(side, range);
+ }
+
+ _isValidElForComment(el: Element) {
+ if (!this.loggedIn) {
+ this.dispatchEvent(
+ new CustomEvent('show-auth-required', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return false;
+ }
+ if (!this.patchRange) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: 'Cannot create comment. Patch range undefined.'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return false;
+ }
+ const patchNum = el.classList.contains(Side.LEFT)
+ ? this.patchRange.basePatchNum
+ : this.patchRange.patchNum;
+
+ const isEdit = patchNumEquals(patchNum, EditPatchSetNum);
+ const isEditBase =
+ patchNumEquals(patchNum, ParentPatchSetNum) &&
+ patchNumEquals(this.patchRange.patchNum, EditPatchSetNum);
+
+ if (isEdit) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: 'You cannot comment on an edit.'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return false;
+ }
+ if (isEditBase) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'You cannot comment on the base patchset of an edit.',
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return false;
+ }
+ return true;
+ }
+
+ _createComment(
+ lineEl: Element,
+ lineNum?: LineNumber,
+ side?: Side,
+ range?: CommentRange
+ ) {
+ const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+ if (!contentEl) throw Error('content el not found for line el');
+ side = side || this._getCommentSideByLineAndContent(lineEl, contentEl);
+ const patchForNewThreads = this._getPatchNumByLineAndContent(
+ lineEl,
+ contentEl
+ );
+ const isOnParent = this._getIsParentCommentByLineAndContent(
+ lineEl,
+ contentEl
+ );
+ this.dispatchEvent(
+ new CustomEvent('create-comment', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ lineNum,
+ side,
+ patchNum: patchForNewThreads,
+ isOnParent,
+ range,
+ },
+ })
+ );
+ }
+
+ _getThreadGroupForLine(contentEl: Element) {
+ return contentEl.querySelector('.thread-group');
+ }
+
+ /**
+ * Gets or creates a comment thread group for a specific line and side on a
+ * diff.
+ */
+ _getOrCreateThreadGroup(contentEl: Element, commentSide: Side) {
+ // Check if thread group exists.
+ let threadGroupEl = this._getThreadGroupForLine(contentEl);
+ if (!threadGroupEl) {
+ threadGroupEl = document.createElement('div');
+ threadGroupEl.className = 'thread-group';
+ threadGroupEl.setAttribute('data-side', commentSide);
+ contentEl.appendChild(threadGroupEl);
+ }
+ return threadGroupEl;
+ }
+
+ /**
+ * The value to be used for the patch number of new comments created at the
+ * given line and content elements.
+ *
+ * In two cases of creating a comment on the left side, the patch number to
+ * be used should actually be right side of the patch range:
+ * - When the patch range is against the parent comment of a normal change.
+ * Such comments declare themmselves to be on the left using side=PARENT.
+ * - If the patch range is against the indexed parent of a merge change.
+ * Such comments declare themselves to be on the given parent by
+ * specifying the parent index via parent=i.
+ */
+ _getPatchNumByLineAndContent(lineEl: Element, contentEl: Element) {
+ if (!this.patchRange) throw Error('patch range not set');
+ let patchNum = this.patchRange.patchNum;
+
+ if (
+ (lineEl.classList.contains(Side.LEFT) ||
+ contentEl.classList.contains('remove')) &&
+ this.patchRange.basePatchNum !== 'PARENT' &&
+ !isMergeParent(this.patchRange.basePatchNum)
+ ) {
+ patchNum = this.patchRange.basePatchNum;
+ }
+ return patchNum;
+ }
+
+ _getIsParentCommentByLineAndContent(lineEl: Element, contentEl: Element) {
+ if (!this.patchRange) throw Error('patch range not set');
+ return (
+ (lineEl.classList.contains(Side.LEFT) ||
+ contentEl.classList.contains('remove')) &&
+ (this.patchRange.basePatchNum === 'PARENT' ||
+ isMergeParent(this.patchRange.basePatchNum))
+ );
+ }
+
+ _getCommentSideByLineAndContent(lineEl: Element, contentEl: Element): Side {
+ let side = Side.RIGHT;
+ if (
+ lineEl.classList.contains(Side.LEFT) ||
+ contentEl.classList.contains('remove')
+ ) {
+ side = Side.LEFT;
+ }
+ return side;
+ }
+
+ _prefsObserver(newPrefs: DiffPreferencesInfo, oldPrefs: DiffPreferencesInfo) {
+ if (!this._prefsEqual(newPrefs, oldPrefs)) {
+ this._prefsChanged(newPrefs);
+ }
+ }
+
+ _prefsEqual(prefs1: DiffPreferencesInfo, prefs2: DiffPreferencesInfo) {
+ if (prefs1 === prefs2) {
+ return true;
+ }
+ if (!prefs1 || !prefs2) {
+ return false;
+ }
+ // Scan the preference objects one level deep to see if they differ.
+ const keys1 = Object.keys(prefs1) as DiffPreferencesInfoKey[];
+ const keys2 = Object.keys(prefs2) as DiffPreferencesInfoKey[];
+ return (
+ keys1.length === keys2.length &&
+ keys1.every(key => prefs1[key] === prefs2[key]) &&
+ keys2.every(key => prefs1[key] === prefs2[key])
+ );
+ }
+
+ _pathObserver() {
+ // Call _prefsChanged(), because line-limit style value depends on path.
+ this._prefsChanged(this.prefs);
+ }
+
+ _viewModeObserver() {
+ this._prefsChanged(this.prefs);
+ }
+
+ _cleanup() {
+ this.cancel();
+ this.blame = null;
+ this._safetyBypass = null;
+ this._showWarning = false;
+ this.clearDiffContent();
+ }
+
+ _lineWrappingObserver() {
+ this._prefsChanged(this.prefs);
+ }
+
+ _prefsChanged(prefs?: DiffPreferencesInfo) {
+ if (!prefs) return;
+
+ this.blame = null;
+
+ const lineLength =
+ this.path === COMMIT_MSG_PATH
+ ? COMMIT_MSG_LINE_LENGTH
+ : prefs.line_length;
+ const stylesToUpdate: {[key: string]: string} = {};
+
+ if (prefs.line_wrapping) {
+ this._diffTableClass = 'full-width';
+ if (this.viewMode === 'SIDE_BY_SIDE') {
+ stylesToUpdate['--content-width'] = 'none';
+ stylesToUpdate['--line-limit'] = `${lineLength}ch`;
+ }
+ } else {
+ this._diffTableClass = '';
+ stylesToUpdate['--content-width'] = `${lineLength}ch`;
+ }
+
+ if (prefs.font_size) {
+ stylesToUpdate['--font-size'] = `${prefs.font_size}px`;
+ }
+
+ this.updateStyles(stylesToUpdate);
+
+ if (this.diff && !this.noRenderOnPrefsChange) {
+ this._debounceRenderDiffTable();
+ }
+ }
+
+ _diffChanged(newValue?: DiffInfo) {
+ this._loading = true;
+ this._cleanup();
+ if (newValue) {
+ this._diffLength = this.getDiffLength(newValue);
+ this._debounceRenderDiffTable();
+ }
+ }
+
+ /**
+ * When called multiple times from the same microtask, will call
+ * _renderDiffTable only once, in the next microtask, unless it is cancelled
+ * before that microtask runs.
+ *
+ * This should be used instead of calling _renderDiffTable directly to
+ * render the diff in response to an input change, because there may be
+ * multiple inputs changing in the same microtask, but we only want to
+ * render once.
+ */
+ _debounceRenderDiffTable() {
+ this.debounce(RENDER_DIFF_TABLE_DEBOUNCE_NAME, () =>
+ this._renderDiffTable()
+ );
+ }
+
+ _renderDiffTable() {
+ if (!this.prefs) {
+ this.dispatchEvent(
+ new CustomEvent('render', {bubbles: true, composed: true})
+ );
+ return;
+ }
+ if (
+ this.prefs.context === -1 &&
+ this._diffLength &&
+ this._diffLength >= LARGE_DIFF_THRESHOLD_LINES &&
+ this._safetyBypass === null
+ ) {
+ this._showWarning = true;
+ this.dispatchEvent(
+ new CustomEvent('render', {bubbles: true, composed: true})
+ );
+ return;
+ }
+
+ this._showWarning = false;
+
+ const keyLocations = this._computeKeyLocations();
+ const bypassPrefs = this._getBypassPrefs(this.prefs);
+ this.$.diffBuilder.render(keyLocations, bypassPrefs).then(() => {
+ this.dispatchEvent(
+ new CustomEvent('render', {
+ bubbles: true,
+ composed: true,
+ detail: {contentRendered: true},
+ })
+ );
+ });
+ }
+
+ _handleRenderContent() {
+ this._loading = false;
+ this._unobserveIncrementalNodes();
+ this._incrementalNodeObserver = (dom(
+ this
+ ) as PolymerDomWrapper).observeNodes(info => {
+ const addedThreadEls = info.addedNodes.filter(isThreadEl);
+ // Removed nodes do not need to be handled because all this code does is
+ // adding a slot for the added thread elements, and the extra slots do
+ // not hurt. It's probably a bigger performance cost to remove them than
+ // to keep them around. Medium term we can even consider to add one slot
+ // for each line from the start.
+ let lastEl;
+ for (const threadEl of addedThreadEls) {
+ const lineNumString = threadEl.getAttribute('line-num') || 'FILE';
+ const commentSide = getSide(threadEl);
+ const lineEl = this.$.diffBuilder.getLineElByNumber(
+ lineNumString,
+ commentSide
+ );
+ // When the line the comment refers to does not exist, log an error
+ // but don't crash. This can happen e.g. if the API does not fully
+ // validate e.g. (robot) comments
+ if (!lineEl) {
+ console.error(
+ 'thread attached to line ',
+ commentSide,
+ lineNumString,
+ ' which does not exist.'
+ );
+ continue;
+ }
+ const contentEl = this.$.diffBuilder.getContentTdByLineEl(lineEl);
+ if (!contentEl) continue;
+ const threadGroupEl = this._getOrCreateThreadGroup(
+ contentEl,
+ commentSide
+ );
+ // Create a slot for the thread and attach it to the thread group.
+ // The Polyfill has some bugs and this only works if the slot is
+ // attached to the group after the group is attached to the DOM.
+ // The thread group may already have a slot with the right name, but
+ // that is okay because the first matching slot is used and the rest
+ // are ignored.
+ const slot = document.createElement('slot') as HTMLSlotElement;
+ const slotAtt = threadEl.getAttribute('slot');
+ if (slotAtt) slot.name = slotAtt;
+ threadGroupEl.appendChild(slot);
+ lastEl = threadEl;
+ }
+
+ // Safari is not binding newly created comment-thread
+ // with the slot somehow, replace itself will rebind it
+ // @see Issue 11182
+ if (lastEl && lastEl.replaceWith) {
+ lastEl.replaceWith(lastEl);
+ }
+ });
+ }
+
+ _unobserveIncrementalNodes() {
+ if (this._incrementalNodeObserver) {
+ (dom(this) as PolymerDomWrapper).unobserveNodes(
+ this._incrementalNodeObserver
+ );
+ }
+ }
+
+ _unobserveNodes() {
+ if (this._nodeObserver) {
+ (dom(this) as PolymerDomWrapper).unobserveNodes(this._nodeObserver);
+ }
+ }
+
+ /**
+ * Get the preferences object including the safety bypass context (if any).
+ */
+ _getBypassPrefs(prefs: DiffPreferencesInfo) {
+ if (this._safetyBypass !== null) {
+ return {...prefs, context: this._safetyBypass};
+ }
+ return prefs;
+ }
+
+ clearDiffContent() {
+ this._unobserveIncrementalNodes();
+ while (this.$.diffTable.hasChildNodes()) {
+ this.$.diffTable.removeChild(this.$.diffTable.lastChild!);
+ }
+ }
+
+ _computeDiffHeaderItems(
+ diffInfoRecord: PolymerDeepPropertyChange<DiffInfo, DiffInfo>
+ ) {
+ const diffInfo = diffInfoRecord.base;
+ if (!diffInfo || !diffInfo.diff_header) {
+ return [];
+ }
+ return diffInfo.diff_header.filter(
+ item =>
+ !(
+ item.startsWith('diff --git ') ||
+ item.startsWith('index ') ||
+ item.startsWith('+++ ') ||
+ item.startsWith('--- ') ||
+ item === 'Binary files differ'
+ )
+ );
+ }
+
+ _computeDiffHeaderHidden(items: string[]) {
+ return items.length === 0;
+ }
+
+ _handleFullBypass() {
+ this._safetyBypass = FULL_CONTEXT;
+ this._debounceRenderDiffTable();
+ }
+
+ _handleLimitedBypass() {
+ this._safetyBypass = LIMITED_CONTEXT;
+ this._debounceRenderDiffTable();
+ }
+
+ _computeWarningClass(showWarning?: boolean) {
+ return showWarning ? 'warn' : '';
+ }
+
+ _computeErrorClass(errorMessage?: string | null) {
+ return errorMessage ? 'showError' : '';
+ }
+
+ expandAllContext() {
+ this._handleFullBypass();
+ }
+
+ _computeNewlineWarning(warnLeft: boolean, warnRight: boolean) {
+ const messages = [];
+ if (warnLeft) {
+ messages.push(NO_NEWLINE_BASE);
+ }
+ if (warnRight) {
+ messages.push(NO_NEWLINE_REVISION);
+ }
+ if (!messages.length) {
+ return null;
+ }
+ return messages.join(' \u2014 '); // \u2014 - '—'
+ }
+
+ _computeNewlineWarningClass(warning: boolean, loading: boolean) {
+ if (loading || !warning) {
+ return 'newlineWarning hidden';
+ }
+ return 'newlineWarning';
+ }
+
+ /**
+ * Get the approximate length of the diff as the sum of the maximum
+ * length of the chunks.
+ */
+ getDiffLength(diff?: DiffInfo) {
+ if (!diff) return 0;
+ return diff.content.reduce((sum, sec) => {
+ if (sec.ab) {
+ return sum + sec.ab.length;
+ } else {
+ return (
+ sum + Math.max(sec.a ? sec.a.length : 0, sec.b ? sec.b.length : 0)
+ );
+ }
+ }, 0);
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff': GrDiff;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index f18af23..b41fff6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -46,9 +46,38 @@
}
table {
border-collapse: collapse;
- border-right: 1px solid var(--border-color);
table-layout: fixed;
}
+
+ /*
+ Context controls break up the table visually, so we set the right border
+ on individual sections to leave a gap for the divider.
+ */
+ .section {
+ border-right: 1px solid var(--border-color);
+ }
+ .section.contextControl.newStyle {
+ /*
+ * Divider inside this section must not have border; we set borders on
+ * the padding rows below.
+ */
+ border-right-width: 0;
+ }
+ /*
+ * Padding rows behind new style context controls. The diff is styled to be
+ * cut into two halves by the negative space of the divider on which the
+ * context control buttons are anchored.
+ */
+ .contextBackground {
+ border-right: 1px solid var(--border-color);
+ }
+ .contextBackground.above {
+ border-bottom: 1px solid var(--border-color);
+ }
+ .contextBackground.below {
+ border-top: 1px solid var(--border-color);
+ }
+
.lineNumButton {
display: block;
width: 100%;
@@ -113,7 +142,7 @@
width: 100%;
}
.full-width .contentText {
- white-space: pre-wrap;
+ white-space: break-spaces;
word-wrap: break-word;
}
.lineNumButton,
@@ -177,6 +206,21 @@
background-color: var(--light-remove-add-highlight-color);
}
+ /* dueToMove */
+ .dueToMove .content.add .contentText,
+ .dueToMove .moveControls.movedIn .moveDescription,
+ .delta.total.dueToMove .content.add .contentText {
+ background-color: var(--light-moved-add-highlight-color);
+ }
+ .dueToMove .content.remove .contentText,
+ .dueToMove .moveControls.movedOut .moveDescription,
+ .delta.total.dueToMove .content.remove .contentText {
+ background-color: var(--light-remove-add-highlight-color);
+ }
+ .moveControls {
+ text-align: right;
+ }
+
/* ignoredWhitespaceOnly */
.ignoredWhitespaceOnly .content.add .contentText .intraline,
.delta.total.ignoredWhitespaceOnly .content.add .contentText,
@@ -191,12 +235,22 @@
/* Newline, to ensure empty lines are one line-height tall. */
content: '\\A';
}
+
+ /* Context controls */
.contextControl {
background-color: var(--diff-context-control-background-color);
border: 1px solid var(--diff-context-control-border-color);
color: var(--diff-context-control-color);
+ --divider-height: var(--spacing-s);
+ --divider-border: 1px;
}
- .contextControl gr-button {
+ .contextControl.newStyle {
+ background-color: transparent;
+ border: none;
+ /* Change to --diff-context-control-color once only new style exists. */
+ --diff-context-control-color: var(--default-button-text-color);
+ }
+ .contextControl:not(.newStyle) gr-button {
display: inline-block;
text-decoration: none;
vertical-align: top;
@@ -214,6 +268,97 @@
.contextControl td:not(.lineNumButton) {
text-align: center;
}
+
+ /*
+ * Padding rows behind new style context controls. Styled as a continuation
+ * of the line gutters and code area.
+ */
+ .contextBackground > .contextLineNum {
+ background-color: var(--diff-blank-background-color);
+ }
+ .contextBackground > td:not(.contextLineNum) {
+ background-color: var(--view-background-color);
+ }
+ .contextBackground {
+ /*
+ * One line of background behind the context expanders which they can
+ * render on top of, plus some padding.
+ */
+ height: calc(var(--line-height-normal) + var(--spacing-s));
+ }
+
+ .contextDivider {
+ height: var(--divider-height);
+ /* Create a positioning context. */
+ transform: translateX(0px);
+ }
+ .contextDivider.collapsed {
+ /* Hide divider gap, but still show child elements (expansion buttons). */
+ height: 0;
+ }
+ .dividerCell {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+ .contextControlButton {
+ background-color: var(--default-button-background-color);
+ font: var(--context-control-button-font, inherit);
+ /* All position is relative to container, so ignore sibling buttons. */
+ position: absolute;
+ }
+ .contextControlButton:first-child {
+ /* First button needs to claim width to display without text wrapping. */
+ position: relative;
+ }
+ .centeredButton {
+ /* Center over divider. */
+ top: 50%;
+ transform: translateY(-50%);
+ --gr-button: {
+ color: var(--diff-context-control-color);
+ border: solid var(--border-color);
+ border-width: 1px;
+ border-radius: var(--border-radius);
+ padding: var(--spacing-s) var(--spacing-l);
+ }
+ }
+ .aboveBelowButtons {
+ display: flex;
+ flex-direction: column;
+ margin-left: var(--spacing-m);
+ position: relative;
+ }
+ .aboveBelowButtons:first-child {
+ margin-left: 0;
+ }
+ .aboveButton {
+ /* Display over preceding content / background placeholder. */
+ transform: translateY(-100%);
+ --gr-button: {
+ color: var(--diff-context-control-color);
+ border: solid var(--border-color);
+ border-width: 1px 1px 0 1px;
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
+ padding: var(--spacing-xxs) var(--spacing-l);
+ }
+ }
+ .belowButton {
+ /* Display over following content / background placeholder. */
+ top: calc(100% + var(--divider-border));
+ --gr-button: {
+ color: var(--diff-context-control-color);
+ border: solid var(--border-color);
+ border-width: 0 1px 1px 1px;
+ border-radius: 0 0 var(--border-radius) var(--border-radius);
+ padding: var(--spacing-xxs) var(--spacing-l);
+ }
+ }
+
.displayLine .diff-row.target-row td {
box-shadow: inset 0 -1px var(--border-color);
}
@@ -413,7 +558,6 @@
id="diffBuilder"
comment-ranges="[[_commentRanges]]"
coverage-ranges="[[coverageRanges]]"
- project-name="[[projectName]]"
diff="[[diff]]"
path="[[path]]"
change-num="[[changeNum]]"
@@ -423,6 +567,7 @@
base-image="[[baseImage]]"
layers="[[layers]]"
revision-image="[[revisionImage]]"
+ use-new-context-controls="[[useNewContextControls]]"
>
<table
id="diffTable"
@@ -432,7 +577,7 @@
<template
is="dom-if"
- if="[[showNoChangeMessage(loading, prefs, _diffLength, diff)]]"
+ if="[[showNoChangeMessage(_loading, prefs, _diffLength, diff)]]"
>
<div class="whitespace-change-only-message">
This file only contains whitespace changes. Modify the whitespace
@@ -443,7 +588,7 @@
</gr-diff-highlight>
</gr-diff-selection>
</div>
- <div class$="[[_computeNewlineWarningClass(_newlineWarning, loading)]]">
+ <div class$="[[_computeNewlineWarningClass(_newlineWarning, _loading)]]">
[[_newlineWarning]]
</div>
<div id="loadingError" class$="[[_computeErrorClass(errorMessage)]]">
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
index 86a2b65..5e95d83 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.js
@@ -19,7 +19,7 @@
import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
import './gr-diff.js';
-import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
import {getComputedStyleValue} from '../../../utils/dom-util.js';
import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
@@ -76,15 +76,15 @@
test('line limit with line_wrapping', () => {
element = basicFixture.instantiate();
- element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: true});
- flushAsynchronousOperations();
+ element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
+ flush();
assert.equal(getComputedStyleValue('--line-limit', element), '80ch');
});
test('line limit without line_wrapping', () => {
element = basicFixture.instantiate();
- element.prefs = Object.assign({}, MINIMAL_PREFS, {line_wrapping: false});
- flushAsynchronousOperations();
+ element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
+ flush();
assert.isNotOk(getComputedStyleValue('--line-limit', element));
});
@@ -225,7 +225,7 @@
element.path = 'file.txt';
element.$.diffBuilder._builder = element.$.diffBuilder._getDiffBuilder(
- getMockDiffResponse(), Object.assign({}, MINIMAL_PREFS));
+ getMockDiffResponse(), {...MINIMAL_PREFS});
// No thread groups.
assert.isNotOk(element._getThreadGroupForLine(contentEl));
@@ -585,7 +585,7 @@
});
suite('getCursorStops', () => {
- const setupDiff = function() {
+ function setupDiff() {
element.diff = getMockDiffResponse();
element.prefs = {
context: 10,
@@ -605,8 +605,9 @@
};
element._renderDiffTable();
- flushAsynchronousOperations();
- };
+ element._loading = false;
+ flush();
+ }
test('getCursorStops returns [] when hidden and noAutoRender', () => {
element.noAutoRender = true;
@@ -693,22 +694,22 @@
test('change in preferences re-renders diff', () => {
sinon.stub(element, '_renderDiffTable');
- element.prefs = Object.assign(
- {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
+ element.prefs = {
+ ...MINIMAL_PREFS, time_format: 'HHMM_12'};
element.flushDebouncer('renderDiffTable');
assert.isTrue(element._renderDiffTable.called);
});
test('adding/removing property in preferences re-renders diff', () => {
const stub = sinon.stub(element, '_renderDiffTable');
- const newPrefs1 = Object.assign({}, MINIMAL_PREFS,
- {line_wrapping: true});
+ const newPrefs1 = {...MINIMAL_PREFS,
+ line_wrapping: true};
element.prefs = newPrefs1;
element.flushDebouncer('renderDiffTable');
assert.isTrue(element._renderDiffTable.called);
stub.reset();
- const newPrefs2 = Object.assign({}, newPrefs1);
+ const newPrefs2 = {...newPrefs1};
delete newPrefs2.line_wrapping;
element.prefs = newPrefs2;
element.flushDebouncer('renderDiffTable');
@@ -719,8 +720,8 @@
'noRenderOnPrefsChange', () => {
sinon.stub(element, '_renderDiffTable');
element.noRenderOnPrefsChange = true;
- element.prefs = Object.assign(
- {}, MINIMAL_PREFS, {time_format: 'HHMM_12'});
+ element.prefs = {
+ ...MINIMAL_PREFS, time_format: 'HHMM_12'};
element.flushDebouncer('renderDiffTable');
assert.isFalse(element._renderDiffTable.called);
});
@@ -753,7 +754,7 @@
assert.equal(element._diffHeaderItems.length, 0);
element.push('diff.diff_header', 'test');
assert.equal(element._diffHeaderItems.length, 1);
- flushAsynchronousOperations();
+ flush();
assert.equal(element.$.diffHeader.textContent.trim(), 'test');
});
@@ -787,7 +788,7 @@
});
test('large render w/ context = 10', done => {
- element.prefs = Object.assign({}, MINIMAL_PREFS, {context: 10});
+ element.prefs = {...MINIMAL_PREFS, context: 10};
function rendered() {
assert.isTrue(renderStub.called);
assert.isFalse(element._showWarning);
@@ -799,7 +800,7 @@
});
test('large render w/ whole file and bypass', done => {
- element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
+ element.prefs = {...MINIMAL_PREFS, context: -1};
element._safetyBypass = 10;
function rendered() {
assert.isTrue(renderStub.called);
@@ -812,7 +813,7 @@
});
test('large render w/ whole file and no bypass', done => {
- element.prefs = Object.assign({}, MINIMAL_PREFS, {context: -1});
+ element.prefs = {...MINIMAL_PREFS, context: -1};
function rendered() {
assert.isFalse(renderStub.called);
assert.isTrue(element._showWarning);
@@ -951,7 +952,7 @@
threadEl.className = 'comment-thread';
threadEl.setAttribute('comment-side', 'right');
threadEl.setAttribute('line-num', 3);
- dom(element).appendChild(threadEl);
+ element.appendChild(threadEl);
flush();
element._renderDiffTable();
@@ -966,7 +967,7 @@
const threadEl = document.createElement('div');
threadEl.className = 'comment-thread';
threadEl.setAttribute('comment-side', 'left');
- dom(element).appendChild(threadEl);
+ element.appendChild(threadEl);
flush();
element._renderDiffTable();
@@ -1012,7 +1013,7 @@
binary,
};
element._renderDiffTable();
- flushAsynchronousOperations();
+ flush();
};
test('clear diff table content as soon as diff changes', () => {
@@ -1028,11 +1029,11 @@
}
setupSampleDiff({content});
assertDiffTableWithContent();
- element.diff = Object.assign({}, element.diff);
+ element.diff = {...element.diff};
// immediately cleaned up
assert.equal(element.$.diffTable.innerHTML, '');
element._renderDiffTable();
- flushAsynchronousOperations();
+ flush();
// rendered again
assertDiffTableWithContent();
});
@@ -1049,7 +1050,7 @@
],
}];
setupSampleDiff({content});
- flushAsynchronousOperations();
+ flush();
const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
assert.equal(getComputedStyle(diffLine).userSelect, 'none');
// click to mark it as selected
@@ -1069,7 +1070,7 @@
}];
setupSampleDiff({content});
element.viewMode = 'UNIFIED_DIFF';
- flushAsynchronousOperations();
+ flush();
const diffLine = element.shadowRoot.querySelectorAll('.contentText')[2];
assert.equal(getComputedStyle(diffLine).userSelect, 'none');
MockInteractions.tap(diffLine);
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
deleted file mode 100644
index 8ec2058..0000000
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ /dev/null
@@ -1,326 +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/shared-styles.js';
-import '../../shared/gr-dropdown-list/gr-dropdown-list.js';
-import '../../shared/gr-select/gr-select.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-patch-range-select_html.js';
-import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {appContext} from '../../../services/app-context.js';
-import {
- computeLatestPatchNum, findSortedIndex, getParentIndex,
- getRevisionByPatchNum,
- isMergeParent,
- patchNumEquals, sortRevisions,
- SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-
-// Maximum length for patch set descriptions.
-const PATCH_DESC_MAX_LENGTH = 500;
-
-/**
- * Fired when the patch range changes
- *
- * @event patch-range-change
- *
- * @property {string} patchNum
- * @property {string} basePatchNum
- * @extends PolymerElement
- */
-class GrPatchRangeSelect extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-patch-range-select'; }
-
- static get properties() {
- return {
- availablePatches: Array,
- _baseDropdownContent: {
- type: Object,
- computed: '_computeBaseDropdownContent(availablePatches, patchNum,' +
- '_sortedRevisions, changeComments, revisionInfo)',
- },
- _patchDropdownContent: {
- type: Object,
- computed: '_computePatchDropdownContent(availablePatches,' +
- 'basePatchNum, _sortedRevisions, changeComments)',
- },
- changeNum: String,
- changeComments: Object,
- /** @type {{ meta_a: !Array, meta_b: !Array}} */
- filesWeblinks: Object,
- patchNum: String,
- basePatchNum: String,
- revisions: Object,
- revisionInfo: Object,
- _sortedRevisions: Array,
- };
- }
-
- static get observers() {
- return [
- '_updateSortedRevisions(revisions.*)',
- ];
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- _getShaForPatch(patch) {
- return patch.sha.substring(0, 10);
- }
-
- _computeBaseDropdownContent(availablePatches, patchNum, _sortedRevisions,
- changeComments, revisionInfo) {
- // Polymer 2: check for undefined
- if ([
- availablePatches,
- patchNum,
- _sortedRevisions,
- changeComments,
- revisionInfo,
- ].includes(undefined)) {
- return undefined;
- }
-
- const parentCounts = revisionInfo.getParentCountMap();
- const currentParentCount = parentCounts.hasOwnProperty(patchNum) ?
- parentCounts[patchNum] : 1;
- const maxParents = revisionInfo.getMaxParents();
- const isMerge = currentParentCount > 1;
-
- const dropdownContent = [];
- for (const basePatch of availablePatches) {
- const basePatchNum = basePatch.num;
- const entry = this._createDropdownEntry(basePatchNum, 'Patchset ',
- _sortedRevisions, changeComments, this._getShaForPatch(basePatch));
- dropdownContent.push(Object.assign({}, entry, {
- disabled: this._computeLeftDisabled(
- basePatch.num, patchNum, _sortedRevisions),
- }));
- }
-
- dropdownContent.push({
- text: isMerge ? 'Auto Merge' : 'Base',
- value: 'PARENT',
- });
-
- for (let idx = 0; isMerge && idx < maxParents; idx++) {
- dropdownContent.push({
- disabled: idx >= currentParentCount,
- triggerText: `Parent ${idx + 1}`,
- text: `Parent ${idx + 1}`,
- mobileText: `Parent ${idx + 1}`,
- value: -(idx + 1),
- });
- }
-
- return dropdownContent;
- }
-
- _computeMobileText(patchNum, changeComments, revisions) {
- return `${patchNum}` +
- `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
- `${this._computePatchSetDescription(revisions, patchNum, true)}`;
- }
-
- _computePatchDropdownContent(availablePatches, basePatchNum,
- _sortedRevisions, changeComments) {
- // Polymer 2: check for undefined
- if ([
- availablePatches,
- basePatchNum,
- _sortedRevisions,
- changeComments,
- ].includes(undefined)) {
- return undefined;
- }
-
- const dropdownContent = [];
- for (const patch of availablePatches) {
- const patchNum = patch.num;
- const entry = this._createDropdownEntry(
- patchNum, patchNum === 'edit' ? '' : 'Patchset ', _sortedRevisions,
- changeComments, this._getShaForPatch(patch));
- dropdownContent.push(Object.assign({}, entry, {
- disabled: this._computeRightDisabled(basePatchNum, patchNum,
- _sortedRevisions),
- }));
- }
- return dropdownContent;
- }
-
- _computeText(patchNum, prefix, changeComments, sha) {
- return `${prefix}${patchNum}` +
- `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
- (` | ${sha}`);
- }
-
- _createDropdownEntry(patchNum, prefix, sortedRevisions, changeComments,
- sha) {
- const entry = {
- triggerText: `${prefix}${patchNum}`,
- text: this._computeText(patchNum, prefix, changeComments, sha),
- mobileText: this._computeMobileText(patchNum, changeComments,
- sortedRevisions),
- bottomText: `${this._computePatchSetDescription(
- sortedRevisions, patchNum)}`,
- value: patchNum,
- };
- const date = this._computePatchSetDate(sortedRevisions, patchNum);
- if (date) {
- entry['date'] = date;
- }
- return entry;
- }
-
- _updateSortedRevisions(revisionsRecord) {
- const revisions = revisionsRecord.base;
- this._sortedRevisions = sortRevisions(Object.values(revisions));
- }
-
- /**
- * The basePatchNum should always be <= patchNum -- because sortedRevisions
- * is sorted in reverse order (higher patchset nums first), invalid base
- * patch nums have an index greater than the index of patchNum.
- *
- * @param {number|string} basePatchNum The possible base patch num.
- * @param {number|string} patchNum The current selected patch num.
- * @param {!Array} sortedRevisions
- */
- _computeLeftDisabled(basePatchNum, patchNum, sortedRevisions) {
- return findSortedIndex(basePatchNum, sortedRevisions) <=
- findSortedIndex(patchNum, sortedRevisions);
- }
-
- /**
- * The basePatchNum should always be <= patchNum -- because sortedRevisions
- * is sorted in reverse order (higher patchset nums first), invalid patch
- * nums have an index greater than the index of basePatchNum.
- *
- * In addition, if the current basePatchNum is 'PARENT', all patchNums are
- * valid.
- *
- * If the current basePatchNum is a parent index, then only patches that have
- * at least that many parents are valid.
- *
- * @param {number|string} basePatchNum The current selected base patch num.
- * @param {number|string} patchNum The possible patch num.
- * @param {!Array} sortedRevisions
- * @return {boolean}
- */
- _computeRightDisabled(basePatchNum, patchNum, sortedRevisions) {
- if (patchNumEquals(basePatchNum, SPECIAL_PATCH_SET_NUM.PARENT)) {
- return false;
- }
-
- if (isMergeParent(basePatchNum)) {
- // Note: parent indices use 1-offset.
- return this.revisionInfo.getParentCount(patchNum) <
- getParentIndex(basePatchNum);
- }
-
- return findSortedIndex(basePatchNum, sortedRevisions) <=
- findSortedIndex(patchNum, sortedRevisions);
- }
-
- _computePatchSetCommentsString(changeComments, patchNum) {
- if (!changeComments) { return; }
-
- const commentCount = changeComments.computeCommentCount({patchNum});
- const commentString = GrCountStringFormatter.computePluralString(
- commentCount, 'comment');
-
- const unresolvedCount = changeComments.computeUnresolvedNum({patchNum});
- const unresolvedString = GrCountStringFormatter.computeString(
- unresolvedCount, 'unresolved');
-
- if (!commentString.length && !unresolvedString.length) {
- return '';
- }
-
- return ` (${commentString}` +
- // Add a comma + space if both comments and unresolved
- (commentString && unresolvedString ? ', ' : '') +
- `${unresolvedString})`;
- }
-
- /**
- * @param {!Array} revisions
- * @param {number|string} patchNum
- * @param {boolean=} opt_addFrontSpace
- */
- _computePatchSetDescription(revisions, patchNum, opt_addFrontSpace) {
- const rev = getRevisionByPatchNum(revisions, patchNum);
- return (rev && rev.description) ?
- (opt_addFrontSpace ? ' ' : '') +
- rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
- }
-
- /**
- * @param {!Array} revisions
- * @param {number|string} patchNum
- */
- _computePatchSetDate(revisions, patchNum) {
- const rev = getRevisionByPatchNum(revisions, patchNum);
- return rev ? rev.created : undefined;
- }
-
- /**
- * Catches value-change events from the patchset dropdowns and determines
- * whether or not a patch change event should be fired.
- */
- _handlePatchChange(e) {
- const detail = {patchNum: this.patchNum, basePatchNum: this.basePatchNum};
- const target = dom(e).localTarget;
- const latestPatchNum = computeLatestPatchNum(this.availablePatches);
- if (target === this.$.patchNumDropdown) {
- if (detail.patchNum === e.detail.value) return;
- this.reporting.reportInteraction('right-patchset-changed',
- {
- previous: detail.patchNum,
- current: e.detail.value,
- latest: latestPatchNum,
- commentCount: this.changeComments.computeCommentCount(
- {patchNum: e.detail.value}),
- });
- detail.patchNum = e.detail.value;
- } else {
- if (patchNumEquals(detail.basePatchNum, e.detail.value)) return;
- this.reporting.reportInteraction('left-patchset-changed',
- {
- previous: detail.basePatchNum,
- current: e.detail.value,
- commentCount: this.changeComments.computeCommentCount(
- {patchNum: e.detail.value}),
- });
- detail.basePatchNum = e.detail.value;
- }
-
- this.dispatchEvent(
- new CustomEvent('patch-range-change', {detail, bubbles: false}));
- }
-}
-
-customElements.define(GrPatchRangeSelect.is, GrPatchRangeSelect);
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
new file mode 100644
index 0000000..2a9fe54
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.ts
@@ -0,0 +1,469 @@
+/**
+ * @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/shared-styles';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+import '../../shared/gr-select/gr-select';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-patch-range-select_html';
+import {GrCountStringFormatter} from '../../shared/gr-count-string-formatter/gr-count-string-formatter';
+import {appContext} from '../../../services/app-context';
+import {
+ computeLatestPatchNum,
+ findSortedIndex,
+ getParentIndex,
+ getRevisionByPatchNum,
+ isMergeParent,
+ patchNumEquals,
+ sortRevisions,
+ PatchSet,
+} from '../../../utils/patch-set-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {
+ ParentPatchSetNum,
+ PatchSetNum,
+ RevisionInfo,
+ Timestamp,
+} from '../../../types/common';
+import {RevisionInfo as RevisionInfoClass} from '../../shared/revision-info/revision-info';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {
+ DropdownItem,
+ DropDownValueChangeEvent,
+ GrDropdownList,
+} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GeneratedWebLink} from '../../core/gr-navigation/gr-navigation';
+
+// Maximum length for patch set descriptions.
+const PATCH_DESC_MAX_LENGTH = 500;
+
+export interface PatchRangeChangeDetail {
+ patchNum?: PatchSetNum;
+ basePatchNum?: PatchSetNum;
+}
+
+export type PatchRangeChangeEvent = CustomEvent<PatchRangeChangeDetail>;
+
+export interface FilesWebLinks {
+ meta_a: GeneratedWebLink[];
+ meta_b: GeneratedWebLink[];
+}
+
+export interface GrPatchRangeSelect {
+ $: {
+ patchNumDropdown: GrDropdownList;
+ };
+}
+
+/**
+ * Fired when the patch range changes
+ *
+ * @event patch-range-change
+ *
+ * @property {string} patchNum
+ * @property {string} basePatchNum
+ * @extends PolymerElement
+ */
+@customElement('gr-patch-range-select')
+export class GrPatchRangeSelect extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Array})
+ availablePatches?: PatchSet[];
+
+ @property({
+ type: Object,
+ computed:
+ '_computeBaseDropdownContent(availablePatches, patchNum,' +
+ '_sortedRevisions, changeComments, revisionInfo)',
+ })
+ _baseDropdownContent?: DropdownItem[];
+
+ @property({
+ type: Object,
+ computed:
+ '_computePatchDropdownContent(availablePatches,' +
+ 'basePatchNum, _sortedRevisions, changeComments)',
+ })
+ _patchDropdownContent?: DropdownItem[];
+
+ @property({type: String})
+ changeNum?: string;
+
+ @property({type: Object})
+ changeComments?: ChangeComments;
+
+ @property({type: Object})
+ filesWeblinks?: FilesWebLinks;
+
+ @property({type: String})
+ patchNum?: PatchSetNum;
+
+ @property({type: String})
+ basePatchNum?: PatchSetNum;
+
+ @property({type: Object})
+ revisions?: RevisionInfo[];
+
+ @property({type: Object})
+ revisionInfo?: RevisionInfoClass;
+
+ @property({type: Array})
+ _sortedRevisions?: RevisionInfo[];
+
+ private readonly reporting: ReportingService = appContext.reportingService;
+
+ constructor() {
+ super();
+ this.reporting = appContext.reportingService;
+ }
+
+ _getShaForPatch(patch: PatchSet) {
+ return patch.sha.substring(0, 10);
+ }
+
+ _computeBaseDropdownContent(
+ availablePatches?: PatchSet[],
+ patchNum?: PatchSetNum,
+ _sortedRevisions?: RevisionInfo[],
+ changeComments?: ChangeComments,
+ revisionInfo?: RevisionInfoClass
+ ): DropdownItem[] | undefined {
+ // Polymer 2: check for undefined
+ if (
+ availablePatches === undefined ||
+ patchNum === undefined ||
+ _sortedRevisions === undefined ||
+ changeComments === undefined ||
+ revisionInfo === undefined
+ ) {
+ return undefined;
+ }
+
+ const parentCounts = revisionInfo.getParentCountMap();
+ const currentParentCount = hasOwnProperty(parentCounts, patchNum)
+ ? parentCounts[patchNum as number]
+ : 1;
+ const maxParents = revisionInfo.getMaxParents();
+ const isMerge = currentParentCount > 1;
+
+ const dropdownContent: DropdownItem[] = [];
+ for (const basePatch of availablePatches) {
+ const basePatchNum = basePatch.num;
+ const entry: DropdownItem = this._createDropdownEntry(
+ basePatchNum,
+ 'Patchset ',
+ _sortedRevisions,
+ changeComments,
+ this._getShaForPatch(basePatch)
+ );
+ dropdownContent.push({
+ ...entry,
+ disabled: this._computeLeftDisabled(
+ basePatch.num,
+ patchNum,
+ _sortedRevisions
+ ),
+ });
+ }
+
+ dropdownContent.push({
+ text: isMerge ? 'Auto Merge' : 'Base',
+ value: 'PARENT',
+ });
+
+ for (let idx = 0; isMerge && idx < maxParents; idx++) {
+ dropdownContent.push({
+ disabled: idx >= currentParentCount,
+ triggerText: `Parent ${idx + 1}`,
+ text: `Parent ${idx + 1}`,
+ mobileText: `Parent ${idx + 1}`,
+ value: -(idx + 1),
+ });
+ }
+
+ return dropdownContent;
+ }
+
+ _computeMobileText(
+ patchNum: PatchSetNum,
+ changeComments: ChangeComments,
+ revisions: RevisionInfo[]
+ ) {
+ return (
+ `${patchNum}` +
+ `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+ `${this._computePatchSetDescription(revisions, patchNum, true)}`
+ );
+ }
+
+ _computePatchDropdownContent(
+ availablePatches?: PatchSet[],
+ basePatchNum?: PatchSetNum,
+ _sortedRevisions?: RevisionInfo[],
+ changeComments?: ChangeComments
+ ): DropdownItem[] | undefined {
+ // Polymer 2: check for undefined
+ if (
+ availablePatches === undefined ||
+ basePatchNum === undefined ||
+ _sortedRevisions === undefined ||
+ changeComments === undefined
+ ) {
+ return undefined;
+ }
+
+ const dropdownContent: DropdownItem[] = [];
+ for (const patch of availablePatches) {
+ const patchNum = patch.num;
+ const entry = this._createDropdownEntry(
+ patchNum,
+ patchNum === 'edit' ? '' : 'Patchset ',
+ _sortedRevisions,
+ changeComments,
+ this._getShaForPatch(patch)
+ );
+ dropdownContent.push({
+ ...entry,
+ disabled: this._computeRightDisabled(
+ basePatchNum,
+ patchNum,
+ _sortedRevisions
+ ),
+ });
+ }
+ return dropdownContent;
+ }
+
+ _computeText(
+ patchNum: PatchSetNum,
+ prefix: string,
+ changeComments: ChangeComments,
+ sha: string
+ ) {
+ return (
+ `${prefix}${patchNum}` +
+ `${this._computePatchSetCommentsString(changeComments, patchNum)}` +
+ ` | ${sha}`
+ );
+ }
+
+ _createDropdownEntry(
+ patchNum: PatchSetNum,
+ prefix: string,
+ sortedRevisions: RevisionInfo[],
+ changeComments: ChangeComments,
+ sha: string
+ ) {
+ const entry: DropdownItem = {
+ triggerText: `${prefix}${patchNum}`,
+ text: this._computeText(patchNum, prefix, changeComments, sha),
+ mobileText: this._computeMobileText(
+ patchNum,
+ changeComments,
+ sortedRevisions
+ ),
+ bottomText: `${this._computePatchSetDescription(
+ sortedRevisions,
+ patchNum
+ )}`,
+ value: patchNum,
+ };
+ const date = this._computePatchSetDate(sortedRevisions, patchNum);
+ if (date) {
+ entry.date = date;
+ }
+ return entry;
+ }
+
+ @observe('revisions.*')
+ _updateSortedRevisions(
+ revisionsRecord: PolymerDeepPropertyChange<RevisionInfo[], RevisionInfo[]>
+ ) {
+ const revisions = revisionsRecord.base;
+ if (!revisions) return;
+ this._sortedRevisions = sortRevisions(Object.values(revisions));
+ }
+
+ /**
+ * The basePatchNum should always be <= patchNum -- because sortedRevisions
+ * is sorted in reverse order (higher patchset nums first), invalid base
+ * patch nums have an index greater than the index of patchNum.
+ *
+ * @param basePatchNum The possible base patch num.
+ * @param patchNum The current selected patch num.
+ */
+ _computeLeftDisabled(
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ sortedRevisions: RevisionInfo[]
+ ): boolean {
+ return (
+ findSortedIndex(basePatchNum, sortedRevisions) <=
+ findSortedIndex(patchNum, sortedRevisions)
+ );
+ }
+
+ /**
+ * The basePatchNum should always be <= patchNum -- because sortedRevisions
+ * is sorted in reverse order (higher patchset nums first), invalid patch
+ * nums have an index greater than the index of basePatchNum.
+ *
+ * In addition, if the current basePatchNum is 'PARENT', all patchNums are
+ * valid.
+ *
+ * If the current basePatchNum is a parent index, then only patches that have
+ * at least that many parents are valid.
+ *
+ * @param basePatchNum The current selected base patch num.
+ * @param patchNum The possible patch num.
+ */
+ _computeRightDisabled(
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ sortedRevisions: RevisionInfo[]
+ ): boolean {
+ if (patchNumEquals(basePatchNum, ParentPatchSetNum)) {
+ return false;
+ }
+
+ if (isMergeParent(basePatchNum)) {
+ if (!this.revisionInfo) {
+ return true;
+ }
+ // Note: parent indices use 1-offset.
+ return (
+ this.revisionInfo.getParentCount(patchNum) <
+ getParentIndex(basePatchNum)
+ );
+ }
+
+ return (
+ findSortedIndex(basePatchNum, sortedRevisions) <=
+ findSortedIndex(patchNum, sortedRevisions)
+ );
+ }
+
+ _computePatchSetCommentsString(
+ changeComments: ChangeComments,
+ patchNum: PatchSetNum
+ ) {
+ if (!changeComments) {
+ return;
+ }
+
+ const commentThreadCount = changeComments.computeCommentThreadCount({
+ patchNum,
+ });
+ const commentThreadString = GrCountStringFormatter.computePluralString(
+ commentThreadCount,
+ 'comment'
+ );
+
+ const unresolvedCount = changeComments.computeUnresolvedNum({patchNum});
+ const unresolvedString = GrCountStringFormatter.computeString(
+ unresolvedCount,
+ 'unresolved'
+ );
+
+ if (!commentThreadString.length && !unresolvedString.length) {
+ return '';
+ }
+
+ return (
+ ` (${commentThreadString}` +
+ // Add a comma + space if both comment threads and unresolved
+ (commentThreadString && unresolvedString ? ', ' : '') +
+ `${unresolvedString})`
+ );
+ }
+
+ _computePatchSetDescription(
+ revisions: RevisionInfo[],
+ patchNum: PatchSetNum,
+ addFrontSpace?: boolean
+ ) {
+ const rev = getRevisionByPatchNum(revisions, patchNum);
+ return rev?.description
+ ? (addFrontSpace ? ' ' : '') +
+ rev.description.substring(0, PATCH_DESC_MAX_LENGTH)
+ : '';
+ }
+
+ _computePatchSetDate(
+ revisions: RevisionInfo[],
+ patchNum: PatchSetNum
+ ): Timestamp | undefined {
+ const rev = getRevisionByPatchNum(revisions, patchNum);
+ return rev ? rev.created : undefined;
+ }
+
+ /**
+ * Catches value-change events from the patchset dropdowns and determines
+ * whether or not a patch change event should be fired.
+ */
+ _handlePatchChange(e: DropDownValueChangeEvent) {
+ const detail: PatchRangeChangeDetail = {
+ patchNum: this.patchNum,
+ basePatchNum: this.basePatchNum,
+ };
+ const target = (dom(e) as EventApi).localTarget;
+ const patchSetValue = e.detail.value as PatchSetNum;
+ const latestPatchNum = computeLatestPatchNum(this.availablePatches);
+ if (target === this.$.patchNumDropdown) {
+ if (detail.patchNum === e.detail.value) return;
+ this.reporting.reportInteraction('right-patchset-changed', {
+ previous: detail.patchNum,
+ current: e.detail.value,
+ latest: latestPatchNum,
+ commentCount: this.changeComments?.computeCommentThreadCount({
+ patchNum: e.detail.value as PatchSetNum,
+ }),
+ });
+ detail.patchNum = patchSetValue;
+ } else {
+ if (patchNumEquals(detail.basePatchNum, patchSetValue)) return;
+ this.reporting.reportInteraction('left-patchset-changed', {
+ previous: detail.basePatchNum,
+ current: e.detail.value,
+ commentCount: this.changeComments?.computeCommentThreadCount({
+ patchNum: patchSetValue,
+ }),
+ });
+ detail.basePatchNum = patchSetValue;
+ }
+
+ this.dispatchEvent(
+ new CustomEvent('patch-range-change', {detail, bubbles: false})
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-patch-range-select': GrPatchRangeSelect;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
index 415ef4b..52465b3 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts
@@ -45,12 +45,10 @@
--native-select-style: {
max-width: 5.25em;
}
- --dropdown-content-stype: {
- max-width: 300px;
- }
}
}
</style>
+ <h3 class="assistive-tech-only">Patchset Range Selection</h3>
<span class="patchRange" aria-label="patch range starts with">
<gr-dropdown-list
id="basePatchDropdown"
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
index 782ffc5..15841d9 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.js
@@ -25,6 +25,7 @@
import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
const commentApiMockElement = createCommentApiMockWithTemplateElement(
'gr-patch-range-select-comment-api-mock', html`
@@ -68,7 +69,7 @@
test('enabled/disabled options', () => {
const patchRange = {
basePatchNum: 'PARENT',
- patchNum: '3',
+ patchNum: 3,
};
const sortedRevisions = [
{_number: 3},
@@ -185,7 +186,7 @@
];
element.patchNum = 2;
element.basePatchNum = 'PARENT';
- flushAsynchronousOperations();
+ flush();
sinon.stub(element, '_computeBaseDropdownContent');
@@ -211,7 +212,7 @@
];
element.patchNum = 2;
element.basePatchNum = 'PARENT';
- flushAsynchronousOperations();
+ flush();
// Should be recomputed for each available patch
sinon.stub(element, '_computeBaseDropdownContent');
@@ -239,7 +240,7 @@
];
element.patchNum = 2;
element.basePatchNum = 'PARENT';
- flushAsynchronousOperations();
+ flush();
// Should be recomputed for each available patch
sinon.stub(element, '_computePatchDropdownContent');
@@ -263,7 +264,7 @@
];
element.patchNum = 2;
element.basePatchNum = 'PARENT';
- flushAsynchronousOperations();
+ flush();
// Should be recomputed for each available patch
sinon.stub(element, '_computePatchDropdownContent');
@@ -345,7 +346,7 @@
},
],
};
- flushAsynchronousOperations();
+ flush();
const domApi = dom(element.root);
assert.equal(
domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
@@ -355,7 +356,7 @@
test('_computePatchSetCommentsString', () => {
// Test string with unresolved comments.
- element.changeComments._comments = {
+ const comments = {
foo: [{
id: '27dcee4d_f7b77cfa',
message: 'test',
@@ -377,6 +378,7 @@
}],
abc: [],
};
+ element.changeComments = new ChangeComments(comments, {}, {}, 123);
assert.equal(element._computePatchSetCommentsString(
element.changeComments, 1), ' (3 comments, 1 unresolved)');
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
deleted file mode 100644
index 231e4b5..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ /dev/null
@@ -1,248 +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 {GrAnnotation} from '../gr-diff-highlight/gr-annotation.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-ranged-comment-layer_html.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {strToClassName} from '../../../utils/dom-util.js';
-
-// Polymer 1 adds # before array's key, while Polymer 2 doesn't
-const HOVER_PATH_PATTERN = /^(commentRanges\.#?\d+)\.hovering$/;
-
-const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
-const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
-
-/** @extends PolymerElement */
-class GrRangedCommentLayer extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-ranged-comment-layer'; }
- /**
- * Fired when the range in a range comment was malformed and had to be
- * normalized.
- *
- * It's `detail` has a `lineNum` and `side` parameter.
- *
- * @event normalize-range
- */
-
- static get properties() {
- return {
- /** @type {!Array<!Gerrit.HoveredRange>} */
- commentRanges: Array,
- _listeners: {
- type: Array,
- value() { return []; },
- },
- _rangesMap: {
- type: Object,
- value() { return {left: {}, right: {}}; },
- },
- };
- }
-
- static get observers() {
- return [
- '_handleCommentRangesChange(commentRanges.*)',
- ];
- }
-
- get styleModuleName() {
- return 'gr-ranged-comment-styles';
- }
-
- /**
- * Layer method to add annotations to a line.
- *
- * @param {!HTMLElement} el The DIV.contentText element to apply the
- * annotation to.
- * @param {!HTMLElement} lineNumberEl
- * @param {!Object} line The line object. (GrDiffLine)
- */
- annotate(el, lineNumberEl, line) {
- let ranges = [];
- if (line.type === GrDiffLine.Type.REMOVE || (
- line.type === GrDiffLine.Type.BOTH &&
- el.getAttribute('data-side') !== 'right')) {
- ranges = ranges.concat(this._getRangesForLine(line, 'left'));
- }
- if (line.type === GrDiffLine.Type.ADD || (
- line.type === GrDiffLine.Type.BOTH &&
- el.getAttribute('data-side') !== 'left')) {
- ranges = ranges.concat(this._getRangesForLine(line, 'right'));
- }
-
- for (const range of ranges) {
- GrAnnotation.annotateElement(el, range.start,
- range.end - range.start,
- (range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT) +
- ` ${strToClassName(range.rootId)}`);
- }
- }
-
- /**
- * Register a listener for layer updates.
- *
- * @param {function(number, number, string)} fn The update handler function.
- * Should accept as arguments the line numbers for the start and end of
- * the update and the side as a string.
- */
- addListener(fn) {
- this._listeners.push(fn);
- }
-
- removeListener(fn) {
- this._listeners = this._listeners.filter(f => f != fn);
- }
-
- /**
- * Notify Layer listeners of changes to annotations.
- *
- * @param {number} start The line where the update starts.
- * @param {number} end The line where the update ends.
- * @param {string} side The side of the update. ('left' or 'right')
- */
- _notifyUpdateRange(start, end, side) {
- for (const listener of this._listeners) {
- listener(start, end, side);
- }
- }
-
- /**
- * Handle change in the ranges by updating the ranges maps and by
- * emitting appropriate update notifications.
- *
- * @param {Object} record The change record.
- */
- _handleCommentRangesChange(record) {
- if (!record) return;
-
- // If the entire set of comments was changed.
- if (record.path === 'commentRanges') {
- this._rangesMap = {left: {}, right: {}};
- for (const {side, range, rootId, hovering} of record.value) {
- this._updateRangesMap({
- side, range, hovering,
- operation: (forLine, start, end, hovering) => {
- forLine.push({start, end, hovering, rootId});
- }});
- }
- }
-
- // If the change only changed the `hovering` property of a comment.
- const match = record.path.match(HOVER_PATH_PATTERN);
- if (match) {
- // The #number indicates the key of that item in the array
- // not the index, especially in polymer 1.
- const {side, range, hovering, rootId} = this.get(match[1]);
-
- this._updateRangesMap({
- side, range, hovering, skipLayerUpdate: true,
- operation: (forLine, start, end, hovering) => {
- const index = forLine.findIndex(lineRange =>
- lineRange.start === start && lineRange.end === end);
- forLine[index].hovering = hovering;
- forLine[index].rootId = rootId;
- }});
- }
-
- // If comments were spliced in or out.
- if (record.path === 'commentRanges.splices') {
- for (const indexSplice of record.value.indexSplices) {
- const removed = indexSplice.removed;
- for (const {side, range, hovering, rootId} of removed) {
- this._updateRangesMap({
- side, range, hovering, operation: (forLine, start, end) => {
- const index = forLine.findIndex(lineRange =>
- lineRange.start === start && lineRange.end === end &&
- rootId === lineRange.rootId);
- forLine.splice(index, 1);
- }});
- }
- const added = indexSplice.object.slice(
- indexSplice.index, indexSplice.index + indexSplice.addedCount);
- for (const {side, range, hovering, rootId} of added) {
- this._updateRangesMap({
- side, range, hovering,
- operation: (forLine, start, end, hovering) => {
- forLine.push({start, end, hovering, rootId});
- }});
- }
- }
- }
- }
-
- /**
- * @param {!Object} options
- * @property {!string} options.side
- * @property {boolean} options.hovering
- * @property {boolean} options.skipLayerUpdate
- * @property {!Function} options.operation
- * @property {!{
- * start_character: number,
- * start_line: number,
- * end_line: number,
- * end_character: number}} options.range
- */
- _updateRangesMap(options) {
- const {side, range, hovering, operation, skipLayerUpdate} = options;
- const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
- for (let line = range.start_line; line <= range.end_line; line++) {
- const forLine = forSide[line] || (forSide[line] = []);
- const start = line === range.start_line ? range.start_character : 0;
- const end = line === range.end_line ? range.end_character : -1;
- operation(forLine, start, end, hovering);
- }
- if (!skipLayerUpdate) {
- this._notifyUpdateRange(range.start_line, range.end_line, side);
- }
- }
-
- _getRangesForLine(line, side) {
- const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
- const ranges = this.get(['_rangesMap', side, lineNum]) || [];
- return ranges
- .map(range => {
- // Make a copy, so that the normalization below does not mess with
- // our map.
- range = Object.assign({}, range);
- range.end = range.end === -1 ? line.text.length : range.end;
-
- // Normalize invalid ranges where the start is after the end but the
- // start still makes sense. Set the end to the end of the line.
- // @see Issue 5744
- if (range.start >= range.end && range.start < line.text.length) {
- range.end = line.text.length;
- this.dispatchEvent(new CustomEvent('normalize-range', {
- bubbles: true,
- composed: true,
- detail: {lineNum, side},
- }));
- }
-
- return range;
- })
- // Sort the ranges so that hovering highlights are on top.
- .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0));
- }
-}
-
-customElements.define(GrRangedCommentLayer.is, GrRangedCommentLayer);
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
new file mode 100644
index 0000000..bb3733f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.ts
@@ -0,0 +1,307 @@
+/**
+ * @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 {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+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-ranged-comment-layer_html';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {strToClassName} from '../../../utils/dom-util';
+import {customElement, property, observe} from '@polymer/decorators';
+import {Side} from '../../../constants/constants';
+import {
+ PolymerDeepPropertyChange,
+ PolymerSpliceChange,
+} from '@polymer/polymer/interfaces';
+import {CommentRange} from '../../../types/common';
+import {DiffLayer, DiffLayerListener} from '../../../types/types';
+
+/**
+ * Enhanced CommentRange by UI state. Interface for incoming ranges set from the
+ * outside.
+ *
+ * TODO(TS): Unify with what is used in gr-diff when these objects are created.
+ */
+export interface CommentRangeLayer {
+ side: Side;
+ range: CommentRange;
+ hovering: boolean;
+ rootId: string;
+}
+
+/**
+ * This class breaks down all comment ranges into individual line segment
+ * highlights.
+ */
+interface CommentRangeLineLayer {
+ hovering: boolean;
+ rootId: string;
+ start: number;
+ end: number;
+}
+
+type LinesMap = {
+ [line in number]: CommentRangeLineLayer[];
+};
+
+type RangesMap = {
+ [side in Side]: LinesMap;
+};
+
+// Polymer 1 adds # before array's key, while Polymer 2 doesn't
+const HOVER_PATH_PATTERN = /^(commentRanges\.#?\d+)\.hovering$/;
+
+const RANGE_HIGHLIGHT = 'style-scope gr-diff range';
+const HOVER_HIGHLIGHT = 'style-scope gr-diff rangeHighlight';
+
+@customElement('gr-ranged-comment-layer')
+export class GrRangedCommentLayer
+ extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+ implements DiffLayer {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the range in a range comment was malformed and had to be
+ * normalized.
+ *
+ * It's `detail` has a `lineNum` and `side` parameter.
+ *
+ * @event normalize-range
+ */
+
+ @property({type: Array})
+ commentRanges: CommentRangeLayer[] = [];
+
+ @property({type: Array})
+ _listeners: DiffLayerListener[] = [];
+
+ @property({type: Object})
+ _rangesMap: RangesMap = {left: {}, right: {}};
+
+ get styleModuleName() {
+ return 'gr-ranged-comment-styles';
+ }
+
+ /**
+ * Layer method to add annotations to a line.
+ *
+ * @param el The DIV.contentText element to apply the annotation to.
+ */
+ annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ let ranges: CommentRangeLineLayer[] = [];
+ if (
+ line.type === GrDiffLineType.REMOVE ||
+ (line.type === GrDiffLineType.BOTH &&
+ el.getAttribute('data-side') !== 'right')
+ ) {
+ ranges = ranges.concat(this._getRangesForLine(line, Side.LEFT));
+ }
+ if (
+ line.type === GrDiffLineType.ADD ||
+ (line.type === GrDiffLineType.BOTH &&
+ el.getAttribute('data-side') !== 'left')
+ ) {
+ ranges = ranges.concat(this._getRangesForLine(line, Side.RIGHT));
+ }
+
+ for (const range of ranges) {
+ GrAnnotation.annotateElement(
+ el,
+ range.start,
+ range.end - range.start,
+ (range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT) +
+ ` ${strToClassName(range.rootId)}`
+ );
+ }
+ }
+
+ /**
+ * Register a listener for layer updates.
+ */
+ addListener(listener: DiffLayerListener) {
+ this._listeners.push(listener);
+ }
+
+ removeListener(listener: DiffLayerListener) {
+ this._listeners = this._listeners.filter(f => f !== listener);
+ }
+
+ /**
+ * Notify Layer listeners of changes to annotations.
+ */
+ _notifyUpdateRange(start: number, end: number, side: Side) {
+ for (const listener of this._listeners) {
+ listener(start, end, side);
+ }
+ }
+
+ /**
+ * Handle change in the ranges by updating the ranges maps and by
+ * emitting appropriate update notifications.
+ */
+ @observe('commentRanges.*')
+ _handleCommentRangesChange(
+ record: PolymerDeepPropertyChange<
+ CommentRangeLayer[],
+ PolymerSpliceChange<CommentRangeLayer[]>
+ >
+ ) {
+ if (!record) return;
+
+ // If the entire set of comments was changed.
+ if (record.path === 'commentRanges') {
+ const value = record.value as CommentRangeLayer[];
+ this._rangesMap = {left: {}, right: {}};
+ for (const {side, range, rootId, hovering} of value) {
+ this._updateRangesMap({
+ side,
+ range,
+ hovering,
+ operation: (forLine, start, end, hovering) => {
+ forLine.push({start, end, hovering, rootId});
+ },
+ });
+ }
+ }
+
+ // If the change only changed the `hovering` property of a comment.
+ const match = record.path.match(HOVER_PATH_PATTERN);
+ if (match) {
+ // The #number indicates the key of that item in the array
+ // not the index, especially in polymer 1.
+ const {side, range, hovering, rootId} = this.get(match[1]);
+
+ this._updateRangesMap({
+ side,
+ range,
+ hovering,
+ skipLayerUpdate: true,
+ operation: (forLine, start, end, hovering) => {
+ const index = forLine.findIndex(
+ lineRange => lineRange.start === start && lineRange.end === end
+ );
+ forLine[index].hovering = hovering;
+ forLine[index].rootId = rootId;
+ },
+ });
+ }
+
+ // If comments were spliced in or out.
+ if (record.path === 'commentRanges.splices') {
+ const value = record.value as PolymerSpliceChange<CommentRangeLayer[]>;
+ for (const indexSplice of value.indexSplices) {
+ const removed = indexSplice.removed;
+ for (const {side, range, hovering, rootId} of removed) {
+ this._updateRangesMap({
+ side,
+ range,
+ hovering,
+ operation: (forLine, start, end) => {
+ const index = forLine.findIndex(
+ lineRange =>
+ lineRange.start === start &&
+ lineRange.end === end &&
+ rootId === lineRange.rootId
+ );
+ forLine.splice(index, 1);
+ },
+ });
+ }
+ const added = indexSplice.object.slice(
+ indexSplice.index,
+ indexSplice.index + indexSplice.addedCount
+ );
+ for (const {side, range, hovering, rootId} of added) {
+ this._updateRangesMap({
+ side,
+ range,
+ hovering,
+ operation: (forLine, start, end, hovering) => {
+ forLine.push({start, end, hovering, rootId});
+ },
+ });
+ }
+ }
+ }
+ }
+
+ _updateRangesMap(options: {
+ side: Side;
+ range: CommentRange;
+ hovering: boolean;
+ operation: (
+ forLine: CommentRangeLineLayer[],
+ start: number,
+ end: number,
+ hovering: boolean
+ ) => void;
+ skipLayerUpdate?: boolean;
+ }) {
+ const {side, range, hovering, operation, skipLayerUpdate} = options;
+ const forSide = this._rangesMap[side] || (this._rangesMap[side] = {});
+ for (let line = range.start_line; line <= range.end_line; line++) {
+ const forLine = forSide[line] || (forSide[line] = []);
+ const start = line === range.start_line ? range.start_character : 0;
+ const end = line === range.end_line ? range.end_character : -1;
+ operation(forLine, start, end, hovering);
+ }
+ if (!skipLayerUpdate) {
+ this._notifyUpdateRange(range.start_line, range.end_line, side);
+ }
+ }
+
+ _getRangesForLine(line: GrDiffLine, side: Side) {
+ const lineNum = side === Side.LEFT ? line.beforeNumber : line.afterNumber;
+ const ranges: CommentRangeLineLayer[] =
+ this.get(['_rangesMap', side, lineNum]) || [];
+ return (
+ ranges
+ .map(range => {
+ // Make a copy, so that the normalization below does not mess with
+ // our map.
+ range = {...range};
+ range.end = range.end === -1 ? line.text.length : range.end;
+
+ // Normalize invalid ranges where the start is after the end but the
+ // start still makes sense. Set the end to the end of the line.
+ // @see Issue 5744
+ if (range.start! >= range.end! && range.start! < line.text.length) {
+ range.end = line.text.length;
+ this.dispatchEvent(
+ new CustomEvent('normalize-range', {
+ bubbles: true,
+ composed: true,
+ detail: {lineNum, side},
+ })
+ );
+ }
+
+ return range;
+ })
+ // Sort the ranges so that hovering highlights are on top.
+ .sort((a, b) => (a.hovering && !b.hovering ? 1 : 0))
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-ranged-comment-layer': GrRangedCommentLayer;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
index 5f32677..441d585 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.js
@@ -19,7 +19,7 @@
import '../gr-diff/gr-diff-line.js';
import './gr-ranged-comment-layer.js';
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
const basicFixture = fixtureFromElement('gr-ranged-comment-layer');
@@ -84,12 +84,12 @@
annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
el = document.createElement('div');
el.setAttribute('data-side', 'left');
- line = new GrDiffLine(GrDiffLine.Type.BOTH);
+ line = new GrDiffLine(GrDiffLineType.BOTH);
line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
});
test('type=Remove no-comment', () => {
- line.type = GrDiffLine.Type.REMOVE;
+ line.type = GrDiffLineType.REMOVE;
line.beforeNumber = 40;
element.annotate(el, lineNumberEl, line);
@@ -98,7 +98,7 @@
});
test('type=Remove has-comment', () => {
- line.type = GrDiffLine.Type.REMOVE;
+ line.type = GrDiffLineType.REMOVE;
line.beforeNumber = 36;
const expectedStart = 6;
const expectedLength = line.text.length - expectedStart;
@@ -114,7 +114,7 @@
});
test('type=Remove has-comment hovering', () => {
- line.type = GrDiffLine.Type.REMOVE;
+ line.type = GrDiffLineType.REMOVE;
line.beforeNumber = 36;
element.set(['commentRanges', 0, 'hovering'], true);
@@ -134,7 +134,7 @@
});
test('type=Both has-comment', () => {
- line.type = GrDiffLine.Type.BOTH;
+ line.type = GrDiffLineType.BOTH;
line.beforeNumber = 36;
const expectedStart = 6;
@@ -151,7 +151,7 @@
});
test('type=Both has-comment off side', () => {
- line.type = GrDiffLine.Type.BOTH;
+ line.type = GrDiffLineType.BOTH;
line.beforeNumber = 36;
el.setAttribute('data-side', 'right');
@@ -161,7 +161,7 @@
});
test('type=Add has-comment', () => {
- line.type = GrDiffLine.Type.ADD;
+ line.type = GrDiffLineType.ADD;
line.afterNumber = 12;
el.setAttribute('data-side', 'right');
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
deleted file mode 100644
index 79f937c..0000000
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ /dev/null
@@ -1,110 +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 '../../../styles/shared-styles.js';
-import '../../shared/gr-tooltip/gr-tooltip.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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-selection-action-box_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrSelectionActionBox extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-selection-action-box'; }
- /**
- * Fired when the comment creation action was taken (click).
- *
- * @event create-comment-requested
- */
-
- static get properties() {
- return {
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- positionBelow: Boolean,
- };
- }
-
- /** @override */
- created() {
- super.created();
-
- // See https://crbug.com/gerrit/4767
- this.addEventListener('mousedown',
- e => this._handleMouseDown(e));
- }
-
- placeAbove(el) {
- flush();
- const rect = this._getTargetBoundingRect(el);
- const boxRect = this.$.tooltip.getBoundingClientRect();
- const parentRect = this._getParentBoundingClientRect();
- this.style.top =
- rect.top - parentRect.top - boxRect.height - 6 + 'px';
- this.style.left =
- rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
- }
-
- placeBelow(el) {
- flush();
- const rect = this._getTargetBoundingRect(el);
- const boxRect = this.$.tooltip.getBoundingClientRect();
- const parentRect = this._getParentBoundingClientRect();
- this.style.top =
- rect.top - parentRect.top + boxRect.height - 6 + 'px';
- this.style.left =
- rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
- }
-
- _getParentBoundingClientRect() {
- // With native shadow DOM, the parent is the shadow root, not the gr-diff
- // element
- const parent = this.parentElement || this.parentNode.host;
- return parent.getBoundingClientRect();
- }
-
- _getTargetBoundingRect(el) {
- let rect;
- if (el instanceof Text) {
- const range = document.createRange();
- range.selectNode(el);
- rect = range.getBoundingClientRect();
- range.detach();
- } else {
- rect = el.getBoundingClientRect();
- }
- return rect;
- }
-
- _handleMouseDown(e) {
- if (e.button !== 0) { return; } // 0 = main button
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('create-comment-requested', {
- composed: true, bubbles: true,
- }));
- }
-}
-
-customElements.define(GrSelectionActionBox.is, GrSelectionActionBox);
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
new file mode 100644
index 0000000..d702fb1
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.ts
@@ -0,0 +1,132 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import {GrTooltip} from '../../shared/gr-tooltip/gr-tooltip';
+import {customElement, property} from '@polymer/decorators';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-selection-action-box_html';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-selection-action-box': GrSelectionActionBox;
+ }
+}
+
+export interface GrSelectionActionBox {
+ $: {
+ tooltip: GrTooltip;
+ };
+}
+
+@customElement('gr-selection-action-box')
+export class GrSelectionActionBox extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the comment creation action was taken (click).
+ *
+ * @event create-comment-requested
+ */
+
+ @property({type: Object})
+ keyEventTarget: Record<string, any> = document.body;
+
+ @property({type: Boolean})
+ positionBelow = false;
+
+ /** @override */
+ created() {
+ super.created();
+
+ // See https://crbug.com/gerrit/4767
+ this.addEventListener('mousedown', e => this._handleMouseDown(e));
+ }
+
+ placeAbove(el: Text | Element | Range) {
+ flush();
+ const rect = this._getTargetBoundingRect(el);
+ const boxRect = this.$.tooltip.getBoundingClientRect();
+ const parentRect = this._getParentBoundingClientRect();
+ if (parentRect === null) {
+ return;
+ }
+ this.style.top = `${rect.top - parentRect.top - boxRect.height - 6}px`;
+ this.style.left = `${
+ rect.left - parentRect.left + (rect.width - boxRect.width) / 2
+ }px`;
+ }
+
+ placeBelow(el: Text | Element | Range) {
+ flush();
+ const rect = this._getTargetBoundingRect(el);
+ const boxRect = this.$.tooltip.getBoundingClientRect();
+ const parentRect = this._getParentBoundingClientRect();
+ if (parentRect === null) {
+ return;
+ }
+ this.style.top = `${rect.top - parentRect.top + boxRect.height - 6}px`;
+ this.style.left = `${
+ rect.left - parentRect.left + (rect.width - boxRect.width) / 2
+ }px`;
+ }
+
+ private _getParentBoundingClientRect() {
+ // With native shadow DOM, the parent is the shadow root, not the gr-diff
+ // element
+ if (this.parentElement) {
+ return this.parentElement.getBoundingClientRect();
+ }
+ if (this.parentNode !== null) {
+ return (this.parentNode as ShadowRoot).host.getBoundingClientRect();
+ }
+ return null;
+ }
+
+ private _getTargetBoundingRect(el: Text | Element | Range) {
+ let rect;
+ if (el instanceof Text) {
+ const range = document.createRange();
+ range.selectNode(el);
+ rect = range.getBoundingClientRect();
+ range.detach();
+ } else {
+ rect = el.getBoundingClientRect();
+ }
+ return rect;
+ }
+
+ private _handleMouseDown(e: MouseEvent) {
+ if (e.button !== 0) {
+ return;
+ } // 0 = main button
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('create-comment-requested', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
deleted file mode 100644
index 6399c4a..0000000
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ /dev/null
@@ -1,554 +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-lib-loader/gr-lib-loader.js';
-import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.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-syntax-layer_html.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
-import {util} from '../../../scripts/util.js';
-
-const LANGUAGE_MAP = {
- 'application/dart': 'dart',
- 'application/json': 'json',
- 'application/x-powershell': 'powershell',
- 'application/typescript': 'typescript',
- 'application/xml': 'xml',
- 'application/xquery': 'xquery',
- 'application/x-erb': 'erb',
- 'text/css': 'css',
- 'text/html': 'html',
- 'text/javascript': 'js',
- 'text/jsx': 'jsx',
- 'text/x-c': 'cpp',
- 'text/x-c++src': 'cpp',
- 'text/x-clojure': 'clojure',
- 'text/x-cmake': 'cmake',
- 'text/x-coffeescript': 'coffeescript',
- 'text/x-common-lisp': 'lisp',
- 'text/x-crystal': 'crystal',
- 'text/x-csharp': 'csharp',
- 'text/x-csrc': 'cpp',
- 'text/x-d': 'd',
- 'text/x-diff': 'diff',
- 'text/x-django': 'django',
- 'text/x-dockerfile': 'dockerfile',
- 'text/x-ebnf': 'ebnf',
- 'text/x-elm': 'elm',
- 'text/x-erlang': 'erlang',
- 'text/x-fortran': 'fortran',
- 'text/x-fsharp': 'fsharp',
- 'text/x-go': 'go',
- 'text/x-groovy': 'groovy',
- 'text/x-haml': 'haml',
- 'text/x-handlebars': 'handlebars',
- 'text/x-haskell': 'haskell',
- 'text/x-haxe': 'haxe',
- 'text/x-ini': 'ini',
- 'text/x-java': 'java',
- 'text/x-julia': 'julia',
- 'text/x-kotlin': 'kotlin',
- 'text/x-latex': 'latex',
- 'text/x-less': 'less',
- 'text/x-lua': 'lua',
- 'text/x-mathematica': 'mathematica',
- 'text/x-nginx-conf': 'nginx',
- 'text/x-nsis': 'nsis',
- 'text/x-objectivec': 'objectivec',
- 'text/x-ocaml': 'ocaml',
- 'text/x-perl': 'perl',
- 'text/x-pgsql': 'pgsql', // postgresql
- 'text/x-php': 'php',
- 'text/x-properties': 'properties',
- 'text/x-protobuf': 'protobuf',
- 'text/x-puppet': 'puppet',
- 'text/x-python': 'python',
- 'text/x-q': 'q',
- 'text/x-ruby': 'ruby',
- 'text/x-rustsrc': 'rust',
- 'text/x-scala': 'scala',
- 'text/x-scss': 'scss',
- 'text/x-scheme': 'scheme',
- 'text/x-shell': 'shell',
- 'text/x-soy': 'soy',
- 'text/x-spreadsheet': 'excel',
- 'text/x-sh': 'bash',
- 'text/x-sql': 'sql',
- 'text/x-swift': 'swift',
- 'text/x-systemverilog': 'sv',
- 'text/x-tcl': 'tcl',
- 'text/x-torque': 'torque',
- 'text/x-twig': 'twig',
- 'text/x-vb': 'vb',
- 'text/x-verilog': 'v',
- 'text/x-vhdl': 'vhdl',
- 'text/x-yaml': 'yaml',
- 'text/vbscript': 'vbscript',
-};
-const ASYNC_DELAY = 10;
-
-const CLASS_SAFELIST = {
- 'gr-diff gr-syntax gr-syntax-attr': true,
- 'gr-diff gr-syntax gr-syntax-attribute': true,
- 'gr-diff gr-syntax gr-syntax-built_in': true,
- 'gr-diff gr-syntax gr-syntax-comment': true,
- 'gr-diff gr-syntax gr-syntax-doctag': true,
- 'gr-diff gr-syntax gr-syntax-function': true,
- 'gr-diff gr-syntax gr-syntax-keyword': true,
- 'gr-diff gr-syntax gr-syntax-link': true,
- 'gr-diff gr-syntax gr-syntax-literal': true,
- 'gr-diff gr-syntax gr-syntax-meta': true,
- 'gr-diff gr-syntax gr-syntax-meta-keyword': true,
- 'gr-diff gr-syntax gr-syntax-name': true,
- 'gr-diff gr-syntax gr-syntax-number': true,
- 'gr-diff gr-syntax gr-syntax-params': true,
- 'gr-diff gr-syntax gr-syntax-regexp': true,
- 'gr-diff gr-syntax gr-syntax-selector-attr': true,
- 'gr-diff gr-syntax gr-syntax-selector-class': true,
- 'gr-diff gr-syntax gr-syntax-selector-id': true,
- 'gr-diff gr-syntax gr-syntax-selector-pseudo': true,
- 'gr-diff gr-syntax gr-syntax-selector-tag': true,
- 'gr-diff gr-syntax gr-syntax-string': true,
- 'gr-diff gr-syntax gr-syntax-tag': true,
- 'gr-diff gr-syntax gr-syntax-template-tag': true,
- 'gr-diff gr-syntax gr-syntax-template-variable': true,
- 'gr-diff gr-syntax gr-syntax-title': true,
- 'gr-diff gr-syntax gr-syntax-type': true,
- 'gr-diff gr-syntax gr-syntax-variable': true,
-};
-
-const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
-const CPP_WCHAR_PATTERN = /L'(\\)?.'/g;
-const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
-const GO_BACKSLASH_LITERAL = '\'\\\\\'';
-const GLOBAL_LT_PATTERN = /</g;
-
-/** @extends PolymerElement */
-class GrSyntaxLayer extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-syntax-layer'; }
-
- static get properties() {
- return {
- diff: {
- type: Object,
- observer: '_diffChanged',
- },
- enabled: {
- type: Boolean,
- value: true,
- },
- _baseRanges: {
- type: Array,
- value() { return []; },
- },
- _revisionRanges: {
- type: Array,
- value() { return []; },
- },
- _baseLanguage: String,
- _revisionLanguage: String,
- _listeners: {
- type: Array,
- value() { return []; },
- },
- /** @type {?number} */
- _processHandle: Number,
- /**
- * The promise last returned from `process()` while the asynchronous
- * processing is running - `null` otherwise. Provides a `cancel()`
- * method that rejects it with `{isCancelled: true}`.
- *
- * @type {?Object}
- */
- _processPromise: {
- type: Object,
- value: null,
- },
- _hljs: Object,
- };
- }
-
- addListener(fn) {
- this.push('_listeners', fn);
- }
-
- removeListener(fn) {
- this._listeners = this._listeners.filter(f => f != fn);
- }
-
- /**
- * Annotation layer method to add syntax annotations to the given element
- * for the given line.
- *
- * @param {!HTMLElement} el
- * @param {!HTMLElement} lineNumberEl
- * @param {!Object} line (GrDiffLine)
- */
- annotate(el, lineNumberEl, line) {
- if (!this.enabled) { return; }
-
- // Determine the side.
- let side;
- if (line.type === GrDiffLine.Type.REMOVE || (
- line.type === GrDiffLine.Type.BOTH &&
- el.getAttribute('data-side') !== 'right')) {
- side = 'left';
- } else if (line.type === GrDiffLine.Type.ADD || (
- el.getAttribute('data-side') !== 'left')) {
- side = 'right';
- }
-
- // Find the relevant syntax ranges, if any.
- let ranges = [];
- if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
- ranges = this._baseRanges[line.beforeNumber - 1] || [];
- } else if (side === 'right' &&
- this._revisionRanges.length >= line.afterNumber) {
- ranges = this._revisionRanges[line.afterNumber - 1] || [];
- }
-
- // Apply the ranges to the element.
- for (const range of ranges) {
- GrAnnotation.annotateElement(
- el, range.start, range.length, range.className);
- }
- }
-
- _getLanguage(diffFileMetaInfo) {
- // The Gerrit API provides only content-type, but for other users of
- // gr-diff it may be more convenient to specify the language directly.
- return diffFileMetaInfo.language ||
- LANGUAGE_MAP[diffFileMetaInfo.content_type];
- }
-
- /**
- * Start processing syntax for the loaded diff and notify layer listeners
- * as syntax info comes online.
- *
- * @return {Promise}
- */
- process() {
- // Cancel any still running process() calls, because they append to the
- // same _baseRanges and _revisionRanges fields.
- this.cancel();
-
- // Discard existing ranges.
- this._baseRanges = [];
- this._revisionRanges = [];
-
- if (!this.enabled || !this.diff.content.length) {
- return Promise.resolve();
- }
-
- if (this.diff.meta_a) {
- this._baseLanguage = this._getLanguage(this.diff.meta_a);
- }
- if (this.diff.meta_b) {
- this._revisionLanguage = this._getLanguage(this.diff.meta_b);
- }
- if (!this._baseLanguage && !this._revisionLanguage) {
- return Promise.resolve();
- }
-
- const state = {
- sectionIndex: 0,
- lineIndex: 0,
- baseContext: undefined,
- revisionContext: undefined,
- lineNums: {left: 1, right: 1},
- lastNotify: {left: 1, right: 1},
- };
-
- const rangesCache = new Map();
-
- this._processPromise = util.makeCancelable(this._loadHLJS()
- .then(() => new Promise(resolve => {
- const nextStep = () => {
- this._processHandle = null;
- this._processNextLine(state, rangesCache);
-
- // Move to the next line in the section.
- state.lineIndex++;
-
- // If the section has been exhausted, move to the next one.
- if (this._isSectionDone(state)) {
- state.lineIndex = 0;
- state.sectionIndex++;
- }
-
- // If all sections have been exhausted, finish.
- if (state.sectionIndex >= this.diff.content.length) {
- resolve();
- this._notify(state);
- return;
- }
-
- if (state.lineIndex % 100 === 0) {
- this._notify(state);
- this._processHandle = this.async(nextStep, ASYNC_DELAY);
- } else {
- nextStep.call(this);
- }
- };
-
- this._processHandle = this.async(nextStep, 1);
- })));
- return this._processPromise
- .finally(() => { this._processPromise = null; });
- }
-
- /**
- * Cancel any asynchronous syntax processing jobs.
- */
- cancel() {
- if (this._processHandle != null) {
- this.cancelAsync(this._processHandle);
- this._processHandle = null;
- }
- if (this._processPromise) {
- this._processPromise.cancel();
- }
- }
-
- _diffChanged() {
- this.cancel();
- this._baseRanges = [];
- this._revisionRanges = [];
- }
-
- /**
- * Take a string of HTML with the (potentially nested) syntax markers
- * Highlight.js emits and emit a list of text ranges and classes for the
- * markers.
- *
- * @param {string} str The string of HTML.
- * @param {Map<string, !Array<!Object>>} rangesCache A map for caching
- * ranges for each string. A cache is read and written by this method.
- * Since diff is mostly comparing same file on two sides, there is good rate
- * of duplication at least for parts that are on left and right parts.
- * @return {!Array<!Object>} The list of ranges.
- */
- _rangesFromString(str, rangesCache) {
- const cached = rangesCache.get(str);
- if (cached) return cached;
-
- const div = document.createElement('div');
- div.innerHTML = str;
- const ranges = this._rangesFromElement(div, 0);
- rangesCache.set(str, ranges);
- return ranges;
- }
-
- _rangesFromElement(elem, offset) {
- let result = [];
- for (const node of elem.childNodes) {
- const nodeLength = GrAnnotation.getLength(node);
- // Note: HLJS may emit a span with class undefined when it thinks there
- // may be a syntax error.
- if (node.tagName === 'SPAN' && node.className !== 'undefined') {
- if (CLASS_SAFELIST.hasOwnProperty(node.className)) {
- result.push({
- start: offset,
- length: nodeLength,
- className: node.className,
- });
- }
- if (node.children.length) {
- result = result.concat(this._rangesFromElement(node, offset));
- }
- }
- offset += nodeLength;
- }
- return result;
- }
-
- /**
- * For a given state, process the syntax for the next line (or pair of
- * lines).
- *
- * @param {!Object} state The processing state for the layer.
- */
- _processNextLine(state, rangesCache) {
- let baseLine;
- let revisionLine;
-
- const section = this.diff.content[state.sectionIndex];
- if (section.ab) {
- baseLine = section.ab[state.lineIndex];
- revisionLine = section.ab[state.lineIndex];
- state.lineNums.left++;
- state.lineNums.right++;
- } else {
- if (section.a && section.a.length > state.lineIndex) {
- baseLine = section.a[state.lineIndex];
- state.lineNums.left++;
- }
- if (section.b && section.b.length > state.lineIndex) {
- revisionLine = section.b[state.lineIndex];
- state.lineNums.right++;
- }
- }
-
- // To store the result of the syntax highlighter.
- let result;
-
- if (this._baseLanguage && baseLine !== undefined &&
- this._hljs.getLanguage(this._baseLanguage)) {
- baseLine = this._workaround(this._baseLanguage, baseLine);
- result = this._hljs.highlight(this._baseLanguage, baseLine, true,
- state.baseContext);
- this.push('_baseRanges',
- this._rangesFromString(result.value, rangesCache));
- state.baseContext = result.top;
- }
-
- if (this._revisionLanguage && revisionLine !== undefined &&
- this._hljs.getLanguage(this._revisionLanguage)) {
- revisionLine = this._workaround(this._revisionLanguage, revisionLine);
- result = this._hljs.highlight(this._revisionLanguage, revisionLine,
- true, state.revisionContext);
- this.push('_revisionRanges',
- this._rangesFromString(result.value, rangesCache));
- state.revisionContext = result.top;
- }
- }
-
- /**
- * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained
- * cases before sending them into HLJS so that they parse correctly.
- *
- * Important notes:
- * * These tests should be as constrained as possible to avoid interfering
- * with code it shouldn't AND to avoid executing regexes as much as
- * possible.
- * * These tests should document the issue clearly enough that the test can
- * be condidently removed when the issue is solved in HLJS.
- * * These tests should rewrite the line of code to have the same number of
- * characters. This method rewrites the string that gets parsed, but NOT
- * the string that gets displayed and highlighted. Thus, the positions
- * must be consistent.
- *
- * @param {!string} language The name of the HLJS language plugin in use.
- * @param {!string} line The line of code to potentially rewrite.
- * @return {string} A potentially-rewritten line of code.
- */
- _workaround(language, line) {
- if (language === 'cpp') {
- /**
- * Prevent confusing < and << operators for the start of a meta string
- * by converting them to a different operator.
- * {@see Issue 4864}
- * {@see https://github.com/isagalaev/highlight.js/issues/1341}
- */
- if (CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) {
- line = line.replace(GLOBAL_LT_PATTERN, '|');
- }
-
- /**
- * Rewrite CPP wchar_t characters literals to wchar_t string literals
- * because HLJS only understands the string form.
- * {@see Issue 5242}
- * {#see https://github.com/isagalaev/highlight.js/issues/1412}
- */
- if (CPP_WCHAR_PATTERN.test(line)) {
- line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
- }
-
- return line;
- }
-
- /**
- * Prevent confusing the closing paren of a parameterized Java annotation
- * being applied to a formal argument as the closing paren of the argument
- * list. Rewrite the parens as spaces.
- * {@see Issue 4776}
- * {@see https://github.com/isagalaev/highlight.js/issues/1324}
- */
- if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) {
- return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 ');
- }
-
- /**
- * HLJS misunderstands backslash character literals in Go.
- * {@see Issue 5007}
- * {#see https://github.com/isagalaev/highlight.js/issues/1411}
- */
- if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) {
- return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
- }
-
- return line;
- }
-
- /**
- * Tells whether the state has exhausted its current section.
- *
- * @param {!Object} state
- * @return {boolean}
- */
- _isSectionDone(state) {
- const section = this.diff.content[state.sectionIndex];
- if (section.ab) {
- return state.lineIndex >= section.ab.length;
- } else {
- return (!section.a || state.lineIndex >= section.a.length) &&
- (!section.b || state.lineIndex >= section.b.length);
- }
- }
-
- /**
- * For a given state, notify layer listeners of any processed line ranges
- * that have not yet been notified.
- *
- * @param {!Object} state
- */
- _notify(state) {
- if (state.lineNums.left - state.lastNotify.left) {
- this._notifyRange(
- state.lastNotify.left,
- state.lineNums.left,
- 'left');
- state.lastNotify.left = state.lineNums.left;
- }
- if (state.lineNums.right - state.lastNotify.right) {
- this._notifyRange(
- state.lastNotify.right,
- state.lineNums.right,
- 'right');
- state.lastNotify.right = state.lineNums.right;
- }
- }
-
- _notifyRange(start, end, side) {
- for (const fn of this._listeners) {
- fn(start, end, side);
- }
- }
-
- _loadHLJS() {
- return this.$.libLoader.getHLJS().then(hljs => {
- this._hljs = hljs;
- });
- }
-}
-
-customElements.define(GrSyntaxLayer.is, GrSyntaxLayer);
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
new file mode 100644
index 0000000..334c8f4
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.ts
@@ -0,0 +1,611 @@
+/**
+ * @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-lib-loader/gr-lib-loader';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+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-syntax-layer_html';
+import {FILE, GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {CancelablePromise, util} from '../../../scripts/util';
+import {customElement, property} from '@polymer/decorators';
+import {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types';
+import {DiffFileMetaInfo, DiffInfo} from '../../../types/common';
+import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
+import {Side} from '../../../constants/constants';
+
+const LANGUAGE_MAP = new Map<string, string>([
+ ['application/dart', 'dart'],
+ ['application/json', 'json'],
+ ['application/x-powershell', 'powershell'],
+ ['application/typescript', 'typescript'],
+ ['application/xml', 'xml'],
+ ['application/xquery', 'xquery'],
+ ['application/x-erb', 'erb'],
+ ['text/css', 'css'],
+ ['text/html', 'html'],
+ ['text/javascript', 'js'],
+ ['text/jsx', 'jsx'],
+ ['text/tsx', 'jsx'],
+ ['text/x-c', 'cpp'],
+ ['text/x-c++src', 'cpp'],
+ ['text/x-clojure', 'clojure'],
+ ['text/x-cmake', 'cmake'],
+ ['text/x-coffeescript', 'coffeescript'],
+ ['text/x-common-lisp', 'lisp'],
+ ['text/x-crystal', 'crystal'],
+ ['text/x-csharp', 'csharp'],
+ ['text/x-csrc', 'cpp'],
+ ['text/x-d', 'd'],
+ ['text/x-diff', 'diff'],
+ ['text/x-django', 'django'],
+ ['text/x-dockerfile', 'dockerfile'],
+ ['text/x-ebnf', 'ebnf'],
+ ['text/x-elm', 'elm'],
+ ['text/x-erlang', 'erlang'],
+ ['text/x-fortran', 'fortran'],
+ ['text/x-fsharp', 'fsharp'],
+ ['text/x-go', 'go'],
+ ['text/x-groovy', 'groovy'],
+ ['text/x-haml', 'haml'],
+ ['text/x-handlebars', 'handlebars'],
+ ['text/x-haskell', 'haskell'],
+ ['text/x-haxe', 'haxe'],
+ ['text/x-ini', 'ini'],
+ ['text/x-java', 'java'],
+ ['text/x-julia', 'julia'],
+ ['text/x-kotlin', 'kotlin'],
+ ['text/x-latex', 'latex'],
+ ['text/x-less', 'less'],
+ ['text/x-lua', 'lua'],
+ ['text/x-mathematica', 'mathematica'],
+ ['text/x-nginx-conf', 'nginx'],
+ ['text/x-nsis', 'nsis'],
+ ['text/x-objectivec', 'objectivec'],
+ ['text/x-ocaml', 'ocaml'],
+ ['text/x-perl', 'perl'],
+ ['text/x-pgsql', 'pgsql'], // postgresql
+ ['text/x-php', 'php'],
+ ['text/x-properties', 'properties'],
+ ['text/x-protobuf', 'protobuf'],
+ ['text/x-puppet', 'puppet'],
+ ['text/x-python', 'python'],
+ ['text/x-q', 'q'],
+ ['text/x-ruby', 'ruby'],
+ ['text/x-rustsrc', 'rust'],
+ ['text/x-scala', 'scala'],
+ ['text/x-scss', 'scss'],
+ ['text/x-scheme', 'scheme'],
+ ['text/x-shell', 'shell'],
+ ['text/x-soy', 'soy'],
+ ['text/x-spreadsheet', 'excel'],
+ ['text/x-sh', 'bash'],
+ ['text/x-sql', 'sql'],
+ ['text/x-swift', 'swift'],
+ ['text/x-systemverilog', 'sv'],
+ ['text/x-tcl', 'tcl'],
+ ['text/x-torque', 'torque'],
+ ['text/x-twig', 'twig'],
+ ['text/x-vb', 'vb'],
+ ['text/x-verilog', 'v'],
+ ['text/x-vhdl', 'vhdl'],
+ ['text/x-yaml', 'yaml'],
+ ['text/vbscript', 'vbscript'],
+]);
+const ASYNC_DELAY = 10;
+
+const CLASS_SAFELIST = new Set<string>([
+ 'gr-diff gr-syntax gr-syntax-attr',
+ 'gr-diff gr-syntax gr-syntax-attribute',
+ 'gr-diff gr-syntax gr-syntax-built_in',
+ 'gr-diff gr-syntax gr-syntax-comment',
+ 'gr-diff gr-syntax gr-syntax-doctag',
+ 'gr-diff gr-syntax gr-syntax-function',
+ 'gr-diff gr-syntax gr-syntax-keyword',
+ 'gr-diff gr-syntax gr-syntax-link',
+ 'gr-diff gr-syntax gr-syntax-literal',
+ 'gr-diff gr-syntax gr-syntax-meta',
+ 'gr-diff gr-syntax gr-syntax-meta-keyword',
+ 'gr-diff gr-syntax gr-syntax-name',
+ 'gr-diff gr-syntax gr-syntax-number',
+ 'gr-diff gr-syntax gr-syntax-params',
+ 'gr-diff gr-syntax gr-syntax-regexp',
+ 'gr-diff gr-syntax gr-syntax-selector-attr',
+ 'gr-diff gr-syntax gr-syntax-selector-class',
+ 'gr-diff gr-syntax gr-syntax-selector-id',
+ 'gr-diff gr-syntax gr-syntax-selector-pseudo',
+ 'gr-diff gr-syntax gr-syntax-selector-tag',
+ 'gr-diff gr-syntax gr-syntax-string',
+ 'gr-diff gr-syntax gr-syntax-tag',
+ 'gr-diff gr-syntax gr-syntax-template-tag',
+ 'gr-diff gr-syntax gr-syntax-template-variable',
+ 'gr-diff gr-syntax gr-syntax-title',
+ 'gr-diff gr-syntax gr-syntax-type',
+ 'gr-diff gr-syntax gr-syntax-variable',
+]);
+
+const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
+const CPP_WCHAR_PATTERN = /L'(\\)?.'/g;
+const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
+const GO_BACKSLASH_LITERAL = "'\\\\'";
+const GLOBAL_LT_PATTERN = /</g;
+
+interface SyntaxLayerRange {
+ start: number;
+ length: number;
+ className: string;
+}
+
+interface SyntaxLayerState {
+ sectionIndex: number;
+ lineIndex: number;
+ baseContext: unknown;
+ revisionContext: unknown;
+ lineNums: {left: number; right: number};
+ lastNotify: {left: number; right: number};
+}
+
+export interface GrSyntaxLayer {
+ $: {
+ libLoader: GrLibLoader;
+ };
+}
+
+@customElement('gr-syntax-layer')
+export class GrSyntaxLayer
+ extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+ implements DiffLayer {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object, observer: '_diffChanged'})
+ diff?: DiffInfo;
+
+ @property({type: Boolean})
+ enabled = true;
+
+ @property({type: Array})
+ _baseRanges: SyntaxLayerRange[][] = [];
+
+ @property({type: Array})
+ _revisionRanges: SyntaxLayerRange[][] = [];
+
+ @property({type: String})
+ _baseLanguage?: string;
+
+ @property({type: String})
+ _revisionLanguage?: string;
+
+ @property({type: Array})
+ _listeners: DiffLayerListener[] = [];
+
+ @property({type: Number})
+ _processHandle: number | null = null;
+
+ @property({type: Object})
+ _processPromise: CancelablePromise<unknown> | null = null;
+
+ @property({type: Object})
+ _hljs?: HighlightJS;
+
+ addListener(listener: DiffLayerListener) {
+ this.push('_listeners', listener);
+ }
+
+ removeListener(listener: DiffLayerListener) {
+ this._listeners = this._listeners.filter(f => f !== listener);
+ }
+
+ /**
+ * Annotation layer method to add syntax annotations to the given element
+ * for the given line.
+ */
+ annotate(el: HTMLElement, _: HTMLElement, line: GrDiffLine) {
+ if (!this.enabled) return;
+ if (line.beforeNumber === FILE) return;
+ if (line.afterNumber === FILE) return;
+
+ // Determine the side.
+ let side;
+ if (
+ line.type === GrDiffLineType.REMOVE ||
+ (line.type === GrDiffLineType.BOTH &&
+ el.getAttribute('data-side') !== 'right')
+ ) {
+ side = 'left';
+ } else if (
+ line.type === GrDiffLineType.ADD ||
+ el.getAttribute('data-side') !== 'left'
+ ) {
+ side = 'right';
+ }
+
+ // Find the relevant syntax ranges, if any.
+ let ranges: SyntaxLayerRange[] = [];
+ if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
+ ranges = this._baseRanges[line.beforeNumber - 1] || [];
+ } else if (
+ side === 'right' &&
+ this._revisionRanges.length >= line.afterNumber
+ ) {
+ ranges = this._revisionRanges[line.afterNumber - 1] || [];
+ }
+
+ // Apply the ranges to the element.
+ for (const range of ranges) {
+ GrAnnotation.annotateElement(
+ el,
+ range.start,
+ range.length,
+ range.className
+ );
+ }
+ }
+
+ _getLanguage(metaInfo: DiffFileMetaInfo) {
+ // The Gerrit API provides only content-type, but for other users of
+ // gr-diff it may be more convenient to specify the language directly.
+ return metaInfo.language ?? LANGUAGE_MAP.get(metaInfo.content_type);
+ }
+
+ /**
+ * Start processing syntax for the loaded diff and notify layer listeners
+ * as syntax info comes online.
+ */
+ process() {
+ // Cancel any still running process() calls, because they append to the
+ // same _baseRanges and _revisionRanges fields.
+ this.cancel();
+
+ // Discard existing ranges.
+ this._baseRanges = [];
+ this._revisionRanges = [];
+
+ if (!this.enabled || !this.diff?.content.length) {
+ return Promise.resolve();
+ }
+
+ if (this.diff.meta_a) {
+ this._baseLanguage = this._getLanguage(this.diff.meta_a);
+ }
+ if (this.diff.meta_b) {
+ this._revisionLanguage = this._getLanguage(this.diff.meta_b);
+ }
+ if (!this._baseLanguage && !this._revisionLanguage) {
+ return Promise.resolve();
+ }
+
+ const state: SyntaxLayerState = {
+ sectionIndex: 0,
+ lineIndex: 0,
+ baseContext: undefined,
+ revisionContext: undefined,
+ lineNums: {left: 1, right: 1},
+ lastNotify: {left: 1, right: 1},
+ };
+
+ const rangesCache = new Map();
+
+ this._processPromise = util.makeCancelable(
+ this._loadHLJS().then(
+ () =>
+ new Promise(resolve => {
+ const nextStep = () => {
+ this._processHandle = null;
+ this._processNextLine(state, rangesCache);
+
+ // Move to the next line in the section.
+ state.lineIndex++;
+
+ // If the section has been exhausted, move to the next one.
+ if (this._isSectionDone(state)) {
+ state.lineIndex = 0;
+ state.sectionIndex++;
+ }
+
+ // If all sections have been exhausted, finish.
+ if (
+ !this.diff ||
+ state.sectionIndex >= this.diff.content.length
+ ) {
+ resolve();
+ this._notify(state);
+ return;
+ }
+
+ if (state.lineIndex % 100 === 0) {
+ this._notify(state);
+ this._processHandle = this.async(nextStep, ASYNC_DELAY);
+ } else {
+ nextStep.call(this);
+ }
+ };
+
+ this._processHandle = this.async(nextStep, 1);
+ })
+ )
+ );
+ return this._processPromise.finally(() => {
+ this._processPromise = null;
+ });
+ }
+
+ /**
+ * Cancel any asynchronous syntax processing jobs.
+ */
+ cancel() {
+ if (this._processHandle !== null) {
+ this.cancelAsync(this._processHandle);
+ this._processHandle = null;
+ }
+ if (this._processPromise) {
+ this._processPromise.cancel();
+ }
+ }
+
+ _diffChanged() {
+ this.cancel();
+ this._baseRanges = [];
+ this._revisionRanges = [];
+ }
+
+ /**
+ * Take a string of HTML with the (potentially nested) syntax markers
+ * Highlight.js emits and emit a list of text ranges and classes for the
+ * markers.
+ *
+ * @param str The string of HTML.
+ * @param rangesCache A map for caching
+ * ranges for each string. A cache is read and written by this method.
+ * Since diff is mostly comparing same file on two sides, there is good rate
+ * of duplication at least for parts that are on left and right parts.
+ * @return The list of ranges.
+ */
+ _rangesFromString(
+ str: string,
+ rangesCache: Map<string, SyntaxLayerRange[]>
+ ): SyntaxLayerRange[] {
+ const cached = rangesCache.get(str);
+ if (cached) return cached;
+
+ const div = document.createElement('div');
+ div.innerHTML = str;
+ const ranges = this._rangesFromElement(div, 0);
+ rangesCache.set(str, ranges);
+ return ranges;
+ }
+
+ _rangesFromElement(elem: Element, offset: number): SyntaxLayerRange[] {
+ let result: SyntaxLayerRange[] = [];
+ for (const node of elem.childNodes) {
+ const nodeLength = GrAnnotation.getLength(node);
+ // Note: HLJS may emit a span with class undefined when it thinks there
+ // may be a syntax error.
+ if (
+ node instanceof Element &&
+ node.tagName === 'SPAN' &&
+ node.className !== 'undefined'
+ ) {
+ if (CLASS_SAFELIST.has(node.className)) {
+ result.push({
+ start: offset,
+ length: nodeLength,
+ className: node.className,
+ });
+ }
+ if (node.children.length) {
+ result = result.concat(this._rangesFromElement(node, offset));
+ }
+ }
+ offset += nodeLength;
+ }
+ return result;
+ }
+
+ /**
+ * For a given state, process the syntax for the next line (or pair of
+ * lines).
+ */
+ _processNextLine(
+ state: SyntaxLayerState,
+ rangesCache: Map<string, SyntaxLayerRange[]>
+ ) {
+ if (!this.diff) return;
+ if (!this._hljs) return;
+
+ let baseLine;
+ let revisionLine;
+ const section = this.diff.content[state.sectionIndex];
+ if (section.ab) {
+ baseLine = section.ab[state.lineIndex];
+ revisionLine = section.ab[state.lineIndex];
+ state.lineNums.left++;
+ state.lineNums.right++;
+ } else {
+ if (section.a && section.a.length > state.lineIndex) {
+ baseLine = section.a[state.lineIndex];
+ state.lineNums.left++;
+ }
+ if (section.b && section.b.length > state.lineIndex) {
+ revisionLine = section.b[state.lineIndex];
+ state.lineNums.right++;
+ }
+ }
+
+ // To store the result of the syntax highlighter.
+ let result;
+
+ if (
+ this._baseLanguage &&
+ baseLine !== undefined &&
+ this._hljs.getLanguage(this._baseLanguage)
+ ) {
+ baseLine = this._workaround(this._baseLanguage, baseLine);
+ result = this._hljs.highlight(
+ this._baseLanguage,
+ baseLine,
+ true,
+ state.baseContext
+ );
+ this.push(
+ '_baseRanges',
+ this._rangesFromString(result.value, rangesCache)
+ );
+ state.baseContext = result.top;
+ }
+
+ if (
+ this._revisionLanguage &&
+ revisionLine !== undefined &&
+ this._hljs.getLanguage(this._revisionLanguage)
+ ) {
+ revisionLine = this._workaround(this._revisionLanguage, revisionLine);
+ result = this._hljs.highlight(
+ this._revisionLanguage,
+ revisionLine,
+ true,
+ state.revisionContext
+ );
+ this.push(
+ '_revisionRanges',
+ this._rangesFromString(result.value, rangesCache)
+ );
+ state.revisionContext = result.top;
+ }
+ }
+
+ /**
+ * Ad hoc fixes for HLJS parsing bugs. Rewrite lines of code in constrained
+ * cases before sending them into HLJS so that they parse correctly.
+ *
+ * Important notes:
+ * * These tests should be as constrained as possible to avoid interfering
+ * with code it shouldn't AND to avoid executing regexes as much as
+ * possible.
+ * * These tests should document the issue clearly enough that the test can
+ * be condidently removed when the issue is solved in HLJS.
+ * * These tests should rewrite the line of code to have the same number of
+ * characters. This method rewrites the string that gets parsed, but NOT
+ * the string that gets displayed and highlighted. Thus, the positions
+ * must be consistent.
+ *
+ * @param language The name of the HLJS language plugin in use.
+ * @param line The line of code to potentially rewrite.
+ * @return A potentially-rewritten line of code.
+ */
+ _workaround(language: string, line: string) {
+ if (language === 'cpp') {
+ /**
+ * Prevent confusing < and << operators for the start of a meta string
+ * by converting them to a different operator.
+ * {@see Issue 4864}
+ * {@see https://github.com/isagalaev/highlight.js/issues/1341}
+ */
+ if (CPP_DIRECTIVE_WITH_LT_PATTERN.test(line)) {
+ line = line.replace(GLOBAL_LT_PATTERN, '|');
+ }
+
+ /**
+ * Rewrite CPP wchar_t characters literals to wchar_t string literals
+ * because HLJS only understands the string form.
+ * {@see Issue 5242}
+ * {#see https://github.com/isagalaev/highlight.js/issues/1412}
+ */
+ if (CPP_WCHAR_PATTERN.test(line)) {
+ line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
+ }
+
+ return line;
+ }
+
+ /**
+ * Prevent confusing the closing paren of a parameterized Java annotation
+ * being applied to a formal argument as the closing paren of the argument
+ * list. Rewrite the parens as spaces.
+ * {@see Issue 4776}
+ * {@see https://github.com/isagalaev/highlight.js/issues/1324}
+ */
+ if (language === 'java' && JAVA_PARAM_ANNOT_PATTERN.test(line)) {
+ return line.replace(JAVA_PARAM_ANNOT_PATTERN, '$1 $2 ');
+ }
+
+ /**
+ * HLJS misunderstands backslash character literals in Go.
+ * {@see Issue 5007}
+ * {#see https://github.com/isagalaev/highlight.js/issues/1411}
+ */
+ if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) {
+ return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
+ }
+
+ return line;
+ }
+
+ /**
+ * Tells whether the state has exhausted its current section.
+ */
+ _isSectionDone(state: SyntaxLayerState) {
+ if (!this.diff) return true;
+ const section = this.diff.content[state.sectionIndex];
+ if (section.ab) {
+ return state.lineIndex >= section.ab.length;
+ } else {
+ return (
+ (!section.a || state.lineIndex >= section.a.length) &&
+ (!section.b || state.lineIndex >= section.b.length)
+ );
+ }
+ }
+
+ /**
+ * For a given state, notify layer listeners of any processed line ranges
+ * that have not yet been notified.
+ */
+ _notify(state: SyntaxLayerState) {
+ if (state.lineNums.left - state.lastNotify.left) {
+ this._notifyRange(state.lastNotify.left, state.lineNums.left, Side.LEFT);
+ state.lastNotify.left = state.lineNums.left;
+ }
+ if (state.lineNums.right - state.lastNotify.right) {
+ this._notifyRange(
+ state.lastNotify.right,
+ state.lineNums.right,
+ Side.RIGHT
+ );
+ state.lastNotify.right = state.lineNums.right;
+ }
+ }
+
+ _notifyRange(start: number, end: number, side: Side) {
+ for (const listener of this._listeners) {
+ listener(start, end, side);
+ }
+ }
+
+ _loadHLJS() {
+ return this.$.libLoader.getHLJS().then(hljs => {
+ this._hljs = hljs;
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-syntax-layer': GrSyntaxLayer;
+ }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
index 03acfb5..6a2bbca 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.js
@@ -19,7 +19,7 @@
import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
import './gr-syntax-layer.js';
import {GrAnnotation} from '../gr-diff-highlight/gr-annotation.js';
-import {GrDiffLine} from '../gr-diff/gr-diff-line.js';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
const basicFixture = fixtureFromElement('gr-syntax-layer');
@@ -57,7 +57,7 @@
const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
const el = document.createElement('div');
el.textContent = 'Etiam dui, blandit wisi.';
- const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+ const line = new GrDiffLine(GrDiffLineType.REMOVE);
line.beforeNumber = 12;
element.annotate(el, lineNumberEl, line);
@@ -74,7 +74,7 @@
const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
const el = document.createElement('div');
el.textContent = str;
- const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+ const line = new GrDiffLine(GrDiffLineType.REMOVE);
line.beforeNumber = 12;
element._baseRanges[11] = [{
start,
@@ -101,7 +101,7 @@
const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
const el = document.createElement('div');
el.textContent = str;
- const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+ const line = new GrDiffLine(GrDiffLineType.REMOVE);
line.beforeNumber = 12;
element._baseRanges[11] = [{
start,
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
deleted file mode 100644
index 8e7bd2d..0000000
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.js
+++ /dev/null
@@ -1,97 +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 '../../../styles/gr-table-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-list-view/gr-list-view.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-documentation-search_html.js';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-
-/**
- * @extends PolymerElement
- */
-class GrDocumentationSearch extends ListViewMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-documentation-search'; }
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
-
- _path: {
- type: String,
- readOnly: true,
- value: '/Documentation',
- },
- _documentationSearches: Array,
-
- _loading: {
- type: Boolean,
- value: true,
- },
- _filter: {
- type: String,
- value: '',
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.dispatchEvent(
- new CustomEvent('title-change', {title: 'Documentation Search'}));
- }
-
- _paramsChanged(params) {
- this._loading = true;
- this._filter = this.getFilterValue(params);
-
- return this._getDocumentationSearches(this._filter);
- }
-
- _getDocumentationSearches(filter) {
- this._documentationSearches = [];
- return this.$.restAPI.getDocumentationSearches(filter)
- .then(searches => {
- // Late response.
- if (filter !== this._filter || !searches) { return; }
- this._documentationSearches = searches;
- this._loading = false;
- });
- }
-
- _computeSearchUrl(url) {
- if (!url) { return ''; }
- return getBaseUrl() + '/' + url;
- }
-}
-
-customElements.define(GrDocumentationSearch.is, GrDocumentationSearch);
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
new file mode 100644
index 0000000..5967b03
--- /dev/null
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search.ts
@@ -0,0 +1,101 @@
+/**
+ * @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 '../../../styles/gr-table-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-list-view/gr-list-view';
+import '../../shared/gr-rest-api-interface/gr-rest-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-documentation-search_html';
+import {
+ ListViewMixin,
+ ListViewParams,
+} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
+import {getBaseUrl} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {DocResult} from '../../../types/common';
+
+export interface GrDocumentationSearch {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-documentation-search')
+export class GrDocumentationSearch extends ListViewMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * URL params passed from the router.
+ */
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: ListViewParams;
+
+ @property({type: Array})
+ _documentationSearches?: DocResult[];
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: String})
+ _filter = '';
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.dispatchEvent(
+ new CustomEvent('title-change', {detail: {title: 'Documentation Search'}})
+ );
+ }
+
+ _paramsChanged(params: ListViewParams) {
+ this._loading = true;
+ this._filter = this.getFilterValue(params);
+
+ return this._getDocumentationSearches(this._filter);
+ }
+
+ _getDocumentationSearches(filter: string) {
+ this._documentationSearches = [];
+ return this.$.restAPI.getDocumentationSearches(filter).then(searches => {
+ // Late response.
+ if (filter !== this._filter || !searches) {
+ return;
+ }
+ this._documentationSearches = searches;
+ this._loading = false;
+ });
+ }
+
+ _computeSearchUrl(url?: string) {
+ if (!url) {
+ return '';
+ }
+ return `${getBaseUrl()}/${url}`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-documentation-search': GrDocumentationSearch;
+ }
+}
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
index de0a990..9f5aae3 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_html.ts
@@ -28,7 +28,7 @@
items="false"
offset="0"
loading="[[_loading]]"
- path="[[_path]]"
+ path="/Documentation"
>
<table id="list" class="genericList">
<tbody>
diff --git a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
index c2a3f3d..ce80f2f 100644
--- a/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
+++ b/polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
@@ -17,7 +17,8 @@
import '../../../test/common-test-setup-karma.js';
import './gr-documentation-search.js';
-import page from 'page/page.mjs';
+import {page} from '../../../utils/page-wrapper-utils.js';
+import 'lodash/lodash.js';
const basicFixture = fixtureFromElement('gr-documentation-search');
@@ -94,7 +95,7 @@
element._loading = false;
element._repos = _.times(25, documentationGenerator);
- flushAsynchronousOperations();
+ flush();
assert.equal(element.computeLoadingClass(element._loading), '');
assert.equal(getComputedStyle(element.$.loading).display, 'none');
});
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
deleted file mode 100644
index 9acfd72..0000000
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.js
+++ /dev/null
@@ -1,49 +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/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-default-editor_html.js';
-
-/** @extends PolymerElement */
-class GrDefaultEditor extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-default-editor'; }
- /**
- * Fired when the content of the editor changes.
- *
- * @event content-change
- */
-
- static get properties() {
- return {
- fileContent: String,
- };
- }
-
- _handleTextareaInput(e) {
- this.dispatchEvent(new CustomEvent(
- 'content-change',
- {detail: {value: e.target.value}, bubbles: true, composed: true}));
- }
-}
-
-customElements.define(GrDefaultEditor.is, GrDefaultEditor);
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
new file mode 100644
index 0000000..4c63303
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -0,0 +1,61 @@
+/**
+ * @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/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-default-editor_html';
+import {customElement, property} from '@polymer/decorators';
+
+export interface GrDefaultEditor {
+ $: {};
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-default-editor': GrDefaultEditor;
+ }
+}
+
+@customElement('gr-default-editor')
+/** @extends PolymerElement */
+export class GrDefaultEditor extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the content of the editor changes.
+ *
+ * @event content-change
+ */
+
+ @property({type: String})
+ fileContent: string | null = null;
+
+ _handleTextareaInput(e: Event) {
+ this.dispatchEvent(
+ new CustomEvent('content-change', {
+ detail: {value: (e.target as HTMLTextAreaElement).value},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.js b/polygerrit-ui/app/elements/edit/gr-edit-constants.js
deleted file mode 100644
index 7282a46..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-constants.js
+++ /dev/null
@@ -1,27 +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.
- */
-
-export const GrEditConstants = {
-// Order corresponds to order in the UI.
- Actions: {
- OPEN: {label: 'Add/Open/Upload', id: 'open'},
- DELETE: {label: 'Delete', id: 'delete'},
- RENAME: {label: 'Rename', id: 'rename'},
- RESTORE: {label: 'Restore', id: 'restore'},
- },
-};
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-constants.ts b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
new file mode 100644
index 0000000..af3fbb2
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-constants.ts
@@ -0,0 +1,31 @@
+/**
+ * @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.
+ */
+
+export interface GrEditAction {
+ label: string;
+ id: string;
+}
+
+export const GrEditConstants = {
+ // Order corresponds to order in the UI.
+ Actions: {
+ OPEN: {label: 'Add/Open/Upload', id: 'open'},
+ DELETE: {label: 'Delete', id: 'delete'},
+ RENAME: {label: 'Rename', id: 'rename'},
+ RESTORE: {label: 'Restore', id: 'restore'},
+ },
+};
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
deleted file mode 100644
index 86a2e61..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.js
+++ /dev/null
@@ -1,301 +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-input/iron-input.js';
-import '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dialog/gr-dialog.js';
-import '../../shared/gr-dropdown/gr-dropdown.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-edit-controls_html.js';
-import {GrEditConstants} from '../gr-edit-constants.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/**
- * @extends PolymerElement
- */
-class GrEditControls extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-edit-controls'; }
-
- static get properties() {
- return {
- change: Object,
- patchNum: String,
-
- /**
- * TODO(kaspern): by default, the RESTORE action should be hidden in the
- * file-list as it is a per-file action only. Remove this default value
- * when the Actions dictionary is moved to a shared constants file and
- * use the hiddenActions property in the parent component.
- */
- hiddenActions: {
- type: Array,
- value() { return [GrEditConstants.Actions.RESTORE.id]; },
- },
-
- _actions: {
- type: Array,
- value() { return Object.values(GrEditConstants.Actions); },
- },
- _path: {
- type: String,
- value: '',
- },
- _newPath: {
- type: String,
- value: '',
- },
- _query: {
- type: Function,
- value() {
- return this._queryFiles.bind(this);
- },
- },
- };
- }
-
- _handleTap(e) {
- e.preventDefault();
- const action = dom(e).localTarget.id;
- switch (action) {
- case GrEditConstants.Actions.OPEN.id:
- this.openOpenDialog();
- return;
- case GrEditConstants.Actions.DELETE.id:
- this.openDeleteDialog();
- return;
- case GrEditConstants.Actions.RENAME.id:
- this.openRenameDialog();
- return;
- case GrEditConstants.Actions.RESTORE.id:
- this.openRestoreDialog();
- return;
- }
- }
-
- /**
- * @param {string=} opt_path
- */
- openOpenDialog(opt_path) {
- if (opt_path) { this._path = opt_path; }
- return this._showDialog(this.$.openDialog);
- }
-
- /**
- * @param {string=} opt_path
- */
- openDeleteDialog(opt_path) {
- if (opt_path) { this._path = opt_path; }
- return this._showDialog(this.$.deleteDialog);
- }
-
- /**
- * @param {string=} opt_path
- */
- openRenameDialog(opt_path) {
- if (opt_path) { this._path = opt_path; }
- return this._showDialog(this.$.renameDialog);
- }
-
- /**
- * @param {string=} opt_path
- */
- openRestoreDialog(opt_path) {
- if (opt_path) { this._path = opt_path; }
- return this._showDialog(this.$.restoreDialog);
- }
-
- /**
- * Given a path string, checks that it is a valid file path.
- *
- * @param {string} path
- * @return {boolean}
- */
- _isValidPath(path) {
- // Double negation needed for strict boolean return type.
- return !!path.length && !path.endsWith('/');
- }
-
- _computeRenameDisabled(path, newPath) {
- return this._isValidPath(path) && this._isValidPath(newPath);
- }
-
- /**
- * Given a dom event, gets the dialog that lies along this event path.
- *
- * @param {!Event} e
- * @return {!Element|undefined}
- */
- _getDialogFromEvent(e) {
- return dom(e).path.find(element => {
- if (!element.classList) { return false; }
- return element.classList.contains('dialog');
- });
- }
-
- _showDialog(dialog) {
- // Some dialogs may not fire their on-close event when closed in certain
- // ways (e.g. by clicking outside the dialog body). This call prevents
- // multiple dialogs from being shown in the same overlay.
- this._hideAllDialogs();
-
- return this.$.overlay.open().then(() => {
- dialog.classList.toggle('invisible', false);
- const autocomplete = dialog.querySelector('gr-autocomplete');
- if (autocomplete) { autocomplete.focus(); }
- this.async(() => { this.$.overlay.center(); }, 1);
- });
- }
-
- _hideAllDialogs() {
- const dialogs = dom(this.root).querySelectorAll('.dialog');
- for (const dialog of dialogs) { this._closeDialog(dialog); }
- }
-
- /**
- * @param {Element|undefined} dialog
- * @param {boolean=} clearInputs
- */
- _closeDialog(dialog, clearInputs) {
- if (!dialog) { return; }
-
- if (clearInputs) {
- // Dialog may have autocompletes and plain inputs -- as these have
- // different properties representing their bound text, it is easier to
- // just make two separate queries.
- dialog.querySelectorAll('gr-autocomplete')
- .forEach(input => { input.text = ''; });
-
- dialog.querySelectorAll('iron-input')
- .forEach(input => { input.bindValue = ''; });
- }
-
- dialog.classList.toggle('invisible', true);
- return this.$.overlay.close();
- }
-
- _handleDialogCancel(e) {
- this._closeDialog(this._getDialogFromEvent(e));
- }
-
- _handleOpenConfirm(e) {
- const url = GerritNav.getEditUrlForDiff(this.change, this._path,
- this.patchNum);
- GerritNav.navigateToRelativeUrl(url);
- this._closeDialog(this._getDialogFromEvent(e), true);
- }
-
- _handleUploadConfirm(path, fileData) {
- if (!this.change || !path || !fileData) {
- this._closeDialog(this.$.openDialog, true);
- return;
- }
- return this.$.restAPI.saveFileUploadChangeEdit(this.change._number, path,
- fileData).then(res => {
- if (!res.ok) { return; }
- this._closeDialog(this.$.openDialog, true);
- GerritNav.navigateToChange(this.change);
- });
- }
-
- _handleDeleteConfirm(e) {
- // Get the dialog before the api call as the event will change during bubbling
- // which will make Polymer.dom(e).path an empty array in polymer 2
- const dialog = this._getDialogFromEvent(e);
- this.$.restAPI.deleteFileInChangeEdit(this.change._number, this._path)
- .then(res => {
- if (!res.ok) { return; }
- this._closeDialog(dialog, true);
- GerritNav.navigateToChange(this.change);
- });
- }
-
- _handleRestoreConfirm(e) {
- const dialog = this._getDialogFromEvent(e);
- this.$.restAPI.restoreFileInChangeEdit(this.change._number, this._path)
- .then(res => {
- if (!res.ok) { return; }
- this._closeDialog(dialog, true);
- GerritNav.navigateToChange(this.change);
- });
- }
-
- _handleRenameConfirm(e) {
- const dialog = this._getDialogFromEvent(e);
- return this.$.restAPI.renameFileInChangeEdit(this.change._number,
- this._path, this._newPath).then(res => {
- if (!res.ok) { return; }
- this._closeDialog(dialog, true);
- GerritNav.navigateToChange(this.change);
- });
- }
-
- _queryFiles(input) {
- return this.$.restAPI.queryChangeFiles(this.change._number,
- this.patchNum, input).then(res => res.map(file => {
- return {name: file};
- }));
- }
-
- _computeIsInvisible(id, hiddenActions) {
- return hiddenActions.includes(id) ? 'invisible' : '';
- }
-
- _handleDragAndDropUpload(event) {
- // We prevent the default clicking.
- event.preventDefault();
- event.stopPropagation();
-
- this._fileUpload(event);
- }
-
- _handleFileUploadChanged(event) {
- this._fileUpload(event);
- }
-
- _fileUpload(event) {
- const e = event.target.files || event.dataTransfer.files;
- for (const file of e) {
- if (!file) continue;
-
- let path = this._path;
- if (!path) {
- path = file.name;
- }
-
- const fr = new FileReader();
- fr.file = file;
- fr.onload = fileLoadEvent => {
- if (!fileLoadEvent) return;
- const fileData = fileLoadEvent.target.result;
- this._handleUploadConfirm(path, fileData);
- };
- fr.readAsDataURL(file);
- }
- }
-}
-
-customElements.define(GrEditControls.is, GrEditControls);
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
new file mode 100644
index 0000000..cf011bb
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls.ts
@@ -0,0 +1,334 @@
+/**
+ * @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-input/iron-input';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-dialog/gr-dialog';
+import '../../shared/gr-dropdown/gr-dropdown';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-edit-controls_html';
+import {GrEditAction, GrEditConstants} from '../gr-edit-constants';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ AutocompleteQuery,
+ AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+
+export interface GrEditControls {
+ $: {
+ restAPI: RestApiService & Element;
+ overlay: GrOverlay;
+ openDialog: GrDialog;
+ deleteDialog: GrDialog;
+ renameDialog: GrDialog;
+ restoreDialog: GrDialog;
+ };
+}
+
+@customElement('gr-edit-controls')
+export class GrEditControls extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ change!: ChangeInfo;
+
+ @property({type: String})
+ patchNum!: PatchSetNum;
+
+ @property({type: Array})
+ hiddenActions: string[] = [GrEditConstants.Actions.RESTORE.id];
+
+ @property({type: Array})
+ _actions: GrEditAction[] = Object.values(GrEditConstants.Actions);
+
+ @property({type: String})
+ _path = '';
+
+ @property({type: String})
+ _newPath = '';
+
+ @property({type: Object})
+ _query: AutocompleteQuery;
+
+ constructor() {
+ super();
+ this._query = (input: string) => this._queryFiles(input);
+ }
+
+ _handleTap(e: Event) {
+ e.preventDefault();
+ const target = (dom(e) as EventApi).localTarget as Element;
+ const action = target.id;
+ switch (action) {
+ case GrEditConstants.Actions.OPEN.id:
+ this.openOpenDialog();
+ return;
+ case GrEditConstants.Actions.DELETE.id:
+ this.openDeleteDialog();
+ return;
+ case GrEditConstants.Actions.RENAME.id:
+ this.openRenameDialog();
+ return;
+ case GrEditConstants.Actions.RESTORE.id:
+ this.openRestoreDialog();
+ return;
+ }
+ }
+
+ openOpenDialog(path?: string) {
+ if (path) {
+ this._path = path;
+ }
+ return this._showDialog(this.$.openDialog);
+ }
+
+ openDeleteDialog(path?: string) {
+ if (path) {
+ this._path = path;
+ }
+ return this._showDialog(this.$.deleteDialog);
+ }
+
+ openRenameDialog(path?: string) {
+ if (path) {
+ this._path = path;
+ }
+ return this._showDialog(this.$.renameDialog);
+ }
+
+ openRestoreDialog(path?: string) {
+ if (path) {
+ this._path = path;
+ }
+ return this._showDialog(this.$.restoreDialog);
+ }
+
+ /**
+ * Given a path string, checks that it is a valid file path.
+ */
+ _isValidPath(path: string) {
+ // Double negation needed for strict boolean return type.
+ return !!path.length && !path.endsWith('/');
+ }
+
+ _computeRenameDisabled(path: string, newPath: string) {
+ return this._isValidPath(path) && this._isValidPath(newPath);
+ }
+
+ /**
+ * Given a dom event, gets the dialog that lies along this event path.
+ */
+ _getDialogFromEvent(e: Event): GrDialog | undefined {
+ return (dom(e) as EventApi).path.find(element => {
+ if (!(element instanceof Element)) return false;
+ if (!element.classList) return false;
+ return element.classList.contains('dialog');
+ }) as GrDialog | undefined;
+ }
+
+ _showDialog(dialog: GrDialog) {
+ // Some dialogs may not fire their on-close event when closed in certain
+ // ways (e.g. by clicking outside the dialog body). This call prevents
+ // multiple dialogs from being shown in the same overlay.
+ this._hideAllDialogs();
+
+ return this.$.overlay.open().then(() => {
+ dialog.classList.toggle('invisible', false);
+ const autocomplete = dialog.querySelector('gr-autocomplete');
+ if (autocomplete) {
+ autocomplete.focus();
+ }
+ this.async(() => {
+ this.$.overlay.center();
+ }, 1);
+ });
+ }
+
+ _hideAllDialogs() {
+ const dialogs = this.root!.querySelectorAll('.dialog') as NodeListOf<
+ GrDialog
+ >;
+ for (const dialog of dialogs) {
+ this._closeDialog(dialog);
+ }
+ }
+
+ _closeDialog(dialog?: GrDialog, clearInputs = false) {
+ if (!dialog) return;
+
+ if (clearInputs) {
+ // Dialog may have autocompletes and plain inputs -- as these have
+ // different properties representing their bound text, it is easier to
+ // just make two separate queries.
+ dialog.querySelectorAll('gr-autocomplete').forEach(input => {
+ input.text = '';
+ });
+
+ dialog.querySelectorAll('iron-input').forEach(input => {
+ input.bindValue = '';
+ });
+ }
+
+ dialog.classList.toggle('invisible', true);
+ return this.$.overlay.close();
+ }
+
+ _handleDialogCancel(e: Event) {
+ this._closeDialog(this._getDialogFromEvent(e));
+ }
+
+ _handleOpenConfirm(e: Event) {
+ const url = GerritNav.getEditUrlForDiff(
+ this.change,
+ this._path,
+ this.patchNum
+ );
+ GerritNav.navigateToRelativeUrl(url);
+ this._closeDialog(this._getDialogFromEvent(e), true);
+ }
+
+ _handleUploadConfirm(path: string, fileData: string) {
+ if (!this.change || !path || !fileData) {
+ this._closeDialog(this.$.openDialog, true);
+ return;
+ }
+ return this.$.restAPI
+ .saveFileUploadChangeEdit(this.change._number, path, fileData)
+ .then(res => {
+ if (!res || !res.ok) {
+ return;
+ }
+ this._closeDialog(this.$.openDialog, true);
+ GerritNav.navigateToChange(this.change);
+ });
+ }
+
+ _handleDeleteConfirm(e: Event) {
+ // Get the dialog before the api call as the event will change during bubbling
+ // which will make Polymer.dom(e).path an empty array in polymer 2
+ const dialog = this._getDialogFromEvent(e);
+ this.$.restAPI
+ .deleteFileInChangeEdit(this.change._number, this._path)
+ .then(res => {
+ if (!res || !res.ok) {
+ return;
+ }
+ this._closeDialog(dialog, true);
+ GerritNav.navigateToChange(this.change);
+ });
+ }
+
+ _handleRestoreConfirm(e: Event) {
+ const dialog = this._getDialogFromEvent(e);
+ this.$.restAPI
+ .restoreFileInChangeEdit(this.change._number, this._path)
+ .then(res => {
+ if (!res || !res.ok) {
+ return;
+ }
+ this._closeDialog(dialog, true);
+ GerritNav.navigateToChange(this.change);
+ });
+ }
+
+ _handleRenameConfirm(e: Event) {
+ const dialog = this._getDialogFromEvent(e);
+ return this.$.restAPI
+ .renameFileInChangeEdit(this.change._number, this._path, this._newPath)
+ .then(res => {
+ if (!res || !res.ok) {
+ return;
+ }
+ this._closeDialog(dialog, true);
+ GerritNav.navigateToChange(this.change);
+ });
+ }
+
+ _queryFiles(input: string): Promise<AutocompleteSuggestion[]> {
+ return this.$.restAPI
+ .queryChangeFiles(this.change._number, this.patchNum, input)
+ .then(res => {
+ if (!res) throw new Error('Failed to retrieve files. Reponse not set.');
+ return res.map(file => {
+ return {name: file};
+ });
+ });
+ }
+
+ _computeIsInvisible(id: string, hiddenActions: string[]) {
+ return hiddenActions.includes(id) ? 'invisible' : '';
+ }
+
+ _handleDragAndDropUpload(event: DragEvent) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (!event.dataTransfer) return;
+ this._fileUpload(event.dataTransfer.files);
+ }
+
+ _handleFileUploadChanged(event: InputEvent) {
+ if (!event.target) return;
+ if (!(event.target instanceof HTMLInputElement)) return;
+ const input = event.target as HTMLInputElement;
+ if (!input.files) return;
+ this._fileUpload(input.files);
+ }
+
+ _fileUpload(files: FileList) {
+ for (const file of files) {
+ if (!file) continue;
+
+ let path = this._path;
+ if (!path) {
+ path = file.name;
+ }
+
+ const fr = new FileReader();
+ // TODO(TS): Do we need this line?
+ // fr.file = file;
+ fr.onload = (fileLoadEvent: ProgressEvent<FileReader>) => {
+ if (!fileLoadEvent) return;
+ const fileData = fileLoadEvent.target!.result;
+ if (typeof fileData !== 'string') return;
+ this._handleUploadConfirm(path, fileData);
+ };
+ fr.readAsDataURL(file);
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-edit-controls': GrEditControls;
+ }
+}
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
index 8269b81..98d1545 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
@@ -17,9 +17,9 @@
import '../../../test/common-test-setup-karma.js';
import './gr-edit-controls.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils.js';
const basicFixture = fixtureFromElement('gr-edit-controls');
@@ -29,8 +29,10 @@
let showDialogSpy;
let closeDialogSpy;
let queryStub;
+ let ironOverlayBackdropStyleEl;
setup(() => {
+ ironOverlayBackdropStyleEl = createIronOverlayBackdropStyleEl();
element = basicFixture.instantiate();
element.change = {_number: '42'};
showDialogSpy = sinon.spy(element, '_showDialog');
@@ -38,14 +40,18 @@
sinon.stub(element, '_hideAllDialogs');
queryStub = sinon.stub(element.$.restAPI, 'queryChangeFiles')
.returns(Promise.resolve([]));
- flushAsynchronousOperations();
+ flush();
+ });
+
+ teardown(() => {
+ ironOverlayBackdropStyleEl.remove();
});
test('all actions exist', () => {
// We take 1 away from the total found, due to an extra button being
// added for the file uploads (browse).
assert.equal(
- dom(element.root).querySelectorAll('gr-button').length - 1,
+ element.root.querySelectorAll('gr-button').length - 1,
element._actions.length);
});
@@ -135,7 +141,7 @@
assert.isFalse(element.$.deleteDialog.disabled);
MockInteractions.tap(element.$.deleteDialog.shadowRoot
.querySelector('gr-button[primary]'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(deleteStub.called);
@@ -162,7 +168,7 @@
assert.isFalse(element.$.deleteDialog.disabled);
MockInteractions.tap(element.$.deleteDialog.shadowRoot
.querySelector('gr-button[primary]'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(deleteStub.called);
@@ -224,7 +230,7 @@
assert.isFalse(element.$.renameDialog.disabled);
MockInteractions.tap(element.$.renameDialog.shadowRoot
.querySelector('gr-button[primary]'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(renameStub.called);
@@ -256,7 +262,7 @@
assert.isFalse(element.$.renameDialog.disabled);
MockInteractions.tap(element.$.renameDialog.shadowRoot
.querySelector('gr-button[primary]'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(renameStub.called);
@@ -307,7 +313,7 @@
return showDialogSpy.lastCall.returnValue.then(() => {
MockInteractions.tap(element.$.restoreDialog.shadowRoot
.querySelector('gr-button[primary]'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(restoreStub.called);
assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
@@ -326,7 +332,7 @@
return showDialogSpy.lastCall.returnValue.then(() => {
MockInteractions.tap(element.$.restoreDialog.shadowRoot
.querySelector('gr-button[primary]'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(restoreStub.called);
assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
@@ -396,20 +402,20 @@
element.addEventListener('tap', element._getDialogFromEvent);
MockInteractions.tap(element.$.openDialog);
- flushAsynchronousOperations();
+ flush();
assert.equal(spy.lastCall.returnValue.id, 'openDialog');
MockInteractions.tap(element.$.deleteDialog);
- flushAsynchronousOperations();
+ flush();
assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
MockInteractions.tap(
element.$.deleteDialog.querySelector('gr-autocomplete'));
- flushAsynchronousOperations();
+ flush();
assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
MockInteractions.tap(element);
- flushAsynchronousOperations();
+ flush();
assert.notOk(spy.lastCall.returnValue);
});
});
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
deleted file mode 100644
index b144bad..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.js
+++ /dev/null
@@ -1,76 +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 '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-dropdown/gr-dropdown.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-edit-file-controls_html.js';
-import {GrEditConstants} from '../gr-edit-constants.js';
-
-/** @extends PolymerElement */
-class GrEditFileControls extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-edit-file-controls'; }
- /**
- * Fired when an action in the overflow menu is tapped.
- *
- * @event file-action-tap
- */
-
- static get properties() {
- return {
- filePath: String,
- _allFileActions: {
- type: Array,
- value: () => Object.values(GrEditConstants.Actions),
- },
- _fileActions: {
- type: Array,
- computed: '_computeFileActions(_allFileActions)',
- },
- };
- }
-
- _handleActionTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this._dispatchFileAction(e.detail.id, this.filePath);
- }
-
- _dispatchFileAction(action, path) {
- this.dispatchEvent(new CustomEvent(
- 'file-action-tap',
- {detail: {action, path}, bubbles: true, composed: true}));
- }
-
- _computeFileActions(actions) {
- // TODO(kaspern): conditionally disable some actions based on file status.
- return actions.map(action => {
- return {
- name: action.label,
- id: action.id,
- };
- });
- }
-}
-
-customElements.define(GrEditFileControls.is, GrEditFileControls);
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
new file mode 100644
index 0000000..9f3d1bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -0,0 +1,87 @@
+/**
+ * @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 '../../shared/gr-button/gr-button';
+import '../../shared/gr-dropdown/gr-dropdown';
+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-edit-file-controls_html';
+import {GrEditConstants} from '../gr-edit-constants';
+import {customElement, property} from '@polymer/decorators';
+
+interface EditAction {
+ label: string;
+ id: string;
+}
+
+/** @extends PolymerElement */
+@customElement('gr-edit-file-controls')
+class GrEditFileControls extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when an action in the overflow menu is tapped.
+ *
+ * @event file-action-tap
+ */
+
+ @property({type: String})
+ filePath?: string;
+
+ @property({type: Array})
+ _allFileActions = Object.values(GrEditConstants.Actions);
+
+ @property({type: Array, computed: '_computeFileActions(_allFileActions)'})
+ _fileActions?: EditAction[];
+
+ _handleActionTap(e: CustomEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ this._dispatchFileAction(e.detail.id, this.filePath);
+ }
+
+ _dispatchFileAction(action: EditAction, path?: string) {
+ this.dispatchEvent(
+ new CustomEvent('file-action-tap', {
+ detail: {action, path},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ _computeFileActions(actions: EditAction[]) {
+ // TODO(kaspern): conditionally disable some actions based on file status.
+ return actions.map(action => {
+ return {
+ name: action.label,
+ id: action.id,
+ };
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-edit-file-controls': GrEditFileControls;
+ }
+}
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
index 8a1c186..180a3a4 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
@@ -37,7 +37,7 @@
const actions = element.$.actions;
element.filePath = 'foo';
actions._open();
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(actions.shadowRoot
.querySelector('li [data-id="open"]'));
@@ -50,7 +50,7 @@
const actions = element.$.actions;
element.filePath = 'foo';
actions._open();
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(actions.shadowRoot
.querySelector('li [data-id="delete"]'));
@@ -63,7 +63,7 @@
const actions = element.$.actions;
element.filePath = 'foo';
actions._open();
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(actions.shadowRoot
.querySelector('li [data-id="restore"]'));
@@ -76,7 +76,7 @@
const actions = element.$.actions;
element.filePath = 'foo';
actions._open();
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(actions.shadowRoot
.querySelector('li [data-id="rename"]'));
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
deleted file mode 100644
index d1677bd..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.js
+++ /dev/null
@@ -1,281 +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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../../plugins/gr-endpoint-param/gr-endpoint-param.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-editable-label/gr-editable-label.js';
-import '../../shared/gr-fixed-panel/gr-fixed-panel.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-storage/gr-storage.js';
-import '../gr-default-editor/gr-default-editor.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-editor-view_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
-import {computeTruncatedPath} from '../../../utils/path-list-util.js';
-
-const RESTORED_MESSAGE = 'Content restored from a previous edit.';
-const SAVING_MESSAGE = 'Saving changes...';
-const SAVED_MESSAGE = 'All changes saved';
-const SAVE_FAILED_MSG = 'Failed to save changes';
-
-const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
-
-/**
- * @extends PolymerElement
- */
-class GrEditorView extends KeyboardShortcutMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-editor-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
- * Fired to notify the user of
- *
- * @event show-alert
- */
-
- static get properties() {
- return {
- /**
- * URL params passed from the router.
- */
- params: {
- type: Object,
- observer: '_paramsChanged',
- },
-
- _change: Object,
- _changeEditDetail: Object,
- _changeNum: String,
- _patchNum: String,
- _path: String,
- _type: String,
- _content: String,
- _newContent: String,
- _saving: {
- type: Boolean,
- value: false,
- },
- _successfulSave: {
- type: Boolean,
- value: false,
- },
- _saveDisabled: {
- type: Boolean,
- value: true,
- computed: '_computeSaveDisabled(_content, _newContent, _saving)',
- },
- _prefs: Object,
- _lineNum: Number,
- };
- }
-
- get keyBindings() {
- return {
- 'ctrl+s meta+s': '_handleSaveShortcut',
- };
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('content-change',
- e => this._handleContentChange(e));
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getEditPrefs().then(prefs => { this._prefs = prefs; });
- }
-
- get storageKey() {
- return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _getEditPrefs() {
- return this.$.restAPI.getEditPreferences();
- }
-
- _paramsChanged(value) {
- if (value.view !== GerritNav.View.EDIT) {
- return;
- }
-
- this._changeNum = value.changeNum;
- this._path = value.path;
- this._patchNum = value.patchNum || SPECIAL_PATCH_SET_NUM.EDIT;
- this._lineNum = value.lineNum;
-
- // NOTE: This may be called before attachment (e.g. while parentElement is
- // null). Fire title-change in an async so that, if attachment to the DOM
- // has been queued, the event can bubble up to the handler in gr-app.
- this.async(() => {
- const title = `Editing ${computeTruncatedPath(this._path)}`;
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title},
- composed: true, bubbles: true,
- }));
- });
-
- const promises = [];
-
- promises.push(this._getChangeDetail(this._changeNum));
- promises.push(
- this._getFileData(this._changeNum, this._path, this._patchNum));
- return Promise.all(promises);
- }
-
- _getChangeDetail(changeNum) {
- return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
- this._change = change;
- });
- }
-
- _handlePathChanged(e) {
- const path = e.detail;
- if (path === this._path) {
- return Promise.resolve();
- }
- return this.$.restAPI.renameFileInChangeEdit(this._changeNum,
- this._path, path).then(res => {
- if (!res.ok) { return; }
-
- this._successfulSave = true;
- this._viewEditInChangeView();
- });
- }
-
- _viewEditInChangeView() {
- const patch = this._successfulSave ? SPECIAL_PATCH_SET_NUM.EDIT
- : this._patchNum;
- GerritNav.navigateToChange(this._change, patch, null,
- patch !== SPECIAL_PATCH_SET_NUM.EDIT);
- }
-
- _getFileData(changeNum, path, patchNum) {
- const storedContent =
- this.$.storage.getEditableContentItem(this.storageKey);
-
- return this.$.restAPI.getFileContent(changeNum, path, patchNum)
- .then(res => {
- if (storedContent && storedContent.message &&
- storedContent.message !== res.content) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: RESTORED_MESSAGE},
- bubbles: true,
- composed: true,
- }));
-
- this._newContent = storedContent.message;
- } else {
- this._newContent = res.content || '';
- }
- this._content = res.content || '';
-
- // A non-ok response may result if the file does not yet exist.
- // The `type` field of the response is only valid when the file
- // already exists.
- if (res.ok && res.type) {
- this._type = res.type;
- } else {
- this._type = '';
- }
- });
- }
-
- _saveEdit() {
- this._saving = true;
- this._showAlert(SAVING_MESSAGE);
- this.$.storage.eraseEditableContentItem(this.storageKey);
- return this.$.restAPI.saveChangeEdit(this._changeNum, this._path,
- this._newContent).then(res => {
- this._saving = false;
- this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
- if (!res.ok) { return; }
-
- this._content = this._newContent;
- this._successfulSave = true;
- });
- }
-
- _showAlert(message) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message},
- bubbles: true,
- composed: true,
- }));
- }
-
- _computeSaveDisabled(content, newContent, saving) {
- // Polymer 2: check for undefined
- if ([
- content,
- newContent,
- saving,
- ].includes(undefined)) {
- return true;
- }
-
- if (saving) {
- return true;
- }
- return content === newContent;
- }
-
- _handleCloseTap() {
- // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
- this._viewEditInChangeView();
- }
-
- _handleContentChange(e) {
- this.debounce('store', () => {
- const content = e.detail.value;
- if (content) {
- this.set('_newContent', e.detail.value);
- this.$.storage.setEditableContentItem(this.storageKey, content);
- } else {
- this.$.storage.eraseEditableContentItem(this.storageKey);
- }
- }, STORAGE_DEBOUNCE_INTERVAL_MS);
- }
-
- _handleSaveShortcut(e) {
- e.preventDefault();
- if (!this._saveDisabled) {
- this._saveEdit();
- }
- }
-}
-
-customElements.define(GrEditorView.is, GrEditorView);
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
new file mode 100644
index 0000000..a0562de
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view.ts
@@ -0,0 +1,400 @@
+/**
+ * @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 '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-editable-label/gr-editable-label';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-storage/gr-storage';
+import '../gr-default-editor/gr-default-editor';
+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-editor-view_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+ GerritNav,
+ GenerateUrlEditViewParameters,
+} from '../../core/gr-navigation/gr-navigation';
+import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util';
+import {computeTruncatedPath} from '../../../utils/path-list-util';
+import {customElement, property} from '@polymer/decorators';
+import {
+ RestApiService,
+ ErrorCallback,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ ChangeInfo,
+ PatchSetNum,
+ EditPreferencesInfo,
+ Base64FileContent,
+ NumericChangeId,
+} from '../../../types/common';
+import {GrStorage} from '../../shared/gr-storage/gr-storage';
+import {HttpMethod, NotifyType} from '../../../constants/constants';
+
+const RESTORED_MESSAGE = 'Content restored from a previous edit.';
+const SAVING_MESSAGE = 'Saving changes...';
+const SAVED_MESSAGE = 'All changes saved';
+const SAVE_FAILED_MSG = 'Failed to save changes';
+const PUBLISHING_EDIT_MSG = 'Publishing edit...';
+const PUBLISH_FAILED_MSG = 'Failed to publish edit';
+
+const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
+
+export interface GrEditorView {
+ $: {
+ restAPI: RestApiService & Element;
+ storage: GrStorage;
+ };
+}
+@customElement('gr-editor-view')
+export class GrEditorView extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ /**
+ * Fired to notify the user of
+ *
+ * @event show-alert
+ */
+
+ @property({type: Object, observer: '_paramsChanged'})
+ params?: GenerateUrlEditViewParameters;
+
+ @property({type: Object})
+ _change?: ChangeInfo | null;
+
+ @property({type: Number})
+ _changeNum?: NumericChangeId;
+
+ @property({type: String})
+ _patchNum?: PatchSetNum;
+
+ @property({type: String})
+ _path?: string;
+
+ @property({type: String})
+ _type?: string;
+
+ @property({type: String})
+ _content?: string;
+
+ @property({type: String})
+ _newContent?: string;
+
+ @property({type: Boolean})
+ _saving = false;
+
+ @property({type: Boolean})
+ _successfulSave = false;
+
+ @property({
+ type: Boolean,
+ computed: '_computeSaveDisabled(_content, _newContent, _saving)',
+ })
+ _saveDisabled = true;
+
+ @property({type: Object})
+ _prefs?: EditPreferencesInfo;
+
+ @property({type: Number})
+ _lineNum?: number;
+
+ get keyBindings() {
+ return {
+ 'ctrl+s meta+s': '_handleSaveShortcut',
+ };
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('content-change', e => {
+ this._handleContentChange(e as CustomEvent<{value: string}>);
+ });
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getEditPrefs().then(prefs => {
+ this._prefs = prefs;
+ });
+ }
+
+ get storageKey() {
+ return `c${this._changeNum}_ps${this._patchNum}_${this._path}`;
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _getEditPrefs() {
+ return this.$.restAPI.getEditPreferences();
+ }
+
+ _paramsChanged(value: GenerateUrlEditViewParameters) {
+ if (value.view !== GerritNav.View.EDIT) {
+ return;
+ }
+
+ this._changeNum = value.changeNum;
+ this._path = value.path;
+ this._patchNum =
+ value.patchNum || (SPECIAL_PATCH_SET_NUM.EDIT as PatchSetNum);
+ this._lineNum =
+ typeof value.lineNum === 'string' ? Number(value.lineNum) : value.lineNum;
+
+ // NOTE: This may be called before attachment (e.g. while parentElement is
+ // null). Fire title-change in an async so that, if attachment to the DOM
+ // has been queued, the event can bubble up to the handler in gr-app.
+ this.async(() => {
+ const title = `Editing ${computeTruncatedPath(value.path)}`;
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+
+ const promises = [];
+
+ promises.push(this._getChangeDetail(this._changeNum));
+ promises.push(
+ this._getFileData(this._changeNum, this._path, this._patchNum)
+ );
+ return Promise.all(promises);
+ }
+
+ _getChangeDetail(changeNum: NumericChangeId) {
+ return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+ this._change = change;
+ });
+ }
+
+ _handlePathChanged(e: CustomEvent<string>) {
+ // TODO(TS) could be cleand up, it was added for type requirements
+ if (this._changeNum === undefined || !this._path) {
+ return Promise.reject(new Error('changeNum or path undefined'));
+ }
+ const path = e.detail;
+ if (path === this._path) {
+ return Promise.resolve();
+ }
+ return this.$.restAPI
+ .renameFileInChangeEdit(this._changeNum, this._path, path)
+ .then(res => {
+ if (!res || !res.ok) {
+ return;
+ }
+
+ this._successfulSave = true;
+ this._viewEditInChangeView();
+ });
+ }
+
+ _viewEditInChangeView() {
+ const patch = this._successfulSave
+ ? (SPECIAL_PATCH_SET_NUM.EDIT as PatchSetNum)
+ : this._patchNum;
+ if (this._change && patch)
+ GerritNav.navigateToChange(
+ this._change,
+ patch,
+ undefined,
+ patch !== SPECIAL_PATCH_SET_NUM.EDIT
+ );
+ }
+
+ _getFileData(
+ changeNum: NumericChangeId,
+ path: string,
+ patchNum?: PatchSetNum
+ ) {
+ if (patchNum === undefined) {
+ return Promise.reject(new Error('patchNum undefined'));
+ }
+ const storedContent = this.$.storage.getEditableContentItem(
+ this.storageKey
+ );
+
+ return this.$.restAPI
+ .getFileContent(changeNum, path, patchNum)
+ .then(res => {
+ const content = (res && (res as Base64FileContent).content) || '';
+ if (
+ storedContent &&
+ storedContent.message &&
+ storedContent.message !== content
+ ) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: RESTORED_MESSAGE},
+ bubbles: true,
+ composed: true,
+ })
+ );
+
+ this._newContent = storedContent.message;
+ } else {
+ this._newContent = content;
+ }
+ this._content = content;
+
+ // A non-ok response may result if the file does not yet exist.
+ // The `type` field of the response is only valid when the file
+ // already exists.
+ if (res && res.ok && res.type) {
+ this._type = res.type;
+ } else {
+ this._type = '';
+ }
+ });
+ }
+
+ _saveEdit() {
+ if (this._changeNum === undefined || !this._path) {
+ return Promise.reject(new Error('changeNum or path undefined'));
+ }
+ this._saving = true;
+ this._showAlert(SAVING_MESSAGE);
+ this.$.storage.eraseEditableContentItem(this.storageKey);
+ if (!this._newContent)
+ return Promise.reject(new Error('new content undefined'));
+ return this.$.restAPI
+ .saveChangeEdit(this._changeNum, this._path, this._newContent)
+ .then(res => {
+ this._saving = false;
+ this._showAlert(res.ok ? SAVED_MESSAGE : SAVE_FAILED_MSG);
+ if (!res.ok) {
+ return res;
+ }
+
+ this._content = this._newContent;
+ this._successfulSave = true;
+ return res;
+ });
+ }
+
+ _showAlert(message: string) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ _computeSaveDisabled(
+ content?: string,
+ newContent?: string,
+ saving?: boolean
+ ) {
+ // Polymer 2: check for undefined
+ if ([content, newContent, saving].includes(undefined)) {
+ return true;
+ }
+
+ if (saving) {
+ return true;
+ }
+ return content === newContent;
+ }
+
+ _handleCloseTap() {
+ // TODO(kaspern): Add a confirm dialog if there are unsaved changes.
+ this._viewEditInChangeView();
+ }
+
+ _handleSaveTap() {
+ this._saveEdit().then(res => {
+ if (res.ok) this._viewEditInChangeView();
+ });
+ }
+
+ _handlePublishTap() {
+ if (!this._changeNum) throw new Error('missing changeNum');
+
+ const changeNum = this._changeNum;
+ this._saveEdit().then(() => {
+ const handleError: ErrorCallback = response => {
+ this._showAlert(PUBLISH_FAILED_MSG);
+ console.error(response);
+ };
+
+ this._showAlert(PUBLISHING_EDIT_MSG);
+
+ this.$.restAPI
+ .executeChangeAction(
+ changeNum,
+ HttpMethod.POST,
+ '/edit:publish',
+ undefined,
+ {notify: NotifyType.NONE},
+ handleError
+ )
+ .then(() => {
+ if (!this._change) throw new Error('missing change');
+ GerritNav.navigateToChange(this._change);
+ });
+ });
+ }
+
+ _handleContentChange(e: CustomEvent<{value: string}>) {
+ this.debounce(
+ 'store',
+ () => {
+ const content = e.detail.value;
+ if (content) {
+ this.set('_newContent', e.detail.value);
+ this.$.storage.setEditableContentItem(this.storageKey, content);
+ } else {
+ this.$.storage.eraseEditableContentItem(this.storageKey);
+ }
+ },
+ STORAGE_DEBOUNCE_INTERVAL_MS
+ );
+ }
+
+ _handleSaveShortcut(e: KeyboardEvent) {
+ e.preventDefault();
+ if (!this._saveDisabled) {
+ this._saveEdit();
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-editor-view': GrEditorView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
index bd8304f..1e05232 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_html.ts
@@ -21,9 +21,11 @@
:host {
background-color: var(--view-background-color);
}
- gr-fixed-panel {
+ .stickyHeader {
background-color: var(--edit-mode-background-color);
border-bottom: 1px var(--border-color) solid;
+ position: sticky;
+ top: 0;
z-index: 1;
}
header,
@@ -76,7 +78,7 @@
}
}
</style>
- <gr-fixed-panel keep-on-scroll="">
+ <div class="stickyHeader">
<header>
<span class="controlGroup">
<span>Edit mode</span>
@@ -90,19 +92,29 @@
</span>
<span class="controlGroup rightControls">
<gr-button id="close" link="" on-click="_handleCloseTap"
- >Close</gr-button
+ >Cancel</gr-button
>
<gr-button
id="save"
disabled$="[[_saveDisabled]]"
primary=""
link=""
- on-click="_saveEdit"
+ title="Save and Close the file"
+ on-click="_handleSaveTap"
>Save</gr-button
>
+ <gr-button
+ id="publish"
+ link=""
+ primary=""
+ title="Publish your edit. A new patchset will be created."
+ on-click="_handlePublishTap"
+ disabled$="[[_saveDisabled]]"
+ >Save & Publish</gr-button
+ >
</span>
</header>
- </gr-fixed-panel>
+ </div>
<div class="textareaWrapper">
<gr-endpoint-decorator id="editorEndpoint" name="editor">
<gr-endpoint-param
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
index 21a61aa..b04277e 100644
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
@@ -19,6 +19,7 @@
import './gr-editor-view.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {SPECIAL_PATCH_SET_NUM} from '../../../utils/patch-set-util.js';
+import {HttpMethod} from '../../../constants/constants.js';
const basicFixture = fixtureFromElement('gr-editor-view');
@@ -51,7 +52,7 @@
suite('_paramsChanged', () => {
test('incorrect view returns immediately', () => {
element._paramsChanged(
- Object.assign({}, mockParams, {view: GerritNav.View.DIFF}));
+ {...mockParams, view: GerritNav.View.DIFF});
assert.notOk(element._changeNum);
});
@@ -64,9 +65,9 @@
});
const promises = element._paramsChanged(
- Object.assign({}, mockParams, {view: GerritNav.View.EDIT}));
+ {...mockParams, view: GerritNav.View.EDIT});
- flushAsynchronousOperations();
+ flush();
assert.equal(element._changeNum, mockParams.changeNum);
assert.equal(element._path, mockParams.path);
assert.deepEqual(changeDetailStub.lastCall.args[0],
@@ -112,7 +113,7 @@
detail: {value: 'new content value'},
}));
element.flushDebouncer('store');
- flushAsynchronousOperations();
+ flush();
assert.equal(element._newContent, 'new content value');
assert.isTrue(storeStub.called);
@@ -128,7 +129,7 @@
element._path = mockParams.path;
element._content = originalText;
element._newContent = originalText;
- flushAsynchronousOperations();
+ flush();
});
test('initial load', () => {
@@ -143,7 +144,7 @@
const alertStub = sinon.stub(element, '_showAlert');
saveFileStub.returns(Promise.resolve({ok: false}));
element._newContent = newText;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.save.hasAttribute('disabled'));
assert.isFalse(element._saving);
@@ -172,7 +173,7 @@
const alertStub = sinon.stub(element, '_showAlert');
saveFileStub.returns(Promise.resolve({ok: true}));
element._newContent = newText;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element._saving);
assert.isFalse(element.$.save.hasAttribute('disabled'));
@@ -187,17 +188,54 @@
assert.isTrue(saveFileStub.called);
assert.isFalse(element._saving);
assert.equal(alertStub.lastCall.args[0], 'All changes saved');
- assert.isFalse(navigateStub.called);
assert.isTrue(element.$.save.hasAttribute('disabled'));
assert.equal(element._content, element._newContent);
assert.isTrue(element._successfulSave);
+ assert.isTrue(navigateStub.called);
+ });
+ });
+
+ test('file modification and publish', () => {
+ const saveSpy = sinon.spy(element, '_saveEdit');
+ const alertStub = sinon.stub(element, '_showAlert');
+ const changeActionsStub =
+ sinon.stub(element.$.restAPI, 'executeChangeAction');
+ saveFileStub.returns(Promise.resolve({ok: true}));
+ element._newContent = newText;
+ flush();
+
+ assert.isFalse(element._saving);
+ assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+ MockInteractions.tap(element.$.publish);
+ assert.isTrue(saveSpy.called);
+ assert.equal(alertStub.getCall(0).args[0], 'Saving changes...');
+ assert.isTrue(element._saving);
+ assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+ return saveSpy.lastCall.returnValue.then(() => {
+ assert.isTrue(saveFileStub.called);
+ assert.isFalse(element._saving);
+
+ assert.equal(alertStub.getCall(1).args[0], 'All changes saved');
+ assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...');
+
+ assert.isTrue(element.$.save.hasAttribute('disabled'));
+ assert.equal(element._content, element._newContent);
+ assert.isTrue(element._successfulSave);
+ assert.isFalse(navigateStub.called);
+
+ const args = changeActionsStub.lastCall.args;
+ assert.equal(args[0], '42');
+ assert.equal(args[1], HttpMethod.POST);
+ assert.equal(args[2], '/edit:publish');
});
});
test('file modification and close', () => {
const closeSpy = sinon.spy(element, '_handleCloseTap');
element._newContent = newText;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.save.hasAttribute('disabled'));
@@ -283,6 +321,7 @@
});
test('_viewEditInChangeView respects _patchNum', () => {
+ element._change = {};
navigateStub.restore();
const navStub = sinon.stub(GerritNav, 'navigateToChange');
element._patchNum = SPECIAL_PATCH_SET_NUM.EDIT;
@@ -311,13 +350,13 @@
element._content = '';
element._newContent = '_test';
MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(handleSpy.calledOnce);
assert.isTrue(saveStub.calledOnce);
MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
- flushAsynchronousOperations();
+ flush();
assert.equal(handleSpy.callCount, 2);
assert.equal(saveStub.callCount, 2);
@@ -325,13 +364,13 @@
test('save disabled', () => {
MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(handleSpy.calledOnce);
assert.isFalse(saveStub.called);
MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
- flushAsynchronousOperations();
+ flush();
assert.equal(handleSpy.callCount, 2);
assert.isFalse(saveStub.called);
@@ -354,7 +393,7 @@
element.addEventListener('show-alert', alertStub);
return element._getFileData(1, 'test', 1).then(() => {
- flushAsynchronousOperations();
+ flush();
assert.isTrue(alertStub.called);
assert.equal(element._newContent, 'pending edit');
@@ -377,7 +416,7 @@
element.addEventListener('show-alert', alertStub);
return element._getFileData(1, 'test', 1).then(() => {
- flushAsynchronousOperations();
+ flush();
assert.isFalse(alertStub.called);
assert.equal(element._newContent, 'pending edit');
diff --git a/polygerrit-ui/app/elements/font-roboto-local-loader.js b/polygerrit-ui/app/elements/font-roboto-local-loader.js
deleted file mode 100644
index 7000d13..0000000
--- a/polygerrit-ui/app/elements/font-roboto-local-loader.js
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-// Place all code related to font-roboto-local here
-import '@polymer/font-roboto-local/roboto.js';
-
diff --git a/polygerrit-ui/app/elements/font-roboto-local-loader.ts b/polygerrit-ui/app/elements/font-roboto-local-loader.ts
new file mode 100644
index 0000000..1be72d2
--- /dev/null
+++ b/polygerrit-ui/app/elements/font-roboto-local-loader.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * 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.
+ */
+
+// Place all code related to font-roboto-local here
+import '@polymer/font-roboto-local/roboto';
diff --git a/polygerrit-ui/app/elements/gr-app-element.js b/polygerrit-ui/app/elements/gr-app-element.js
deleted file mode 100644
index 3ee67ae..0000000
--- a/polygerrit-ui/app/elements/gr-app-element.js
+++ /dev/null
@@ -1,607 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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/shared-styles.js';
-import '../styles/themes/app-theme.js';
-import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme.js';
-import './admin/gr-admin-view/gr-admin-view.js';
-import './documentation/gr-documentation-search/gr-documentation-search.js';
-import './change-list/gr-change-list-view/gr-change-list-view.js';
-import './change-list/gr-dashboard-view/gr-dashboard-view.js';
-import './change/gr-change-view/gr-change-view.js';
-import './core/gr-error-manager/gr-error-manager.js';
-import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js';
-import './core/gr-main-header/gr-main-header.js';
-import './core/gr-router/gr-router.js';
-import './core/gr-smart-search/gr-smart-search.js';
-import './diff/gr-diff-view/gr-diff-view.js';
-import './edit/gr-editor-view/gr-editor-view.js';
-import './plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import './plugins/gr-endpoint-param/gr-endpoint-param.js';
-import './plugins/gr-endpoint-slot/gr-endpoint-slot.js';
-import './plugins/gr-external-style/gr-external-style.js';
-import './plugins/gr-plugin-host/gr-plugin-host.js';
-import './settings/gr-cla-view/gr-cla-view.js';
-import './settings/gr-registration-dialog/gr-registration-dialog.js';
-import './settings/gr-settings-view/gr-settings-view.js';
-import './shared/gr-fixed-panel/gr-fixed-panel.js';
-import './shared/gr-lib-loader/gr-lib-loader.js';
-import './shared/gr-rest-api-interface/gr-rest-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-app-element_html.js';
-import {getBaseUrl} from '../utils/url-util.js';
-import {
- KeyboardShortcutMixin,
- Shortcut,
- SPECIAL_SHORTCUT,
-} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {GerritNav} from './core/gr-navigation/gr-navigation.js';
-import {appContext} from '../services/app-context.js';
-
-/**
- * @extends PolymerElement
- */
-class GrAppElement extends KeyboardShortcutMixin(
- GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-app-element'; }
- /**
- * Fired when the URL location changes.
- *
- * @event location-change
- */
-
- static get properties() {
- return {
- /**
- * @type {{ query: string, view: string, screen: string }}
- */
- params: Object,
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
-
- _account: {
- type: Object,
- observer: '_accountChanged',
- },
-
- /**
- * The last time the g key was pressed in milliseconds (or a keydown event
- * was handled if the key is held down).
- *
- * @type {number|null}
- */
- _lastGKeyPressTimestamp: {
- type: Number,
- value: null,
- },
-
- /**
- * @type {{ plugin: Object }}
- */
- _serverConfig: Object,
- _version: String,
- _showChangeListView: Boolean,
- _showDashboardView: Boolean,
- _showChangeView: Boolean,
- _showDiffView: Boolean,
- _showSettingsView: Boolean,
- _showAdminView: Boolean,
- _showCLAView: Boolean,
- _showEditorView: Boolean,
- _showPluginScreen: Boolean,
- _showDocumentationSearch: Boolean,
- /** @type {?} */
- _viewState: Object,
- /** @type {?} */
- _lastError: Object,
- _lastSearchPage: String,
- _path: String,
- _pluginScreenName: {
- type: String,
- computed: '_computePluginScreenName(params)',
- },
- _settingsUrl: String,
- _feedbackUrl: String,
- // Used to allow searching on mobile
- mobileSearch: {
- type: Boolean,
- value: false,
- },
-
- /**
- * Other elements in app must open this URL when
- * user login is required.
- */
- _loginUrl: {
- type: String,
- value: '/login',
- },
- };
- }
-
- static get observers() {
- return [
- '_viewChanged(params.view)',
- '_paramsChanged(params.*)',
- ];
- }
-
- keyboardShortcuts() {
- return {
- [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
- [Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
- [Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
- [Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
- [Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
- [Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- created() {
- super.created();
- this._bindKeyboardShortcuts();
- this.addEventListener('page-error',
- e => this._handlePageError(e));
- this.addEventListener('title-change',
- e => this._handleTitleChange(e));
- this.addEventListener('location-change',
- e => this._handleLocationChange(e));
- this.addEventListener('rpc-log',
- e => this._handleRpcLog(e));
- this.addEventListener('shortcut-triggered',
- e => this._handleShortcutTriggered(e));
- }
-
- /** @override */
- ready() {
- super.ready();
- this._updateLoginUrl();
- this.reporting.appStarted();
- this.$.router.start();
-
- this.$.restAPI.getAccount().then(account => {
- this._account = account;
- const role = account ? 'user' : 'guest';
- this.reporting.reportLifeCycle(`Started as ${role}`);
- });
- this.$.restAPI.getConfig().then(config => {
- this._serverConfig = config;
-
- if (config && config.gerrit && config.gerrit.report_bug_url) {
- this._feedbackUrl = config.gerrit.report_bug_url;
- }
- });
- this.$.restAPI.getVersion().then(version => {
- this._version = version;
- this._logWelcome();
- });
-
- if (window.localStorage.getItem('dark-theme')) {
- applyDarkTheme();
- }
-
- // Note: this is evaluated here to ensure that it only happens after the
- // router has been initialized. @see Issue 7837
- this._settingsUrl = GerritNav.getUrlForSettings();
-
- this._viewState = {
- changeView: {
- changeNum: null,
- patchRange: null,
- selectedFileIndex: 0,
- showReplyDialog: false,
- diffMode: null,
- numFilesShown: null,
- scrollTop: 0,
- },
- changeListView: {
- query: null,
- offset: 0,
- selectedChangeIndex: 0,
- },
- dashboardView: {
- selectedChangeIndex: 0,
- },
- };
- }
-
- _bindKeyboardShortcuts() {
- this.bindShortcut(Shortcut.SEND_REPLY,
- SPECIAL_SHORTCUT.DOC_ONLY, 'ctrl+enter', 'meta+enter');
- this.bindShortcut(Shortcut.EMOJI_DROPDOWN,
- SPECIAL_SHORTCUT.DOC_ONLY, ':');
-
- this.bindShortcut(
- Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
- this.bindShortcut(
- Shortcut.GO_TO_USER_DASHBOARD, SPECIAL_SHORTCUT.GO_KEY, 'i');
- this.bindShortcut(
- Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'o');
- this.bindShortcut(
- Shortcut.GO_TO_MERGED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'm');
- this.bindShortcut(
- Shortcut.GO_TO_ABANDONED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'a');
- this.bindShortcut(
- Shortcut.GO_TO_WATCHED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'w');
-
- this.bindShortcut(
- Shortcut.CURSOR_NEXT_CHANGE, 'j');
- this.bindShortcut(
- Shortcut.CURSOR_PREV_CHANGE, 'k');
- this.bindShortcut(
- Shortcut.OPEN_CHANGE, 'o');
- this.bindShortcut(
- Shortcut.NEXT_PAGE, 'n', ']');
- this.bindShortcut(
- Shortcut.PREV_PAGE, 'p', '[');
- this.bindShortcut(
- Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
- this.bindShortcut(
- Shortcut.TOGGLE_CHANGE_STAR, 's:keyup');
- this.bindShortcut(
- Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
- this.bindShortcut(
- Shortcut.EDIT_TOPIC, 't');
-
- this.bindShortcut(
- Shortcut.OPEN_REPLY_DIALOG, 'a:keyup');
- this.bindShortcut(
- Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup');
- this.bindShortcut(
- Shortcut.EXPAND_ALL_MESSAGES, 'x');
- this.bindShortcut(
- Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
- this.bindShortcut(
- Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
- this.bindShortcut(
- Shortcut.UP_TO_DASHBOARD, 'u');
- this.bindShortcut(
- Shortcut.UP_TO_CHANGE, 'u');
- this.bindShortcut(
- Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
- this.bindShortcut(
- Shortcut.DIFF_AGAINST_BASE, SPECIAL_SHORTCUT.V_KEY, 'down', 's');
- this.bindShortcut(
- Shortcut.DIFF_AGAINST_LATEST, SPECIAL_SHORTCUT.V_KEY, 'up', 'w');
- this.bindShortcut(
- Shortcut.DIFF_BASE_AGAINST_LEFT,
- SPECIAL_SHORTCUT.V_KEY, 'left', 'a');
- this.bindShortcut(
- Shortcut.DIFF_RIGHT_AGAINST_LATEST,
- SPECIAL_SHORTCUT.V_KEY, 'right', 'd');
- this.bindShortcut(
- Shortcut.DIFF_BASE_AGAINST_LATEST, SPECIAL_SHORTCUT.V_KEY, 'b');
-
- this.bindShortcut(
- Shortcut.NEXT_LINE, 'j', 'down');
- this.bindShortcut(
- Shortcut.PREV_LINE, 'k', 'up');
- if (this._isCursorManagerSupportMoveToVisibleLine()) {
- this.bindShortcut(
- Shortcut.VISIBLE_LINE, '.');
- }
- this.bindShortcut(
- Shortcut.NEXT_CHUNK, 'n');
- this.bindShortcut(
- Shortcut.PREV_CHUNK, 'p');
- this.bindShortcut(
- Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
- this.bindShortcut(
- Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
- this.bindShortcut(
- Shortcut.PREV_COMMENT_THREAD, 'shift+p');
- this.bindShortcut(
- Shortcut.EXPAND_ALL_COMMENT_THREADS,
- SPECIAL_SHORTCUT.DOC_ONLY, 'e');
- this.bindShortcut(
- Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
- SPECIAL_SHORTCUT.DOC_ONLY, 'shift+e');
- this.bindShortcut(
- Shortcut.LEFT_PANE, 'shift+left');
- this.bindShortcut(
- Shortcut.RIGHT_PANE, 'shift+right');
- this.bindShortcut(
- Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
- this.bindShortcut(
- Shortcut.NEW_COMMENT, 'c');
- this.bindShortcut(
- Shortcut.SAVE_COMMENT,
- 'ctrl+enter', 'meta+enter', 'ctrl+s', 'meta+s');
- this.bindShortcut(
- Shortcut.OPEN_DIFF_PREFS, ',');
- this.bindShortcut(
- Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
-
- this.bindShortcut(
- Shortcut.NEXT_FILE, ']');
- this.bindShortcut(
- Shortcut.PREV_FILE, '[');
- this.bindShortcut(
- Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
- this.bindShortcut(
- Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
- this.bindShortcut(
- Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
- this.bindShortcut(
- Shortcut.CURSOR_PREV_FILE, 'k', 'up');
- this.bindShortcut(
- Shortcut.OPEN_FILE, 'o', 'enter');
- this.bindShortcut(
- Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
- this.bindShortcut(
- Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
- this.bindShortcut(
- Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
- this.bindShortcut(
- Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
- this.bindShortcut(
- Shortcut.TOGGLE_BLAME, 'b');
- this.bindShortcut(
- Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
-
- this.bindShortcut(
- Shortcut.OPEN_FIRST_FILE, ']');
- this.bindShortcut(
- Shortcut.OPEN_LAST_FILE, '[');
-
- this.bindShortcut(
- Shortcut.SEARCH, '/');
- }
-
- _isCursorManagerSupportMoveToVisibleLine() {
- // This method is a copy-paste from the
- // method _isIntersectionObserverSupported of gr-cursor-manager.js
- // It is better share this method with gr-cursor-manager,
- // but doing it require a lot if changes instead of 1-line copied code
- return 'IntersectionObserver' in window;
- }
-
- _accountChanged(account) {
- if (!account) { return; }
-
- // Preferences are cached when a user is logged in; warm them.
- this.$.restAPI.getPreferences();
- this.$.restAPI.getDiffPreferences();
- this.$.restAPI.getEditPreferences();
- this.$.errorManager.knownAccountId =
- this._account && this._account._account_id || null;
- }
-
- _viewChanged(view) {
- this.$.errorView.classList.remove('show');
- this.set('_showChangeListView', view === GerritNav.View.SEARCH);
- this.set('_showDashboardView', view === GerritNav.View.DASHBOARD);
- this.set('_showChangeView', view === GerritNav.View.CHANGE);
- this.set('_showDiffView', view === GerritNav.View.DIFF);
- this.set('_showSettingsView', view === GerritNav.View.SETTINGS);
- this.set('_showAdminView', view === GerritNav.View.ADMIN ||
- view === GerritNav.View.GROUP || view === GerritNav.View.REPO);
- this.set('_showCLAView', view === GerritNav.View.AGREEMENTS);
- this.set('_showEditorView', view === GerritNav.View.EDIT);
- const isPluginScreen = view === GerritNav.View.PLUGIN_SCREEN;
- this.set('_showPluginScreen', false);
- // Navigation within plugin screens does not restamp gr-endpoint-decorator
- // because _showPluginScreen value does not change. To force restamp,
- // change _showPluginScreen value between true and false.
- if (isPluginScreen) {
- this.async(() => this.set('_showPluginScreen', true), 1);
- }
- this.set('_showDocumentationSearch',
- view === GerritNav.View.DOCUMENTATION_SEARCH);
- if (this.params.justRegistered) {
- this.$.registrationOverlay.open();
- this.$.registrationDialog.loadData().then(() => {
- this.$.registrationOverlay.refit();
- });
- }
- this.$.header.unfloat();
- }
-
- _handleShortcutTriggered(event) {
- const {event: e, goKey, vKey} = event.detail;
- // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
- let key = `${e.key}:${e.type}`;
- if (goKey) key = 'g+' + key;
- if (vKey) key = 'v+' + key;
- if (e.shiftKey) key = 'shift+' + key;
- if (e.ctrlKey) key = 'ctrl+' + key;
- if (e.metaKey) key = 'meta+' + key;
- if (e.altKey) key = 'alt+' + key;
- this.reporting.reportInteraction('shortcut-triggered', {
- key,
- from: event.path && event.path[0]
- && event.path[0].nodeName || 'unknown',
- });
- }
-
- _handlePageError(e) {
- const props = [
- '_showChangeListView',
- '_showDashboardView',
- '_showChangeView',
- '_showDiffView',
- '_showSettingsView',
- '_showAdminView',
- ];
- for (const showProp of props) {
- this.set(showProp, false);
- }
-
- this.$.errorView.classList.add('show');
- const response = e.detail.response;
- const err = {text: [response.status, response.statusText].join(' ')};
- if (response.status === 404) {
- err.emoji = '¯\\_(ツ)_/¯';
- this._lastError = err;
- } else {
- err.emoji = 'o_O';
- response.text().then(text => {
- err.moreInfo = text;
- this._lastError = err;
- });
- }
- }
-
- _handleLocationChange(e) {
- this._updateLoginUrl();
-
- const hash = e.detail.hash.substring(1);
- let pathname = e.detail.pathname;
- if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
- pathname += '@' + hash;
- }
- this.set('_path', pathname);
- }
-
- _updateLoginUrl() {
- const baseUrl = getBaseUrl();
- if (baseUrl) {
- // Strip the canonical path from the path since needing canonical in
- // the path is unneeded and breaks the url.
- this._loginUrl = baseUrl + '/login/' + encodeURIComponent(
- '/' + window.location.pathname.substring(baseUrl.length) +
- window.location.search +
- window.location.hash);
- } else {
- this._loginUrl = '/login/' + encodeURIComponent(
- window.location.pathname +
- window.location.search +
- window.location.hash);
- }
- }
-
- _paramsChanged(paramsRecord) {
- const params = paramsRecord.base;
- const viewsToCheck = [GerritNav.View.SEARCH, GerritNav.View.DASHBOARD];
- if (viewsToCheck.includes(params.view)) {
- this.set('_lastSearchPage', location.pathname);
- }
- }
-
- _handleTitleChange(e) {
- if (e.detail.title) {
- document.title = e.detail.title + ' · Gerrit Code Review';
- } else {
- document.title = '';
- }
- }
-
- handleShowKeyboardShortcuts() {
- this.$.keyboardShortcuts.open();
- }
-
- _showKeyboardShortcuts(e) {
- // same shortcut should close the dialog if pressed again
- // when dialog is open
- if (this.$.keyboardShortcuts.opened) {
- this.$.keyboardShortcuts.close();
- return;
- }
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
- this.$.keyboardShortcuts.open();
- }
-
- _handleKeyboardShortcutDialogClose() {
- this.$.keyboardShortcuts.close();
- }
-
- _handleAccountDetailUpdate(e) {
- this.$.mainHeader.reload();
- if (this.params.view === GerritNav.View.SETTINGS) {
- this.shadowRoot.querySelector('gr-settings-view').reloadAccountDetail();
- }
- }
-
- _handleRegistrationDialogClose(e) {
- this.params.justRegistered = false;
- this.$.registrationOverlay.close();
- }
-
- _goToOpenedChanges() {
- GerritNav.navigateToStatusSearch('open');
- }
-
- _goToUserDashboard() {
- GerritNav.navigateToUserDashboard();
- }
-
- _goToMergedChanges() {
- GerritNav.navigateToStatusSearch('merged');
- }
-
- _goToAbandonedChanges() {
- GerritNav.navigateToStatusSearch('abandoned');
- }
-
- _goToWatchedChanges() {
- // The query is hardcoded, and doesn't respect custom menu entries
- GerritNav.navigateToSearchQuery('is:watched is:open');
- }
-
- _computePluginScreenName({plugin, screen}) {
- if (!plugin || !screen) return '';
- return `${plugin}-screen-${screen}`;
- }
-
- _logWelcome() {
- console.group('Runtime Info');
- console.log('Gerrit UI (PolyGerrit)');
- console.log(`Gerrit Server Version: ${this._version}`);
- if (window.VERSION_INFO) {
- console.log(`UI Version Info: ${window.VERSION_INFO}`);
- }
- if (this._feedbackUrl) {
- console.log(`Please file bugs and feedback at: ${this._feedbackUrl}`);
- }
- console.groupEnd();
- }
-
- /**
- * Intercept RPC log events emitted by REST API interfaces.
- * Note: the REST API interface cannot use gr-reporting directly because
- * that would create a cyclic dependency.
- */
- _handleRpcLog(e) {
- this.reporting.reportRpcTiming(e.detail.anonymizedUrl,
- e.detail.elapsed);
- }
-
- _mobileSearchToggle(e) {
- this.mobileSearch = !this.mobileSearch;
- }
-
- getThemeEndpoint() {
- // For now, we only have dark mode and light mode
- return window.localStorage.getItem('dark-theme') ?
- 'app-theme-dark' :
- 'app-theme-light';
- }
-}
-
-customElements.define(GrAppElement.is, GrAppElement);
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
new file mode 100644
index 0000000..c2fb124
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -0,0 +1,707 @@
+/**
+ * @license
+ * Copyright (C) 2019 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/shared-styles';
+import '../styles/themes/app-theme';
+import {applyTheme as applyDarkTheme} from '../styles/themes/dark-theme';
+import './admin/gr-admin-view/gr-admin-view';
+import './documentation/gr-documentation-search/gr-documentation-search';
+import './change-list/gr-change-list-view/gr-change-list-view';
+import './change-list/gr-dashboard-view/gr-dashboard-view';
+import './change/gr-change-view/gr-change-view';
+import './core/gr-error-manager/gr-error-manager';
+import './core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog';
+import './core/gr-main-header/gr-main-header';
+import './core/gr-router/gr-router';
+import './core/gr-smart-search/gr-smart-search';
+import './diff/gr-diff-view/gr-diff-view';
+import './edit/gr-editor-view/gr-editor-view';
+import './plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import './plugins/gr-endpoint-param/gr-endpoint-param';
+import './plugins/gr-endpoint-slot/gr-endpoint-slot';
+import './plugins/gr-external-style/gr-external-style';
+import './plugins/gr-plugin-host/gr-plugin-host';
+import './settings/gr-cla-view/gr-cla-view';
+import './settings/gr-registration-dialog/gr-registration-dialog';
+import './settings/gr-settings-view/gr-settings-view';
+import './shared/gr-lib-loader/gr-lib-loader';
+import './shared/gr-rest-api-interface/gr-rest-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-app-element_html';
+import {getBaseUrl} from '../utils/url-util';
+import {
+ KeyboardShortcutMixin,
+ Shortcut,
+ SPECIAL_SHORTCUT,
+} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {GerritNav, GerritView} from './core/gr-navigation/gr-navigation';
+import {appContext} from '../services/app-context';
+import {flush} from '@polymer/polymer/lib/utils/flush';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
+import {GrRouter} from './core/gr-router/gr-router';
+import {
+ AccountDetailInfo,
+ ElementPropertyDeepChange,
+ ServerInfo,
+} from '../types/common';
+import {GrErrorManager} from './core/gr-error-manager/gr-error-manager';
+import {GrOverlay} from './shared/gr-overlay/gr-overlay';
+import {GrRegistrationDialog} from './settings/gr-registration-dialog/gr-registration-dialog';
+import {
+ AppElementJustRegisteredParams,
+ AppElementParams,
+ isAppElementJustRegisteredParams,
+} from './gr-app-types';
+import {GrMainHeader} from './core/gr-main-header/gr-main-header';
+import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
+import {
+ CustomKeyboardEvent,
+ LocationChangeEvent,
+ PageErrorEventDetail,
+ RpcLogEvent,
+ ShortcutTriggeredEvent,
+ TitleChangeEventDetail,
+} from '../types/events';
+import {ViewState} from '../types/types';
+
+interface ErrorInfo {
+ text: string;
+ emoji?: string;
+ moreInfo?: string;
+}
+
+export interface GrAppElement {
+ $: {
+ restAPI: RestApiService & Element;
+ router: GrRouter;
+ errorManager: GrErrorManager;
+ errorView: HTMLDivElement;
+ mainHeader: GrMainHeader;
+ };
+}
+
+// TODO(TS): implement AppElement interface from gr-app-types.ts
+@customElement('gr-app-element')
+export class GrAppElement extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the URL location changes.
+ *
+ * @event location-change
+ */
+
+ @property({type: Object})
+ params?: AppElementParams;
+
+ @property({type: Object})
+ keyEventTarget = document.body;
+
+ @property({type: Object, observer: '_accountChanged'})
+ _account?: AccountDetailInfo;
+
+ @property({type: Number})
+ _lastGKeyPressTimestamp: number | null = null;
+
+ @property({type: Object})
+ _serverConfig?: ServerInfo;
+
+ @property({type: String})
+ _version?: string;
+
+ @property({type: Boolean})
+ _showChangeListView?: boolean;
+
+ @property({type: Boolean})
+ _showDashboardView?: boolean;
+
+ @property({type: Boolean})
+ _showChangeView?: boolean;
+
+ @property({type: Boolean})
+ _showDiffView?: boolean;
+
+ @property({type: Boolean})
+ _showSettingsView?: boolean;
+
+ @property({type: Boolean})
+ _showAdminView?: boolean;
+
+ @property({type: Boolean})
+ _showCLAView?: boolean;
+
+ @property({type: Boolean})
+ _showEditorView?: boolean;
+
+ @property({type: Boolean})
+ _showPluginScreen?: boolean;
+
+ @property({type: Boolean})
+ _showDocumentationSearch?: boolean;
+
+ @property({type: Object})
+ _viewState?: ViewState;
+
+ @property({type: Object})
+ _lastError?: ErrorInfo;
+
+ @property({type: String})
+ _lastSearchPage?: string;
+
+ @property({type: String})
+ _path?: string;
+
+ @property({type: String, computed: '_computePluginScreenName(params)'})
+ _pluginScreenName?: string;
+
+ @property({type: String})
+ _settingsUrl?: string;
+
+ @property({type: String})
+ _feedbackUrl?: string;
+
+ @property({type: Boolean})
+ mobileSearch = false;
+
+ @property({type: String})
+ _loginUrl = '/login';
+
+ @property({type: Boolean})
+ loadRegistrationDialog = false;
+
+ @property({type: Boolean})
+ loadKeyboardShortcutsDialog = false;
+
+ private reporting = appContext.reportingService;
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
+ [Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
+ [Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
+ [Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
+ [Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
+ [Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
+ };
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this._bindKeyboardShortcuts();
+ this.addEventListener('page-error', e => this._handlePageError(e));
+ this.addEventListener('title-change', e => this._handleTitleChange(e));
+ this.addEventListener('location-change', e =>
+ this._handleLocationChange(e)
+ );
+ this.addEventListener('rpc-log', e => this._handleRpcLog(e));
+ this.addEventListener('shortcut-triggered', e =>
+ this._handleShortcutTriggered(e)
+ );
+ // Ideally individual views should handle this event and respond with a soft
+ // reload. This is a catch-all for all views that cannot or have not
+ // implemented that.
+ this.addEventListener('reload', () => window.location.reload());
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._updateLoginUrl();
+ this.reporting.appStarted();
+ this.$.router.start();
+
+ this.$.restAPI.getAccount().then(account => {
+ this._account = account;
+ const role = account ? 'user' : 'guest';
+ this.reporting.reportLifeCycle(`Started as ${role}`);
+ });
+ this.$.restAPI.getConfig().then(config => {
+ this._serverConfig = config;
+
+ if (config && config.gerrit && config.gerrit.report_bug_url) {
+ this._feedbackUrl = config.gerrit.report_bug_url;
+ }
+ });
+ this.$.restAPI.getVersion().then(version => {
+ this._version = version;
+ this._logWelcome();
+ });
+
+ if (window.localStorage.getItem('dark-theme')) {
+ applyDarkTheme();
+ }
+
+ // Note: this is evaluated here to ensure that it only happens after the
+ // router has been initialized. @see Issue 7837
+ this._settingsUrl = GerritNav.getUrlForSettings();
+
+ this._viewState = {
+ changeView: {
+ changeNum: null,
+ patchRange: null,
+ selectedFileIndex: 0,
+ showReplyDialog: false,
+ showDownloadDialog: false,
+ diffMode: null,
+ numFilesShown: null,
+ scrollTop: 0,
+ },
+ changeListView: {
+ query: null,
+ offset: 0,
+ selectedChangeIndex: 0,
+ },
+ dashboardView: {
+ selectedChangeIndex: 0,
+ },
+ };
+ }
+
+ _bindKeyboardShortcuts() {
+ this.bindShortcut(
+ Shortcut.SEND_REPLY,
+ SPECIAL_SHORTCUT.DOC_ONLY,
+ 'ctrl+enter',
+ 'meta+enter'
+ );
+ this.bindShortcut(Shortcut.EMOJI_DROPDOWN, SPECIAL_SHORTCUT.DOC_ONLY, ':');
+
+ this.bindShortcut(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
+ this.bindShortcut(
+ Shortcut.GO_TO_USER_DASHBOARD,
+ SPECIAL_SHORTCUT.GO_KEY,
+ 'i'
+ );
+ this.bindShortcut(
+ Shortcut.GO_TO_OPENED_CHANGES,
+ SPECIAL_SHORTCUT.GO_KEY,
+ 'o'
+ );
+ this.bindShortcut(
+ Shortcut.GO_TO_MERGED_CHANGES,
+ SPECIAL_SHORTCUT.GO_KEY,
+ 'm'
+ );
+ this.bindShortcut(
+ Shortcut.GO_TO_ABANDONED_CHANGES,
+ SPECIAL_SHORTCUT.GO_KEY,
+ 'a'
+ );
+ this.bindShortcut(
+ Shortcut.GO_TO_WATCHED_CHANGES,
+ SPECIAL_SHORTCUT.GO_KEY,
+ 'w'
+ );
+
+ this.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
+ this.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
+ this.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
+ this.bindShortcut(Shortcut.NEXT_PAGE, 'n', ']');
+ this.bindShortcut(Shortcut.PREV_PAGE, 'p', '[');
+ this.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
+ this.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's:keydown');
+ this.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
+ this.bindShortcut(Shortcut.EDIT_TOPIC, 't');
+
+ this.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a:keyup');
+ this.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup');
+ this.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
+ this.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
+ this.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
+ this.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
+ this.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
+ this.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
+ this.bindShortcut(
+ Shortcut.DIFF_AGAINST_BASE,
+ SPECIAL_SHORTCUT.V_KEY,
+ 'down',
+ 's'
+ );
+ // this keyboard shortcut is used in toast _displayDiffAgainstLatestToast
+ // in gr-diff-view. Any updates here should be reflected there
+ this.bindShortcut(
+ Shortcut.DIFF_AGAINST_LATEST,
+ SPECIAL_SHORTCUT.V_KEY,
+ 'up',
+ 'w'
+ );
+ // this keyboard shortcut is used in toast _displayDiffBaseAgainstLeftToast
+ // in gr-diff-view. Any updates here should be reflected there
+ this.bindShortcut(
+ Shortcut.DIFF_BASE_AGAINST_LEFT,
+ SPECIAL_SHORTCUT.V_KEY,
+ 'left',
+ 'a'
+ );
+ this.bindShortcut(
+ Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+ SPECIAL_SHORTCUT.V_KEY,
+ 'right',
+ 'd'
+ );
+ this.bindShortcut(
+ Shortcut.DIFF_BASE_AGAINST_LATEST,
+ SPECIAL_SHORTCUT.V_KEY,
+ 'b'
+ );
+
+ this.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
+ this.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
+ if (this._isCursorManagerSupportMoveToVisibleLine()) {
+ this.bindShortcut(Shortcut.VISIBLE_LINE, '.');
+ }
+ this.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
+ this.bindShortcut(Shortcut.PREV_CHUNK, 'p');
+ this.bindShortcut(Shortcut.EXPAND_ALL_DIFF_CONTEXT, 'shift+x');
+ this.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
+ this.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
+ this.bindShortcut(
+ Shortcut.EXPAND_ALL_COMMENT_THREADS,
+ SPECIAL_SHORTCUT.DOC_ONLY,
+ 'e'
+ );
+ this.bindShortcut(
+ Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+ SPECIAL_SHORTCUT.DOC_ONLY,
+ 'shift+e'
+ );
+ this.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
+ this.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
+ this.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+ this.bindShortcut(Shortcut.NEW_COMMENT, 'c');
+ this.bindShortcut(
+ Shortcut.SAVE_COMMENT,
+ 'ctrl+enter',
+ 'meta+enter',
+ 'ctrl+s',
+ 'meta+s'
+ );
+ this.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
+ this.bindShortcut(Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
+
+ this.bindShortcut(Shortcut.NEXT_FILE, ']');
+ this.bindShortcut(Shortcut.PREV_FILE, '[');
+ this.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
+ this.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
+ this.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+ this.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
+ this.bindShortcut(Shortcut.OPEN_FILE, 'o', 'enter');
+ this.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
+ this.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
+ this.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
+ this.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
+ this.bindShortcut(Shortcut.TOGGLE_BLAME, 'b:keyup');
+ this.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
+ this.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
+
+ this.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
+ this.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
+
+ this.bindShortcut(Shortcut.SEARCH, '/');
+ }
+
+ _isCursorManagerSupportMoveToVisibleLine() {
+ // This method is a copy-paste from the
+ // method _isIntersectionObserverSupported of gr-cursor-manager.js
+ // It is better share this method with gr-cursor-manager,
+ // but doing it require a lot if changes instead of 1-line copied code
+ return 'IntersectionObserver' in window;
+ }
+
+ _accountChanged(account?: AccountDetailInfo) {
+ if (!account) return;
+
+ // Preferences are cached when a user is logged in; warm them.
+ this.$.restAPI.getPreferences();
+ this.$.restAPI.getDiffPreferences();
+ this.$.restAPI.getEditPreferences();
+ this.$.errorManager.knownAccountId =
+ (this._account && this._account._account_id) || null;
+ }
+
+ @observe('params.view')
+ _viewChanged(view?: GerritView) {
+ this.$.errorView.classList.remove('show');
+ this.set('_showChangeListView', view === GerritView.SEARCH);
+ this.set('_showDashboardView', view === GerritView.DASHBOARD);
+ this.set('_showChangeView', view === GerritView.CHANGE);
+ this.set('_showDiffView', view === GerritView.DIFF);
+ this.set('_showSettingsView', view === GerritView.SETTINGS);
+ // _showAdminView must be in sync with the gr-admin-view AdminViewParams type
+ this.set(
+ '_showAdminView',
+ view === GerritView.ADMIN ||
+ view === GerritView.GROUP ||
+ view === GerritView.REPO
+ );
+ this.set('_showCLAView', view === GerritView.AGREEMENTS);
+ this.set('_showEditorView', view === GerritView.EDIT);
+ const isPluginScreen = view === GerritView.PLUGIN_SCREEN;
+ this.set('_showPluginScreen', false);
+ // Navigation within plugin screens does not restamp gr-endpoint-decorator
+ // because _showPluginScreen value does not change. To force restamp,
+ // change _showPluginScreen value between true and false.
+ if (isPluginScreen) {
+ this.async(() => this.set('_showPluginScreen', true), 1);
+ }
+ this.set(
+ '_showDocumentationSearch',
+ view === GerritView.DOCUMENTATION_SEARCH
+ );
+ if (
+ this.params &&
+ isAppElementJustRegisteredParams(this.params) &&
+ this.params.justRegistered
+ ) {
+ this.loadRegistrationDialog = true;
+ flush();
+ const registrationOverlay = this.shadowRoot!.querySelector(
+ '#registrationOverlay'
+ ) as GrOverlay;
+ const registrationDialog = this.shadowRoot!.querySelector(
+ '#registrationDialog'
+ ) as GrRegistrationDialog;
+ registrationOverlay.open();
+ registrationDialog.loadData().then(() => {
+ registrationOverlay.refit();
+ });
+ }
+ }
+
+ _handleShortcutTriggered(event: ShortcutTriggeredEvent) {
+ const {event: e, goKey, vKey} = event.detail;
+ // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
+ let key = `${((e as unknown) as KeyboardEvent).key}:${e.type}`;
+ if (goKey) key = 'g+' + key;
+ if (vKey) key = 'v+' + key;
+ if (e.shiftKey) key = 'shift+' + key;
+ if (e.ctrlKey) key = 'ctrl+' + key;
+ if (e.metaKey) key = 'meta+' + key;
+ if (e.altKey) key = 'alt+' + key;
+ this.reporting.reportInteraction('shortcut-triggered', {
+ key,
+ from:
+ (event.path && event.path[0] && (event.path[0] as Element).nodeName) ??
+ 'unknown',
+ });
+ }
+
+ _handlePageError(e: CustomEvent<PageErrorEventDetail>) {
+ const props = [
+ '_showChangeListView',
+ '_showDashboardView',
+ '_showChangeView',
+ '_showDiffView',
+ '_showSettingsView',
+ '_showAdminView',
+ ];
+ for (const showProp of props) {
+ this.set(showProp, false);
+ }
+
+ this.$.errorView.classList.add('show');
+ const response = e.detail.response;
+ const err: ErrorInfo = {
+ text: [response.status, response.statusText].join(' '),
+ };
+ if (response.status === 404) {
+ err.emoji = '¯\\_(ツ)_/¯';
+ this._lastError = err;
+ } else {
+ err.emoji = 'o_O';
+ response.text().then(text => {
+ err.moreInfo = text;
+ this._lastError = err;
+ });
+ }
+ }
+
+ _handleLocationChange(e: LocationChangeEvent) {
+ this._updateLoginUrl();
+
+ const hash = e.detail.hash.substring(1);
+ let pathname = e.detail.pathname;
+ if (pathname.startsWith('/c/') && Number(hash) > 0) {
+ pathname += '@' + hash;
+ }
+ this.set('_path', pathname);
+ }
+
+ _updateLoginUrl() {
+ const baseUrl = getBaseUrl();
+ if (baseUrl) {
+ // Strip the canonical path from the path since needing canonical in
+ // the path is unneeded and breaks the url.
+ this._loginUrl =
+ baseUrl +
+ '/login/' +
+ encodeURIComponent(
+ '/' +
+ window.location.pathname.substring(baseUrl.length) +
+ window.location.search +
+ window.location.hash
+ );
+ } else {
+ this._loginUrl =
+ '/login/' +
+ encodeURIComponent(
+ window.location.pathname +
+ window.location.search +
+ window.location.hash
+ );
+ }
+ }
+
+ @observe('params.*')
+ _paramsChanged(
+ paramsRecord: ElementPropertyDeepChange<GrAppElement, 'params'>
+ ) {
+ const params = paramsRecord.base;
+ const viewsToCheck = [GerritView.SEARCH, GerritView.DASHBOARD];
+ if (params?.view && viewsToCheck.includes(params.view)) {
+ this.set('_lastSearchPage', location.pathname);
+ }
+ }
+
+ _handleTitleChange(e: CustomEvent<TitleChangeEventDetail>) {
+ if (e.detail.title) {
+ document.title = e.detail.title + ' · Gerrit Code Review';
+ } else {
+ document.title = '';
+ }
+ }
+
+ handleShowKeyboardShortcuts() {
+ this.loadKeyboardShortcutsDialog = true;
+ flush();
+ (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).open();
+ }
+
+ _showKeyboardShortcuts(e: CustomKeyboardEvent) {
+ // same shortcut should close the dialog if pressed again
+ // when dialog is open
+ this.loadKeyboardShortcutsDialog = true;
+ flush();
+ const keyboardShortcuts = this.shadowRoot!.querySelector(
+ '#keyboardShortcuts'
+ ) as GrOverlay;
+ if (!keyboardShortcuts) return;
+ if (keyboardShortcuts.opened) {
+ keyboardShortcuts.close();
+ return;
+ }
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+ keyboardShortcuts.open();
+ }
+
+ _handleKeyboardShortcutDialogClose() {
+ (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).close();
+ }
+
+ _handleAccountDetailUpdate() {
+ this.$.mainHeader.reload();
+ if (this.params?.view === GerritView.SETTINGS) {
+ (this.shadowRoot!.querySelector(
+ 'gr-settings-view'
+ ) as GrSettingsView).reloadAccountDetail();
+ }
+ }
+
+ _handleRegistrationDialogClose() {
+ // The registration dialog is visible only if this.params is
+ // instanceof AppElementJustRegisteredParams
+ (this.params as AppElementJustRegisteredParams).justRegistered = false;
+ (this.shadowRoot!.querySelector(
+ '#registrationOverlay'
+ ) as GrOverlay).close();
+ }
+
+ _goToOpenedChanges() {
+ GerritNav.navigateToStatusSearch('open');
+ }
+
+ _goToUserDashboard() {
+ GerritNav.navigateToUserDashboard();
+ }
+
+ _goToMergedChanges() {
+ GerritNav.navigateToStatusSearch('merged');
+ }
+
+ _goToAbandonedChanges() {
+ GerritNav.navigateToStatusSearch('abandoned');
+ }
+
+ _goToWatchedChanges() {
+ // The query is hardcoded, and doesn't respect custom menu entries
+ GerritNav.navigateToSearchQuery('is:watched is:open');
+ }
+
+ _computePluginScreenName(params: AppElementParams) {
+ if (params.view !== GerritView.PLUGIN_SCREEN) return '';
+ if (!params.plugin || !params.screen) return '';
+ return `${params.plugin}-screen-${params.screen}`;
+ }
+
+ _logWelcome() {
+ console.group('Runtime Info');
+ console.info('Gerrit UI (PolyGerrit)');
+ console.info(`Gerrit Server Version: ${this._version}`);
+ if (window.VERSION_INFO) {
+ console.info(`UI Version Info: ${window.VERSION_INFO}`);
+ }
+ if (this._feedbackUrl) {
+ console.info(`Please file bugs and feedback at: ${this._feedbackUrl}`);
+ }
+ console.groupEnd();
+ }
+
+ /**
+ * Intercept RPC log events emitted by REST API interfaces.
+ * Note: the REST API interface cannot use gr-reporting directly because
+ * that would create a cyclic dependency.
+ */
+ _handleRpcLog(e: RpcLogEvent) {
+ this.reporting.reportRpcTiming(e.detail.anonymizedUrl, e.detail.elapsed);
+ }
+
+ _mobileSearchToggle() {
+ this.mobileSearch = !this.mobileSearch;
+ }
+
+ getThemeEndpoint() {
+ // For now, we only have dark mode and light mode
+ return window.localStorage.getItem('dark-theme')
+ ? 'app-theme-dark'
+ : 'app-theme-light';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-app-element': GrAppElement;
+ }
+}
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
index 66624e3..88cd7f5 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.ts
+++ b/polygerrit-ui/app/elements/gr-app-element_html.ts
@@ -24,13 +24,6 @@
flex-direction: column;
min-height: 100%;
}
- gr-fixed-panel {
- /**
- * This one should be greater that the z-index in gr-diff-view
- * because gr-main-header contains overlay.
- */
- z-index: 10;
- }
gr-main-header,
footer {
color: var(--primary-text-color);
@@ -99,24 +92,25 @@
}
</style>
<gr-endpoint-decorator name="banner"></gr-endpoint-decorator>
- <gr-fixed-panel id="header">
- <gr-main-header
- id="mainHeader"
- search-query="{{params.query}}"
- on-mobile-search="_mobileSearchToggle"
- on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
- login-url="[[_loginUrl]]"
- >
- </gr-main-header>
- </gr-fixed-panel>
+ <gr-main-header
+ id="mainHeader"
+ search-query="{{params.query}}"
+ on-mobile-search="_mobileSearchToggle"
+ on-show-keyboard-shortcuts="handleShowKeyboardShortcuts"
+ mobile-search-hidden="[[!mobileSearch]]"
+ login-url="[[_loginUrl]]"
+ >
+ </gr-main-header>
<main>
- <gr-smart-search
- id="search"
- label="Search for changes"
- search-query="{{params.query}}"
- hidden="[[!mobileSearch]]"
- >
- </gr-smart-search>
+ <template is="dom-if" if="[[mobileSearch]]">
+ <gr-smart-search
+ id="search"
+ label="Search for changes"
+ search-query="{{params.query}}"
+ hidden="[[!mobileSearch]]"
+ >
+ </gr-smart-search>
+ </template>
<template is="dom-if" if="[[_showChangeListView]]" restamp="true">
<gr-change-list-view
params="[[params]]"
@@ -201,20 +195,24 @@
<gr-endpoint-decorator name="footer-right"></gr-endpoint-decorator>
</div>
</footer>
- <gr-overlay id="keyboardShortcuts" with-backdrop="">
- <gr-keyboard-shortcuts-dialog
- on-close="_handleKeyboardShortcutDialogClose"
- ></gr-keyboard-shortcuts-dialog>
- </gr-overlay>
- <gr-overlay id="registrationOverlay" with-backdrop="">
- <gr-registration-dialog
- id="registrationDialog"
- settings-url="[[_settingsUrl]]"
- on-account-detail-update="_handleAccountDetailUpdate"
- on-close="_handleRegistrationDialogClose"
- >
- </gr-registration-dialog>
- </gr-overlay>
+ <template is="dom-if" if="[[loadKeyboardShortcutsDialog]]">
+ <gr-overlay id="keyboardShortcuts" with-backdrop="">
+ <gr-keyboard-shortcuts-dialog
+ on-close="_handleKeyboardShortcutDialogClose"
+ ></gr-keyboard-shortcuts-dialog>
+ </gr-overlay>
+ </template>
+ <template is="dom-if" if="[[loadRegistrationDialog]]">
+ <gr-overlay id="registrationOverlay" with-backdrop="">
+ <gr-registration-dialog
+ id="registrationDialog"
+ settings-url="[[_settingsUrl]]"
+ on-account-detail-update="_handleAccountDetailUpdate"
+ on-close="_handleRegistrationDialogClose"
+ >
+ </gr-registration-dialog>
+ </gr-overlay>
+ </template>
<gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
<gr-error-manager
id="errorManager"
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.js b/polygerrit-ui/app/elements/gr-app-global-var-init.js
deleted file mode 100644
index 402dff2..0000000
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.js
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-/**
- * @fileoverview This file is a backwards-compatibility shim.
- * Before Polygerrit converted to ES Modules, it exposes some variables out onto
- * the global namespace. Plugins can depend on these variables and we must
- * expose these variables until plugins switch to direct import from polygerrit.
- */
-
-import {getAccountDisplayName, getDisplayName, getGroupDisplayName, getUserName} from '../utils/display-name-util.js';
-import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation.js';
-import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper.js';
-import {GrDiffLine} from './diff/gr-diff/gr-diff-line.js';
-import {GrDiffGroup} from './diff/gr-diff/gr-diff-group.js';
-import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder.js';
-import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side.js';
-import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image.js';
-import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified.js';
-import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary.js';
-import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api.js';
-import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api.js';
-import {GrEditConstants} from './edit/gr-edit-constants.js';
-import {GrFileListConstants} from './change/gr-file-list-constants.js';
-import {GrDomHooksManager, GrDomHook} from './plugins/gr-dom-hooks/gr-dom-hooks.js';
-import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator.js';
-import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api.js';
-import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
-import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser.js';
-import {pluginEndpoints, GrPluginEndpoints} from './shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser.js';
-import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface.js';
-import {GrRangeNormalizer} from './diff/gr-diff-highlight/gr-range-normalizer.js';
-import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter.js';
-import {GrReviewerSuggestionsProvider, SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js';
-import {util} from '../scripts/util.js';
-import page from 'page/page.mjs';
-import {appContext} from '../services/app-context.js';
-import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api.js';
-import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context.js';
-import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api.js';
-import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api.js';
-import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js';
-import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js';
-import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper.js';
-import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api.js';
-import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api.js';
-import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api.js';
-import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api.js';
-import {pluginLoader, PluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
-import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context.js';
-import {getPluginNameFromUrl, getRestAPI, PLUGIN_LOADING_TIMEOUT_MS, PRELOADED_PROTOCOL, send} from './shared/gr-js-api-interface/gr-api-utils.js';
-import {getBaseUrl} from '../utils/url-util.js';
-import {GerritNav} from './core/gr-navigation/gr-navigation.js';
-import {getRootElement} from '../scripts/rootElement.js';
-import {rangesEqual} from './diff/gr-diff/gr-diff-utils.js';
-import {RevisionInfo} from './shared/revision-info/revision-info.js';
-import {CoverageType} from '../types/types.js';
-import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll.js';
-
-export function initGlobalVariables() {
- window.GrDisplayNameUtils = {
- getUserName,
- getDisplayName,
- getAccountDisplayName,
- getGroupDisplayName,
- };
- window.GrAnnotation = GrAnnotation;
- window.GrAttributeHelper = GrAttributeHelper;
- window.GrDiffLine = GrDiffLine;
- window.GrDiffGroup = GrDiffGroup;
- window.GrDiffBuilder = GrDiffBuilder;
- window.GrDiffBuilderSideBySide = GrDiffBuilderSideBySide;
- window.GrDiffBuilderImage = GrDiffBuilderImage;
- window.GrDiffBuilderUnified = GrDiffBuilderUnified;
- window.GrDiffBuilderBinary = GrDiffBuilderBinary;
- window.GrChangeActionsInterface = GrChangeActionsInterface;
- window.GrChangeReplyInterface = GrChangeReplyInterface;
- window.GrEditConstants = GrEditConstants;
- window.GrFileListConstants = GrFileListConstants;
- window.GrDomHooksManager = GrDomHooksManager;
- window.GrDomHook = GrDomHook;
- window.GrEtagDecorator = GrEtagDecorator;
- window.GrThemeApi = GrThemeApi;
- window.SiteBasedCache = SiteBasedCache;
- window.FetchPromisesCache = FetchPromisesCache;
- window.GrRestApiHelper = GrRestApiHelper;
- window.GrLinkTextParser = GrLinkTextParser;
- window.GrPluginEndpoints = GrPluginEndpoints;
- window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
- window.GrPopupInterface = GrPopupInterface;
- window.GrRangeNormalizer = GrRangeNormalizer;
- window.GrCountStringFormatter = GrCountStringFormatter;
- window.GrReviewerSuggestionsProvider = GrReviewerSuggestionsProvider;
- window.util = util;
- window.page = page;
- window.Auth = appContext.authService;
- window.EventEmitter = appContext.eventEmitter;
- window.GrAdminApi = GrAdminApi;
- window.GrAnnotationActionsContext = GrAnnotationActionsContext;
- window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
- window.GrChangeMetadataApi = GrChangeMetadataApi;
- window.GrEmailSuggestionsProvider = GrEmailSuggestionsProvider;
- window.GrGroupSuggestionsProvider = GrGroupSuggestionsProvider;
- window.GrEventHelper = GrEventHelper;
- window.GrPluginRestApi = GrPluginRestApi;
- window.GrRepoApi = GrRepoApi;
- window.GrSettingsApi = GrSettingsApi;
- window.GrStylesApi = GrStylesApi;
- window.PluginLoader = PluginLoader;
- window.GrPluginActionContext = GrPluginActionContext;
-
- window._apiUtils = {
- getPluginNameFromUrl,
- send,
- getRestAPI,
- getBaseUrl,
- PRELOADED_PROTOCOL,
- PLUGIN_LOADING_TIMEOUT_MS,
- };
-
- window.Gerrit = window.Gerrit || {};
- window.Gerrit.Nav = GerritNav;
- window.Gerrit.getRootElement = getRootElement;
- window.Gerrit.Auth = appContext.authService;
-
- window.Gerrit._pluginLoader = pluginLoader;
- window.Gerrit._endpoints = pluginEndpoints;
-
- window.Gerrit.slotToContent = slot => slot;
- window.Gerrit.rangesEqual = rangesEqual;
- window.Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES =
- SUGGESTIONS_PROVIDERS_USERS_TYPES;
- window.Gerrit.RevisionInfo = RevisionInfo;
- window.Gerrit.CoverageType = CoverageType;
- Object.defineProperty(window.Gerrit, 'hiddenscroll', {
- get: getHiddenScroll,
- set: _setHiddenScroll,
- });
-}
diff --git a/polygerrit-ui/app/elements/gr-app-global-var-init.ts b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
new file mode 100644
index 0000000..fac5f45
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -0,0 +1,180 @@
+/**
+ * @license
+ * 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.
+ */
+
+/**
+ * @fileoverview This file is a backwards-compatibility shim.
+ * Before Polygerrit converted to ES Modules, it exposes some variables out onto
+ * the global namespace. Plugins can depend on these variables and we must
+ * expose these variables until plugins switch to direct import from polygerrit.
+ */
+
+import {
+ getAccountDisplayName,
+ getDisplayName,
+ getGroupDisplayName,
+ getUserName,
+} from '../utils/display-name-util';
+import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
+import {GrAttributeHelper} from './plugins/gr-attribute-helper/gr-attribute-helper';
+import {GrDiffLine, GrDiffLineType} from './diff/gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from './diff/gr-diff/gr-diff-group';
+import {GrDiffBuilder} from './diff/gr-diff-builder/gr-diff-builder';
+import {GrDiffBuilderSideBySide} from './diff/gr-diff-builder/gr-diff-builder-side-by-side';
+import {GrDiffBuilderImage} from './diff/gr-diff-builder/gr-diff-builder-image';
+import {GrDiffBuilderUnified} from './diff/gr-diff-builder/gr-diff-builder-unified';
+import {GrDiffBuilderBinary} from './diff/gr-diff-builder/gr-diff-builder-binary';
+import {GrChangeActionsInterface} from './shared/gr-js-api-interface/gr-change-actions-js-api';
+import {GrChangeReplyInterface} from './shared/gr-js-api-interface/gr-change-reply-js-api';
+import {GrEditConstants} from './edit/gr-edit-constants';
+import {
+ GrDomHooksManager,
+ GrDomHook,
+} from './plugins/gr-dom-hooks/gr-dom-hooks';
+import {GrEtagDecorator} from './shared/gr-rest-api-interface/gr-etag-decorator';
+import {GrThemeApi} from './plugins/gr-theme-api/gr-theme-api';
+import {
+ SiteBasedCache,
+ FetchPromisesCache,
+ GrRestApiHelper,
+} from './shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {GrLinkTextParser} from './shared/gr-linked-text/link-text-parser';
+import {
+ getPluginEndpoints,
+ GrPluginEndpoints,
+} from './shared/gr-js-api-interface/gr-plugin-endpoints';
+import {GrReviewerUpdatesParser} from './shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {GrPopupInterface} from './plugins/gr-popup-interface/gr-popup-interface';
+import {GrCountStringFormatter} from './shared/gr-count-string-formatter/gr-count-string-formatter';
+import {
+ GrReviewerSuggestionsProvider,
+ SUGGESTIONS_PROVIDERS_USERS_TYPES,
+} from '../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {util} from '../scripts/util';
+import {page} from '../utils/page-wrapper-utils';
+import {appContext} from '../services/app-context';
+import {GrAdminApi} from './plugins/gr-admin-api/gr-admin-api';
+import {GrAnnotationActionsContext} from './shared/gr-js-api-interface/gr-annotation-actions-context';
+import {GrAnnotationActionsInterface} from './shared/gr-js-api-interface/gr-annotation-actions-js-api';
+import {GrChangeMetadataApi} from './plugins/gr-change-metadata-api/gr-change-metadata-api';
+import {GrEmailSuggestionsProvider} from '../scripts/gr-email-suggestions-provider/gr-email-suggestions-provider';
+import {GrGroupSuggestionsProvider} from '../scripts/gr-group-suggestions-provider/gr-group-suggestions-provider';
+import {GrEventHelper} from './plugins/gr-event-helper/gr-event-helper';
+import {GrPluginRestApi} from './shared/gr-js-api-interface/gr-plugin-rest-api';
+import {GrRepoApi} from './plugins/gr-repo-api/gr-repo-api';
+import {GrSettingsApi} from './plugins/gr-settings-api/gr-settings-api';
+import {GrStylesApi} from './plugins/gr-styles-api/gr-styles-api';
+import {
+ getPluginLoader,
+ PluginLoader,
+} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
+import {
+ getPluginNameFromUrl,
+ getRestAPI,
+ PLUGIN_LOADING_TIMEOUT_MS,
+ PRELOADED_PROTOCOL,
+ send,
+} from './shared/gr-js-api-interface/gr-api-utils';
+import {getBaseUrl} from '../utils/url-util';
+import {GerritNav} from './core/gr-navigation/gr-navigation';
+import {getRootElement} from '../scripts/rootElement';
+import {rangesEqual} from './diff/gr-diff/gr-diff-utils';
+import {RevisionInfo} from './shared/revision-info/revision-info';
+import {CoverageType} from '../types/types';
+import {_setHiddenScroll, getHiddenScroll} from '../scripts/hiddenscroll';
+
+export function initGlobalVariables() {
+ window.GrDisplayNameUtils = {
+ getUserName,
+ getDisplayName,
+ getAccountDisplayName,
+ getGroupDisplayName,
+ };
+ window.GrAnnotation = GrAnnotation;
+ window.GrAttributeHelper = GrAttributeHelper;
+ window.GrDiffLine = GrDiffLine;
+ window.GrDiffLineType = GrDiffLineType;
+ window.GrDiffGroup = GrDiffGroup;
+ window.GrDiffGroupType = GrDiffGroupType;
+ window.GrDiffBuilder = GrDiffBuilder;
+ window.GrDiffBuilderSideBySide = GrDiffBuilderSideBySide;
+ window.GrDiffBuilderImage = GrDiffBuilderImage;
+ window.GrDiffBuilderUnified = GrDiffBuilderUnified;
+ window.GrDiffBuilderBinary = GrDiffBuilderBinary;
+ window.GrChangeActionsInterface = GrChangeActionsInterface;
+ window.GrChangeReplyInterface = GrChangeReplyInterface;
+ window.GrEditConstants = GrEditConstants;
+ window.GrDomHooksManager = GrDomHooksManager;
+ window.GrDomHook = GrDomHook;
+ window.GrEtagDecorator = GrEtagDecorator;
+ window.GrThemeApi = GrThemeApi;
+ window.SiteBasedCache = SiteBasedCache;
+ window.FetchPromisesCache = FetchPromisesCache;
+ window.GrRestApiHelper = GrRestApiHelper;
+ window.GrLinkTextParser = GrLinkTextParser;
+ window.GrPluginEndpoints = GrPluginEndpoints;
+ window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
+ window.GrPopupInterface = GrPopupInterface;
+ window.GrCountStringFormatter = GrCountStringFormatter;
+ window.GrReviewerSuggestionsProvider = GrReviewerSuggestionsProvider;
+ window.util = util;
+ window.page = page;
+ window.Auth = appContext.authService;
+ window.EventEmitter = appContext.eventEmitter;
+ window.GrAdminApi = GrAdminApi;
+ window.GrAnnotationActionsContext = GrAnnotationActionsContext;
+ window.GrAnnotationActionsInterface = GrAnnotationActionsInterface;
+ window.GrChangeMetadataApi = GrChangeMetadataApi;
+ window.GrEmailSuggestionsProvider = GrEmailSuggestionsProvider;
+ window.GrGroupSuggestionsProvider = GrGroupSuggestionsProvider;
+ window.GrEventHelper = GrEventHelper;
+ window.GrPluginRestApi = GrPluginRestApi;
+ window.GrRepoApi = GrRepoApi;
+ window.GrSettingsApi = GrSettingsApi;
+ window.GrStylesApi = GrStylesApi;
+ window.PluginLoader = PluginLoader;
+ window.GrPluginActionContext = GrPluginActionContext;
+
+ window._apiUtils = {
+ getPluginNameFromUrl,
+ send,
+ getRestAPI,
+ getBaseUrl,
+ PRELOADED_PROTOCOL,
+ PLUGIN_LOADING_TIMEOUT_MS,
+ };
+
+ window.Gerrit = window.Gerrit || {};
+ window.Gerrit.Nav = GerritNav;
+ window.Gerrit.getRootElement = getRootElement;
+ window.Gerrit.Auth = appContext.authService;
+
+ window.Gerrit._pluginLoader = getPluginLoader();
+ // TODO: should define as a getter
+ window.Gerrit._endpoints = getPluginEndpoints();
+
+ // TODO(TS): seems not used, probably just remove
+ window.Gerrit.slotToContent = (slot: any) => slot;
+ window.Gerrit.rangesEqual = rangesEqual;
+ window.Gerrit.SUGGESTIONS_PROVIDERS_USERS_TYPES = SUGGESTIONS_PROVIDERS_USERS_TYPES;
+ window.Gerrit.RevisionInfo = RevisionInfo;
+ window.Gerrit.CoverageType = CoverageType;
+ Object.defineProperty(window.Gerrit, 'hiddenscroll', {
+ get: getHiddenScroll,
+ set: _setHiddenScroll,
+ });
+}
diff --git a/polygerrit-ui/app/elements/gr-app-init.js b/polygerrit-ui/app/elements/gr-app-init.js
deleted file mode 100644
index ea10ce8..0000000
--- a/polygerrit-ui/app/elements/gr-app-init.js
+++ /dev/null
@@ -1,31 +0,0 @@
-/**
- * @license
- * 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.
- */
-import {initAppContext} from '../services/app-context-init.js';
-import {initVisibilityReporter, initPerformanceReporter, initErrorReporter} from '../services/gr-reporting/gr-reporting.js';
-import {appContext} from '../services/app-context.js';
-
-if (!window.Polymer) {
- window.Polymer = {
- lazyRegister: true,
- };
-}
-window.Gerrit = window.Gerrit || {};
-
-initAppContext();
-initVisibilityReporter(appContext);
-initPerformanceReporter(appContext);
-initErrorReporter(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app-init.ts b/polygerrit-ui/app/elements/gr-app-init.ts
new file mode 100644
index 0000000..6d79ce1
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-init.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * 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.
+ */
+import {initAppContext} from '../services/app-context-init';
+import {
+ initVisibilityReporter,
+ initPerformanceReporter,
+ initErrorReporter,
+} from '../services/gr-reporting/gr-reporting_impl';
+import {appContext} from '../services/app-context';
+
+interface UninitializedPolymer {
+ lazyRegister: boolean;
+}
+
+if (!window.Polymer) {
+ // Without as... it violates internal google rules.
+ ((window.Polymer as unknown) as UninitializedPolymer) = {
+ lazyRegister: true,
+ };
+}
+
+initAppContext();
+initVisibilityReporter(appContext);
+initPerformanceReporter(appContext);
+initErrorReporter(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
new file mode 100644
index 0000000..b05117f
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -0,0 +1,146 @@
+/**
+ * @license
+ * 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.
+ */
+import {
+ GenerateUrlParameters,
+ GerritView,
+ GroupDetailView,
+ RepoDetailView,
+} from './core/gr-navigation/gr-navigation';
+import {
+ DashboardId,
+ GroupId,
+ NumericChangeId,
+ PatchSetNum,
+ RepoName,
+ UrlEncodedCommentId,
+} from '../types/common';
+
+export interface AppElement extends HTMLElement {
+ params: AppElementParams | GenerateUrlParameters;
+}
+
+// TODO(TS): Remove unify AppElementParams with GenerateUrlParameters
+// Seems we can use GenerateUrlParameters instead of AppElementParams,
+// but it require some refactoring
+export interface AppElementDashboardParams {
+ view: GerritView.DASHBOARD;
+ project?: RepoName;
+ dashboard: DashboardId;
+ user?: string;
+ sections: Array<{name: string; query: string}>;
+ title?: string;
+}
+
+export interface AppElementGroupParams {
+ view: GerritView.GROUP;
+ detail?: GroupDetailView;
+ groupId: GroupId;
+}
+
+export interface AppElementAdminParams {
+ view: GerritView.ADMIN;
+ adminView: string;
+ offset?: string | number;
+ filter?: string | null;
+ openCreateModal?: boolean;
+}
+
+export interface AppElementRepoParams {
+ view: GerritView.REPO;
+ detail?: RepoDetailView;
+ repo: RepoName;
+ offset?: string | number;
+ filter?: string | null;
+}
+
+export interface AppElementDocSearchParams {
+ view: GerritView.DOCUMENTATION_SEARCH;
+ filter: string | null;
+}
+
+export interface AppElementPluginScreenParams {
+ view: GerritView.PLUGIN_SCREEN;
+ plugin?: string;
+ screen?: string;
+}
+
+export interface AppElementSearchParam {
+ view: GerritView.SEARCH;
+ query: string;
+ offset: string;
+}
+
+export interface AppElementSettingsParam {
+ view: GerritView.SETTINGS;
+ emailToken?: string;
+}
+
+export interface AppElementAgreementParam {
+ view: GerritView.AGREEMENTS;
+}
+
+export interface AppElementDiffViewParam {
+ view: GerritView.DIFF;
+ changeNum: NumericChangeId;
+ project?: RepoName;
+ commentId?: UrlEncodedCommentId;
+ path?: string;
+ patchNum?: PatchSetNum;
+ basePatchNum?: PatchSetNum;
+ lineNum: number;
+ leftSide?: boolean;
+ commentLink?: boolean;
+}
+export interface AppElementChangeViewParams {
+ view: GerritView.CHANGE;
+ changeNum: NumericChangeId;
+ project: RepoName;
+ edit?: boolean;
+ patchNum?: PatchSetNum;
+ basePatchNum?: PatchSetNum;
+ queryMap?: Map<string, string> | URLSearchParams;
+}
+
+export interface AppElementJustRegisteredParams {
+ // We use params.view === ... as a type guard.
+ // The view?: never tells to the compiler that
+ // AppElementJustRegisteredParams can't have view property.
+ // Otherwise, the compiler reports an error when the code tries to use
+ // the property 'view' of AppElementParams.
+ view?: never;
+ justRegistered: boolean;
+}
+
+export type AppElementParams =
+ | AppElementDashboardParams
+ | AppElementGroupParams
+ | AppElementAdminParams
+ | AppElementChangeViewParams
+ | AppElementRepoParams
+ | AppElementDocSearchParams
+ | AppElementPluginScreenParams
+ | AppElementSearchParam
+ | AppElementSettingsParam
+ | AppElementAgreementParam
+ | AppElementDiffViewParam
+ | AppElementJustRegisteredParams;
+
+export function isAppElementJustRegisteredParams(
+ p: AppElementParams
+): p is AppElementJustRegisteredParams {
+ return (p as AppElementJustRegisteredParams).justRegistered !== undefined;
+}
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
deleted file mode 100644
index fef7ab9..0000000
--- a/polygerrit-ui/app/elements/gr-app.js
+++ /dev/null
@@ -1,67 +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 {safeTypesBridge} from '../utils/safe-types-util.js';
-
-// We need to use goog.declareModuleId internally in google for TS-imports-JS
-// case. To avoid errors when goog is not available, the empty implementation is
-// added.
-window.goog = window.goog || {declareModuleId(name) {}};
-import './gr-app-init.js';
-import './font-roboto-local-loader.js';
-// Sets up global Polymer variable, because plugins requires it.
-import '../scripts/bundled-polymer.js';
-
-/**
- * setCancelSyntheticClickEvents is set to true by
- * default which will cancel synthetic click events
- * on older touch device.
- * See https://github.com/Polymer/polymer/issues/5289
- */
-import {setPassiveTouchGestures, setCancelSyntheticClickEvents} from '@polymer/polymer/lib/utils/settings.js';
-setCancelSyntheticClickEvents(false);
-setPassiveTouchGestures(true);
-
-import 'polymer-resin/standalone/polymer-resin.js';
-import {initGlobalVariables} from './gr-app-global-var-init.js';
-import './gr-app-element.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-app_html.js';
-import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit.js';
-import {appContext} from '../services/app-context.js';
-
-security.polymer_resin.install({
- allowedIdentifierPrefixes: [''],
- reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
- safeTypesBridge,
-});
-
-/** @extends PolymerElement */
-class GrApp extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-app'; }
-}
-
-customElements.define(GrApp.is, GrApp);
-
-initGlobalVariables();
-initGerritPluginApi(appContext);
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
new file mode 100644
index 0000000..f19931f
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -0,0 +1,63 @@
+/**
+ * @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 {safeTypesBridge} from '../utils/safe-types-util';
+import './gr-app-init';
+import './font-roboto-local-loader';
+// Sets up global Polymer variable, because plugins requires it.
+import '../scripts/bundled-polymer';
+
+/**
+ * setCancelSyntheticClickEvents is set to true by
+ * default which will cancel synthetic click events
+ * on older touch device.
+ * See https://github.com/Polymer/polymer/issues/5289
+ */
+import {
+ setPassiveTouchGestures,
+ setCancelSyntheticClickEvents,
+} from '@polymer/polymer/lib/utils/settings';
+setCancelSyntheticClickEvents(false);
+setPassiveTouchGestures(true);
+
+import {initGlobalVariables} from './gr-app-global-var-init';
+import './gr-app-element';
+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-app_html';
+import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
+import {customElement} from '@polymer/decorators';
+import {installPolymerResin} from '../scripts/polymer-resin-install';
+
+installPolymerResin(safeTypesBridge);
+
+@customElement('gr-app')
+class GrApp extends GestureEventListeners(LegacyElementMixin(PolymerElement)) {
+ static get template() {
+ return htmlTemplate;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-app': GrApp;
+ }
+}
+
+initGlobalVariables();
+initGerritPluginApi();
diff --git a/polygerrit-ui/app/elements/lit/gr-lit-element.ts b/polygerrit-ui/app/elements/lit/gr-lit-element.ts
new file mode 100644
index 0000000..9ebadd5
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/gr-lit-element.ts
@@ -0,0 +1,52 @@
+/**
+ * @license
+ * 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.
+ */
+import {LitElement} from 'lit-element';
+import {Observable, Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
+
+/**
+ * Base class for Gerrit's lit-elements.
+ *
+ * Adds basic functionality that we want to have available in all Gerrit's
+ * components.
+ */
+export abstract class GrLitElement extends LitElement {
+ disconnected$ = new Subject();
+
+ /**
+ * Hooks up an element property with an observable. Apart from subscribing it
+ * makes sure that you are unsubscribed when the component is disconnected.
+ * And it requests a template check when a new value comes in.
+ *
+ * Should be called from connectedCallback() such that you will be
+ * re-subscribed when the component is re-connected.
+ *
+ * TODO: Maybe distinctUntilChanged should be applied to obs$?
+ */
+ subscribe<Key extends keyof this>(prop: Key, obs$: Observable<this[Key]>) {
+ obs$.pipe(takeUntil(this.disconnected$)).subscribe(value => {
+ const oldValue = this[prop];
+ this[prop] = value;
+ this.requestUpdate(prop, oldValue);
+ });
+ }
+
+ disconnectedCallback() {
+ this.disconnected$.next();
+ super.disconnectedCallback();
+ }
+}
diff --git a/polygerrit-ui/app/elements/lit/gr-lit-element_test.ts b/polygerrit-ui/app/elements/lit/gr-lit-element_test.ts
new file mode 100644
index 0000000..9b7b0e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/gr-lit-element_test.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../test/common-test-setup-karma';
+import {html, customElement} from 'lit-element';
+import {GrLitElement} from './gr-lit-element';
+
+@customElement('test-gr-lit-element')
+export class TestGrLitElement extends GrLitElement {
+ render() {
+ return html`<span>test</span>`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'test-gr-lit-element': GrLitElement;
+ }
+}
+
+suite('gr-lit-element test', () => {
+ test('is defined', () => {
+ const el = document.createElement('test-gr-lit-element');
+ assert.instanceOf(el, TestGrLitElement);
+ });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
deleted file mode 100644
index 2dfb79f..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.js
+++ /dev/null
@@ -1,35 +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.
- */
-
-/** @constructor */
-export function GrAdminApi(plugin) {
- this.plugin = plugin;
- plugin.on('admin-menu-links', this);
- this._menuLinks = [];
-}
-
-/**
- * @param {string} text
- * @param {string} url
- */
-GrAdminApi.prototype.addMenuLink = function(text, url, opt_capability) {
- this._menuLinks.push({text, url, capability: opt_capability || null});
-};
-
-GrAdminApi.prototype.getMenuLinks = function() {
- return this._menuLinks.slice(0);
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
new file mode 100644
index 0000000..1332118
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api.ts
@@ -0,0 +1,47 @@
+/**
+ * @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 {PluginApi} from '../gr-plugin-types';
+
+/** Interface for menu link */
+export interface MenuLink {
+ text: string;
+ url: string;
+ capability: string | null;
+}
+
+/**
+ * GrAdminApi class.
+ *
+ * Defines common methods to register / retrieve menu links.
+ */
+export class GrAdminApi {
+ // TODO(TS): maybe define as enum if its a limited set
+ private menuLinks: MenuLink[] = [];
+
+ constructor(private readonly plugin: PluginApi) {
+ this.plugin.on('admin-menu-links', this);
+ }
+
+ addMenuLink(text: string, url: string, capability?: string) {
+ this.menuLinks.push({text, url, capability: capability || null});
+ }
+
+ getMenuLinks(): MenuLink[] {
+ return this.menuLinks.slice(0);
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
index c3552cb..9a8f75e 100644
--- a/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-admin-api/gr-admin-api_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
const pluginApi = _testOnly_initGerritPluginApi();
@@ -29,7 +29,7 @@
let plugin;
pluginApi.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
adminApi = plugin.admin();
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
deleted file mode 100644
index 3f8aa44..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
+++ /dev/null
@@ -1,99 +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.
- */
-
-/** @constructor */
-export function GrAttributeHelper(element) {
- this.element = element;
- this._promises = {};
-}
-
-GrAttributeHelper.prototype._getChangedEventName = function(name) {
- return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
-};
-
-/**
- * Returns true if the property is defined on wrapped element.
- *
- * @param {string} name
- * @return {boolean}
- */
-GrAttributeHelper.prototype._elementHasProperty = function(name) {
- return this.element[name] !== undefined;
-};
-
-GrAttributeHelper.prototype._reportValue = function(callback, value) {
- try {
- callback(value);
- } catch (e) {
- console.info(e);
- }
-};
-
-/**
- * Binds callback to property updates.
- *
- * @param {string} name Property name.
- * @param {function(?)} callback
- * @return {function()} Unbind function.
- */
-GrAttributeHelper.prototype.bind = function(name, callback) {
- const attributeChangedEventName = this._getChangedEventName(name);
- const changedHandler = e => this._reportValue(callback, e.detail.value);
- const unbind = () => this.element.removeEventListener(
- attributeChangedEventName, changedHandler);
- this.element.addEventListener(
- attributeChangedEventName, changedHandler);
- if (this._elementHasProperty(name)) {
- this._reportValue(callback, this.element[name]);
- }
- return unbind;
-};
-
-/**
- * Get value of the property from wrapped object. Waits for the property
- * to be initialized if it isn't defined.
- *
- * @param {string} name Property name.
- * @return {!Promise<?>}
- */
-GrAttributeHelper.prototype.get = function(name) {
- if (this._elementHasProperty(name)) {
- return Promise.resolve(this.element[name]);
- }
- if (!this._promises[name]) {
- let resolve;
- const promise = new Promise(r => resolve = r);
- const unbind = this.bind(name, value => {
- resolve(value);
- unbind();
- });
- this._promises[name] = promise;
- }
- return this._promises[name];
-};
-
-/**
- * Sets value and dispatches event to force notify.
- *
- * @param {string} name Property name.
- * @param {?} value
- */
-GrAttributeHelper.prototype.set = function(name, value) {
- this.element[name] = value;
- this.element.dispatchEvent(
- new CustomEvent(this._getChangedEventName(name), {detail: {value}}));
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
new file mode 100644
index 0000000..6fc7a17
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.ts
@@ -0,0 +1,94 @@
+/**
+ * @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.
+ */
+
+export class GrAttributeHelper {
+ private readonly _promises = new Map<string, Promise<any>>();
+
+ // TOOD(TS): Change any to something more like HTMLElement.
+ constructor(public element: any) {}
+
+ _getChangedEventName(name: string): string {
+ return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
+ }
+
+ /**
+ * Returns true if the property is defined on wrapped element.
+ */
+ _elementHasProperty(name: string) {
+ return this.element[name] !== undefined;
+ }
+
+ _reportValue(callback: (value: any) => void, value: any) {
+ try {
+ callback(value);
+ } catch (e) {
+ console.info(e);
+ }
+ }
+
+ /**
+ * Binds callback to property updates.
+ *
+ * @param name Property name.
+ * @return Unbind function.
+ */
+ bind(name: string, callback: (value: any) => void) {
+ const attributeChangedEventName = this._getChangedEventName(name);
+ const changedHandler = (e: CustomEvent) =>
+ this._reportValue(callback, e.detail.value);
+ const unbind = () =>
+ this.element.removeEventListener(
+ attributeChangedEventName,
+ changedHandler
+ );
+ this.element.addEventListener(attributeChangedEventName, changedHandler);
+ if (this._elementHasProperty(name)) {
+ this._reportValue(callback, this.element[name]);
+ }
+ return unbind;
+ }
+
+ /**
+ * Get value of the property from wrapped object. Waits for the property
+ * to be initialized if it isn't defined.
+ */
+ get(name: string): Promise<unknown> {
+ if (this._elementHasProperty(name)) {
+ return Promise.resolve(this.element[name]);
+ }
+ if (!this._promises.has(name)) {
+ let resolve: (value: any) => void;
+ const promise = new Promise(r => (resolve = r));
+ const unbind = this.bind(name, value => {
+ resolve(value);
+ unbind();
+ });
+ this._promises.set(name, promise);
+ }
+ return this._promises.get(name)!;
+ }
+
+ /**
+ * Sets value and dispatches event to force notify.
+ */
+ set(name: string, value: any) {
+ this.element[name] = value;
+ this.element.dispatchEvent(
+ new CustomEvent(this._getChangedEventName(name), {detail: {value}})
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
deleted file mode 100644
index 89d8ec2..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.js
+++ /dev/null
@@ -1,35 +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.
- */
-
-/** @constructor */
-export function GrChangeMetadataApi(plugin) {
- this._hook = null;
- this.plugin = plugin;
-}
-
-GrChangeMetadataApi.prototype._createHook = function() {
- this._hook = this.plugin.hook('change-metadata-item');
-};
-
-GrChangeMetadataApi.prototype.onLabelsChanged = function(callback) {
- if (!this._hook) {
- this._createHook();
- }
- this._hook.onAttached(element =>
- this.plugin.attributeHelper(element).bind('labels', callback));
- return this;
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
new file mode 100644
index 0000000..322d32e
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-change-metadata-api/gr-change-metadata-api.ts
@@ -0,0 +1,42 @@
+/**
+ * @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 {HookApi, PluginApi} from '../gr-plugin-types';
+
+export class GrChangeMetadataApi {
+ private _hook: HookApi | null;
+
+ public plugin: PluginApi;
+
+ constructor(plugin: PluginApi) {
+ this.plugin = plugin;
+ this._hook = null;
+ }
+
+ _createHook() {
+ this._hook = this.plugin.hook('change-metadata-item');
+ }
+
+ onLabelsChanged(callback: (value: unknown) => void) {
+ if (!this._hook) {
+ this._createHook();
+ }
+ this._hook!.onAttached((element: Element) =>
+ this.plugin.attributeHelper(element).bind('labels', callback)
+ );
+ return this;
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
new file mode 100644
index 0000000..bfcff33
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api.ts
@@ -0,0 +1,21 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Settings
+ *
+ * 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 {PluginApi} from '../gr-plugin-types';
+
+export class GrChecksApi {
+ constructor(readonly plugin: PluginApi) {}
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
new file mode 100644
index 0000000..45cbb47
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-checks-api/gr-checks-api_test.ts
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {GrChecksApi} from './gr-checks-api';
+import {PluginApi} from '../gr-plugin-types';
+
+const gerritPluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-settings-api tests', () => {
+ let checksApi: GrChecksApi | undefined;
+
+ setup(() => {
+ let pluginApi: PluginApi | undefined = undefined;
+ gerritPluginApi.install(
+ p => {
+ pluginApi = p;
+ },
+ '0.1',
+ 'http://test.com/plugins/testplugin/static/test.js'
+ );
+ getPluginLoader().loadPlugins([]);
+ assert.isOk(pluginApi);
+ checksApi = pluginApi!.checks();
+ });
+
+ teardown(() => {
+ checksApi = undefined;
+ });
+
+ test('exists', () => {
+ assert.isOk(checksApi);
+ });
+});
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
deleted file mode 100644
index 93cbcf5..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
+++ /dev/null
@@ -1,164 +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 {PolymerElement} from '@polymer/polymer/polymer-element.js';
-
-/** @constructor */
-export function GrDomHooksManager(plugin) {
- this._plugin = plugin;
- this._hooks = {};
-}
-
-GrDomHooksManager.prototype._getHookName = function(endpointName,
- opt_moduleName) {
- if (opt_moduleName) {
- return endpointName + ' ' + opt_moduleName;
- } else {
- // lowercase in case plugin's name contains uppercase letters
- // TODO: this still can not prevent if plugin has invalid char
- // other than uppercase, but is the first step
- // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
- const pluginName = this._plugin.getPluginName() || 'unknown_plugin';
- return pluginName.toLowerCase() + '-autogenerated-' + endpointName;
- }
-};
-
-GrDomHooksManager.prototype.getDomHook = function(endpointName,
- opt_moduleName) {
- const hookName = this._getHookName(endpointName, opt_moduleName);
- if (!this._hooks[hookName]) {
- this._hooks[hookName] = new GrDomHook(hookName, opt_moduleName);
- }
- return this._hooks[hookName];
-};
-
-/** @constructor */
-export function GrDomHook(hookName, opt_moduleName) {
- this._instances = [];
- this._attachCallbacks = [];
- this._detachCallbacks = [];
- if (opt_moduleName) {
- this._moduleName = opt_moduleName;
- } else {
- this._moduleName = hookName;
- this._createPlaceholder(hookName);
- }
-}
-
-GrDomHook.prototype._createPlaceholder = function(hookName) {
- class HookPlaceholder extends PolymerElement {
- static get is() { return hookName; }
-
- static get properties() {
- return {
- plugin: Object,
- content: Object,
- };
- }
- }
-
- customElements.define(HookPlaceholder.is, HookPlaceholder);
-};
-
-GrDomHook.prototype.handleInstanceDetached = function(instance) {
- const index = this._instances.indexOf(instance);
- if (index !== -1) {
- this._instances.splice(index, 1);
- }
- this._detachCallbacks.forEach(callback => callback(instance));
-};
-
-GrDomHook.prototype.handleInstanceAttached = function(instance) {
- this._instances.push(instance);
- this._attachCallbacks.forEach(callback => callback(instance));
-};
-
-/**
- * Get instance of last DOM hook element attached into the endpoint.
- * Returns a Promise, that's resolved when attachment is done.
- *
- * @return {!Promise<!Element>}
- */
-GrDomHook.prototype.getLastAttached = function() {
- if (this._instances.length) {
- return Promise.resolve(this._instances.slice(-1)[0]);
- }
- if (!this._lastAttachedPromise) {
- let resolve;
- const promise = new Promise(r => resolve = r);
- this._attachCallbacks.push(resolve);
- this._lastAttachedPromise = promise.then(element => {
- this._lastAttachedPromise = null;
- const index = this._attachCallbacks.indexOf(resolve);
- if (index !== -1) {
- this._attachCallbacks.splice(index, 1);
- }
- return element;
- });
- }
- return this._lastAttachedPromise;
-};
-
-/**
- * Get all DOM hook elements.
- */
-GrDomHook.prototype.getAllAttached = function() {
- return this._instances;
-};
-
-/**
- * Install a new callback to invoke when a new instance of DOM hook element
- * is attached.
- *
- * @param {function(Element)} callback
- */
-GrDomHook.prototype.onAttached = function(callback) {
- this._attachCallbacks.push(callback);
- return this;
-};
-
-/**
- * Install a new callback to invoke when an instance of DOM hook element
- * is detached.
- *
- * @param {function(Element)} callback
- */
-GrDomHook.prototype.onDetached = function(callback) {
- this._detachCallbacks.push(callback);
- return this;
-};
-
-/**
- * Name of DOM hook element that will be installed into the endpoint.
- */
-GrDomHook.prototype.getModuleName = function() {
- return this._moduleName;
-};
-
-GrDomHook.prototype.getPublicAPI = function() {
- const result = {};
- const exposedMethods = [
- 'onAttached',
- 'onDetached',
- 'getLastAttached',
- 'getAllAttached',
- 'getModuleName',
- ];
- for (const p of exposedMethods) {
- result[p] = this[p].bind(this);
- }
- return result;
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
new file mode 100644
index 0000000..dd76be4
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.ts
@@ -0,0 +1,161 @@
+/**
+ * @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 {PolymerElement} from '@polymer/polymer/polymer-element';
+import {HookApi, HookCallback, PluginApi} from '../gr-plugin-types';
+
+export class GrDomHooksManager {
+ private _hooks: Record<string, GrDomHook>;
+
+ private _plugin: PluginApi;
+
+ constructor(plugin: PluginApi) {
+ this._plugin = plugin;
+ this._hooks = {};
+ }
+
+ _getHookName(endpointName: string, moduleName?: string) {
+ if (moduleName) {
+ return endpointName + ' ' + moduleName;
+ } else {
+ // lowercase in case plugin's name contains uppercase letters
+ // TODO: this still can not prevent if plugin has invalid char
+ // other than uppercase, but is the first step
+ // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name
+ const pluginName: string =
+ this._plugin.getPluginName() || 'unknown_plugin';
+ return pluginName.toLowerCase() + '-autogenerated-' + endpointName;
+ }
+ }
+
+ getDomHook(endpointName: string, moduleName?: string) {
+ const hookName = this._getHookName(endpointName, moduleName);
+ if (!this._hooks[hookName]) {
+ this._hooks[hookName] = new GrDomHook(hookName, moduleName);
+ }
+ return this._hooks[hookName];
+ }
+}
+
+export class GrDomHook implements HookApi {
+ private _instances: HTMLElement[] = [];
+
+ private _attachCallbacks: HookCallback[] = [];
+
+ private _detachCallbacks: HookCallback[] = [];
+
+ private _moduleName: string;
+
+ private _lastAttachedPromise: Promise<HTMLElement> | null = null;
+
+ constructor(hookName: string, moduleName?: string) {
+ if (moduleName) {
+ this._moduleName = moduleName;
+ } else {
+ this._moduleName = hookName;
+ this._createPlaceholder(hookName);
+ }
+ }
+
+ _createPlaceholder(hookName: string) {
+ class HookPlaceholder extends PolymerElement {
+ static get is() {
+ return hookName;
+ }
+
+ static get properties() {
+ return {
+ plugin: Object,
+ content: Object,
+ };
+ }
+ }
+
+ customElements.define(HookPlaceholder.is, HookPlaceholder);
+ }
+
+ handleInstanceDetached(instance: HTMLElement) {
+ const index = this._instances.indexOf(instance);
+ if (index !== -1) {
+ this._instances.splice(index, 1);
+ }
+ this._detachCallbacks.forEach(callback => callback(instance));
+ }
+
+ handleInstanceAttached(instance: HTMLElement) {
+ this._instances.push(instance);
+ this._attachCallbacks.forEach(callback => callback(instance));
+ }
+
+ /**
+ * Get instance of last DOM hook element attached into the endpoint.
+ * Returns a Promise, that's resolved when attachment is done.
+ */
+ getLastAttached(): Promise<HTMLElement> {
+ if (this._instances.length) {
+ return Promise.resolve(this._instances.slice(-1)[0]);
+ }
+ if (!this._lastAttachedPromise) {
+ let resolve: HookCallback;
+ const promise = new Promise<HTMLElement>(r => {
+ resolve = r;
+ this._attachCallbacks.push(resolve);
+ });
+ this._lastAttachedPromise = promise.then((element: HTMLElement) => {
+ this._lastAttachedPromise = null;
+ const index = this._attachCallbacks.indexOf(resolve);
+ if (index !== -1) {
+ this._attachCallbacks.splice(index, 1);
+ }
+ return element;
+ });
+ }
+ return this._lastAttachedPromise;
+ }
+
+ /**
+ * Get all DOM hook elements.
+ */
+ getAllAttached() {
+ return this._instances;
+ }
+
+ /**
+ * Install a new callback to invoke when a new instance of DOM hook element
+ * is attached.
+ */
+ onAttached(callback: HookCallback) {
+ this._attachCallbacks.push(callback);
+ return this;
+ }
+
+ /**
+ * Install a new callback to invoke when an instance of DOM hook element
+ * is detached.
+ *
+ */
+ onDetached(callback: HookCallback) {
+ this._detachCallbacks.push(callback);
+ return this;
+ }
+
+ /**
+ * Name of DOM hook element that will be installed into the endpoint.
+ */
+ getModuleName() {
+ return this._moduleName;
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
index a2b92ec..49223b9 100644
--- a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.js
@@ -23,18 +23,8 @@
const pluginApi = _testOnly_initGerritPluginApi();
suite('gr-dom-hooks tests', () => {
- const PUBLIC_METHODS =[
- 'onAttached',
- 'onDetached',
- 'getLastAttached',
- 'getAllAttached',
- 'getModuleName',
- ];
-
let instance;
-
let hook;
- let hookInternal;
setup(() => {
let plugin;
@@ -46,16 +36,11 @@
suite('placeholder', () => {
setup(()=>{
sinon.stub(GrDomHook.prototype, '_createPlaceholder');
- hookInternal = instance.getDomHook('foo-bar');
- hook = hookInternal.getPublicAPI();
- });
-
- test('public hook API has only public methods', () => {
- assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+ hook = instance.getDomHook('foo-bar');
});
test('registers placeholder class', () => {
- assert.isTrue(hookInternal._createPlaceholder.calledWithExactly(
+ assert.isTrue(hook._createPlaceholder.calledWithExactly(
'testplugin-autogenerated-foo-bar'));
});
@@ -68,12 +53,7 @@
suite('custom element', () => {
setup(() => {
- hookInternal = instance.getDomHook('foo-bar', 'my-el');
- hook = hookInternal.getPublicAPI();
- });
-
- test('public hook API has only public methods', () => {
- assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+ hook = instance.getDomHook('foo-bar', 'my-el');
});
test('getModuleName()', () => {
@@ -89,8 +69,8 @@
document.createElement(hook.getModuleName()),
document.createElement(hook.getModuleName()),
];
- hookInternal.handleInstanceAttached(el1);
- hookInternal.handleInstanceAttached(el2);
+ hook.handleInstanceAttached(el1);
+ hook.handleInstanceAttached(el2);
assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
});
@@ -102,9 +82,9 @@
document.createElement(hook.getModuleName()),
document.createElement(hook.getModuleName()),
];
- hookInternal.handleInstanceDetached(el1);
+ hook.handleInstanceDetached(el1);
assert.isTrue(onDetachedSpy.firstCall.calledWithExactly(el1));
- hookInternal.handleInstanceDetached(el2);
+ hook.handleInstanceDetached(el2);
assert.isTrue(onDetachedSpy.secondCall.calledWithExactly(el2));
});
@@ -115,10 +95,10 @@
];
el1.textContent = 'one';
el2.textContent = 'two';
- hookInternal.handleInstanceAttached(el1);
- hookInternal.handleInstanceAttached(el2);
+ hook.handleInstanceAttached(el1);
+ hook.handleInstanceAttached(el2);
assert.deepEqual([el1, el2], hook.getAllAttached());
- hookInternal.handleInstanceDetached(el1);
+ hook.handleInstanceDetached(el1);
assert.deepEqual([el2], hook.getAllAttached());
});
@@ -131,8 +111,8 @@
];
el1.textContent = 'one';
el2.textContent = 'two';
- hookInternal.handleInstanceAttached(el1);
- hookInternal.handleInstanceAttached(el2);
+ hook.handleInstanceAttached(el1);
+ hook.handleInstanceAttached(el2);
const afterAttachedPromise = hook.getLastAttached().then(
el => assert.strictEqual(el2, el));
return Promise.all([
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
deleted file mode 100644
index c91819a..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
+++ /dev/null
@@ -1,177 +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 '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-endpoint-decorator_html.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-const INIT_PROPERTIES_TIMEOUT_MS = 10000;
-
-/** @extends PolymerElement */
-class GrEndpointDecorator extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-endpoint-decorator'; }
-
- static get properties() {
- return {
- name: String,
- /** @type {!Map} */
- _domHooks: {
- type: Map,
- value() { return new Map(); },
- },
- /**
- * This map prevents importing the same endpoint twice.
- * Without caching, if a plugin is loaded after the loaded plugins
- * callback fires, it will be imported twice and appear twice on the page.
- *
- * @type {!Map}
- */
- _initializedPlugins: {
- type: Map,
- value() { return new Map(); },
- },
- };
- }
-
- /** @override */
- detached() {
- super.detached();
- for (const [el, domHook] of this._domHooks) {
- domHook.handleInstanceDetached(el);
- }
- pluginEndpoints.onDetachedEndpoint(this.name, this._endpointCallBack);
- }
-
- _initDecoration(name, plugin, slot) {
- const el = document.createElement(name);
- return this._initProperties(el, plugin,
- this.getContentChildren().find(
- el => el.nodeName !== 'GR-ENDPOINT-PARAM'))
- .then(el => {
- const slotEl = slot ?
- dom(this).querySelector(`gr-endpoint-slot[name=${slot}]`) :
- null;
- if (slot && slotEl) {
- slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
- } else {
- this._appendChild(el);
- }
- return el;
- });
- }
-
- _initReplacement(name, plugin) {
- this.getContentChildNodes()
- .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
- .forEach(node => node.remove());
- const el = document.createElement(name);
- return this._initProperties(el, plugin).then(
- el => this._appendChild(el));
- }
-
- _getEndpointParams() {
- return Array.from(
- dom(this).querySelectorAll('gr-endpoint-param'));
- }
-
- /**
- * @param {!Element} el
- * @param {!Object} plugin
- * @param {!Element=} opt_content
- * @return {!Promise<Element>}
- */
- _initProperties(el, plugin, opt_content) {
- el.plugin = plugin;
- if (opt_content) {
- el.content = opt_content;
- }
- const expectProperties = this._getEndpointParams().map(paramEl => {
- const helper = plugin.attributeHelper(paramEl);
- const paramName = paramEl.getAttribute('name');
- return helper.get('value').then(
- value => helper.bind('value',
- value => plugin.attributeHelper(el).set(paramName, value))
- );
- });
- let timeoutId;
- const timeout = new Promise(
- resolve => timeoutId = setTimeout(() => {
- console.warn(
- 'Timeout waiting for endpoint properties initialization: ' +
- `plugin ${plugin.getPluginName()}, endpoint ${this.name}`);
- }, INIT_PROPERTIES_TIMEOUT_MS));
- return Promise.race([timeout, Promise.all(expectProperties)])
- .then(() => el)
- .finally(() => {
- if (timeoutId) clearTimeout(timeoutId);
- });
- }
-
- _appendChild(el) {
- return dom(this.root).appendChild(el);
- }
-
- _initModule({moduleName, plugin, type, domHook, slot}) {
- const name = plugin.getPluginName() + '.' + moduleName;
- if (this._initializedPlugins.get(name)) {
- return;
- }
- let initPromise;
- switch (type) {
- case 'decorate':
- initPromise = this._initDecoration(moduleName, plugin, slot);
- break;
- case 'replace':
- initPromise = this._initReplacement(moduleName, plugin);
- break;
- }
- if (!initPromise) {
- console.warn('Unable to initialize module ' + name);
- }
- this._initializedPlugins.set(name, true);
- initPromise.then(el => {
- domHook.handleInstanceAttached(el);
- this._domHooks.set(el, domHook);
- });
- }
-
- /** @override */
- ready() {
- super.ready();
- this._endpointCallBack = this._initModule.bind(this);
- pluginEndpoints.onNewEndpoint(this.name, this._endpointCallBack);
- if (this.name) {
- pluginLoader.awaitPluginsLoaded()
- .then(() => pluginEndpoints.getAndImportPlugins(this.name))
- .then(() =>
- pluginEndpoints
- .getDetails(this.name)
- .forEach(this._initModule, this)
- );
- }
- }
-}
-
-customElements.define(GrEndpointDecorator.is, GrEndpointDecorator);
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
new file mode 100644
index 0000000..3a83729
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.ts
@@ -0,0 +1,200 @@
+/**
+ * @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 '../../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-endpoint-decorator_html';
+import {
+ getPluginEndpoints,
+ ModuleInfo,
+} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {customElement, property} from '@polymer/decorators';
+import {HookApi, PluginApi} from '../gr-plugin-types';
+
+const INIT_PROPERTIES_TIMEOUT_MS = 10000;
+
+@customElement('gr-endpoint-decorator')
+class GrEndpointDecorator extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ name!: string;
+
+ @property({type: Object})
+ _domHooks = new Map<HTMLElement, HookApi>();
+
+ @property({type: Object})
+ _initializedPlugins = new Map<string, boolean>();
+
+ /**
+ * This is the callback that the plugin endpoint manager should be calling
+ * when a new element is registered for this endpoint. It points to
+ * _initModule().
+ */
+ _endpointCallBack: (info: ModuleInfo) => void = () => {};
+
+ /** @override */
+ detached() {
+ super.detached();
+ for (const [el, domHook] of this._domHooks) {
+ domHook.handleInstanceDetached(el);
+ }
+ getPluginEndpoints().onDetachedEndpoint(this.name, this._endpointCallBack);
+ }
+
+ _initDecoration(
+ name: string,
+ plugin: PluginApi,
+ slot?: string
+ ): Promise<HTMLElement> {
+ const el = document.createElement(name);
+ return this._initProperties(
+ el,
+ plugin,
+ this.getContentChildren().find(el => el.nodeName !== 'GR-ENDPOINT-PARAM')
+ ).then(el => {
+ const slotEl = slot
+ ? this.querySelector(`gr-endpoint-slot[name=${slot}]`)
+ : null;
+ if (slot && slotEl?.parentNode) {
+ slotEl.parentNode.insertBefore(el, slotEl.nextSibling);
+ } else {
+ this._appendChild(el);
+ }
+ return el;
+ });
+ }
+
+ _initReplacement(name: string, plugin: PluginApi): Promise<HTMLElement> {
+ this.getContentChildNodes()
+ .filter(node => node.nodeName !== 'GR-ENDPOINT-PARAM')
+ .forEach(node => (node as ChildNode).remove());
+ const el = document.createElement(name);
+ return this._initProperties(el, plugin).then((el: HTMLElement) =>
+ this._appendChild(el)
+ );
+ }
+
+ _getEndpointParams() {
+ return Array.from(this.querySelectorAll('gr-endpoint-param'));
+ }
+
+ _initProperties(
+ htmlEl: HTMLElement,
+ plugin: PluginApi,
+ content?: HTMLElement
+ ) {
+ const el = htmlEl as HTMLElement & {
+ plugin?: PluginApi;
+ content?: HTMLElement;
+ };
+ el.plugin = plugin;
+ if (content) {
+ el.content = content;
+ }
+ const expectProperties = this._getEndpointParams().map(paramEl => {
+ const helper = plugin.attributeHelper(paramEl);
+ // TODO: this should be replaced by accessing the property directly
+ const paramName = paramEl.getAttribute('name');
+ if (!paramName) throw Error('plugin endpoint parameter missing a name');
+ return helper
+ .get('value')
+ .then(() =>
+ helper.bind('value', value =>
+ plugin.attributeHelper(el).set(paramName, value)
+ )
+ );
+ });
+ // TODO(TS): Should be a number, but TS thinks that is must be some weird
+ // NodeJS.Timeout object.
+ let timeoutId: any;
+ const timeout = new Promise(
+ () =>
+ (timeoutId = setTimeout(() => {
+ console.warn(
+ 'Timeout waiting for endpoint properties initialization: ' +
+ `plugin ${plugin.getPluginName()}, endpoint ${this.name}`
+ );
+ }, INIT_PROPERTIES_TIMEOUT_MS))
+ );
+ return Promise.race([timeout, Promise.all(expectProperties)])
+ .then(() => el)
+ .finally(() => {
+ if (timeoutId) clearTimeout(timeoutId);
+ });
+ }
+
+ _appendChild(el: HTMLElement): HTMLElement {
+ if (!this.root) throw Error('plugin endpoint decorator missing root');
+ return this.root.appendChild(el);
+ }
+
+ _initModule({moduleName, plugin, type, domHook, slot}: ModuleInfo) {
+ const name = plugin.getPluginName() + '.' + moduleName;
+ if (this._initializedPlugins.get(name)) {
+ return;
+ }
+ let initPromise;
+ switch (type) {
+ case 'decorate':
+ initPromise = this._initDecoration(moduleName, plugin, slot);
+ break;
+ case 'replace':
+ initPromise = this._initReplacement(moduleName, plugin);
+ break;
+ }
+ if (!initPromise) {
+ throw Error(`unknown endpoint type ${type} used by plugin ${name}`);
+ }
+ this._initializedPlugins.set(name, true);
+ initPromise.then(el => {
+ if (domHook) {
+ domHook.handleInstanceAttached(el);
+ this._domHooks.set(el, domHook);
+ }
+ });
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._endpointCallBack = (info: ModuleInfo) => this._initModule(info);
+ getPluginEndpoints().onNewEndpoint(this.name, this._endpointCallBack);
+ if (this.name) {
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => getPluginEndpoints().getAndImportPlugins(this.name))
+ .then(() =>
+ getPluginEndpoints()
+ .getDetails(this.name)
+ .forEach(this._initModule, this)
+ );
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-endpoint-decorator': GrEndpointDecorator;
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
index 72c73b7..c48258d 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
@@ -19,11 +19,10 @@
import './gr-endpoint-decorator.js';
import '../gr-endpoint-param/gr-endpoint-param.js';
import '../gr-endpoint-slot/gr-endpoint-slot.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {resetPlugins} from '../../../test/test-utils.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
const pluginApi = _testOnly_initGerritPluginApi();
@@ -57,7 +56,7 @@
setup(done => {
resetPlugins();
container = basicFixture.instantiate();
- sinon.stub(pluginEndpoints, 'importUrl')
+ sinon.stub(getPluginEndpoints(), 'importUrl')
.callsFake( url => Promise.resolve());
pluginApi.install(p => plugin = p, '0.1',
'http://some/plugin/url.html');
@@ -72,7 +71,7 @@
replacementHook = plugin.registerCustomComponent(
'second', 'other-module', {replace: true});
// Mimic all plugins loaded.
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
flush(done);
});
@@ -84,7 +83,7 @@
const endpoints =
Array.from(container.querySelectorAll('gr-endpoint-decorator'));
assert.equal(endpoints.length, 3);
- assert.isTrue(pluginEndpoints.importUrl.calledWith(
+ assert.isTrue(getPluginEndpoints().importUrl.calledWith(
new URL('http://some/plugin/url.html')
));
});
@@ -92,7 +91,7 @@
test('decoration', () => {
const element =
container.querySelector('gr-endpoint-decorator[name="first"]');
- const modules = Array.from(dom(element.root).children).filter(
+ const modules = Array.from(element.root.children).filter(
element => element.nodeName === 'SOME-MODULE');
assert.equal(modules.length, 1);
const [module] = modules;
@@ -111,7 +110,7 @@
test('decoration with slot', () => {
const element =
container.querySelector('gr-endpoint-decorator[name="first"]');
- const modules = [...dom(element).querySelectorAll('some-module-2')];
+ const modules = [...element.querySelectorAll('some-module-2')];
assert.equal(modules.length, 1);
const [module] = modules;
assert.isOk(module);
@@ -129,7 +128,7 @@
test('replacement', () => {
const element =
container.querySelector('gr-endpoint-decorator[name="second"]');
- const module = Array.from(dom(element.root).children).find(
+ const module = Array.from(element.root.children).find(
element => element.nodeName === 'OTHER-MODULE');
assert.isOk(module);
assert.equal(module['someparam'], 'foofoo');
@@ -148,7 +147,7 @@
flush(() => {
const element =
container.querySelector('gr-endpoint-decorator[name="banana"]');
- const module = Array.from(dom(element.root).children).find(
+ const module = Array.from(element.root.children).find(
element => element.nodeName === 'NOOB-NOOB');
assert.isOk(module);
done();
@@ -161,10 +160,10 @@
flush(() => {
const element =
container.querySelector('gr-endpoint-decorator[name="banana"]');
- const module1 = Array.from(dom(element.root).children).find(
+ const module1 = Array.from(element.root.children).find(
element => element.nodeName === 'MOD-ONE');
assert.isOk(module1);
- const module2 = Array.from(dom(element.root).children).find(
+ const module2 = Array.from(element.root.children).find(
element => element.nodeName === 'MOD-TWO');
assert.isOk(module2);
done();
@@ -174,18 +173,18 @@
test('late param setup', done => {
const element =
container.querySelector('gr-endpoint-decorator[name="banana"]');
- const param = dom(element).querySelector('gr-endpoint-param');
+ const param = element.querySelector('gr-endpoint-param');
param['value'] = undefined;
plugin.registerCustomComponent('banana', 'noob-noob');
flush(() => {
- let module = Array.from(dom(element.root).children).find(
+ let module = Array.from(element.root.children).find(
element => element.nodeName === 'NOOB-NOOB');
// Module waits for param to be defined.
assert.isNotOk(module);
const value = {abc: 'def'};
param.value = value;
flush(() => {
- module = Array.from(dom(element.root).children).find(
+ module = Array.from(element.root.children).find(
element => element.nodeName === 'NOOB-NOOB');
assert.isOk(module);
assert.strictEqual(module['someParam'], value);
@@ -197,13 +196,13 @@
test('param is bound', done => {
const element =
container.querySelector('gr-endpoint-decorator[name="banana"]');
- const param = dom(element).querySelector('gr-endpoint-param');
+ const param = element.querySelector('gr-endpoint-param');
const value1 = {abc: 'def'};
const value2 = {def: 'abc'};
param.value = value1;
plugin.registerCustomComponent('banana', 'noob-noob');
flush(() => {
- const module = Array.from(dom(element.root).children).find(
+ const module = Array.from(element.root.children).find(
element => element.nodeName === 'NOOB-NOOB');
assert.strictEqual(module['someParam'], value1);
param.value = value2;
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
deleted file mode 100644
index 82402c0..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
+++ /dev/null
@@ -1,54 +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 {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';
-
-/** @extends PolymerElement */
-class GrEndpointParam extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get is() { return 'gr-endpoint-param'; }
-
- static get properties() {
- return {
- name: String,
- value: {
- type: Object,
- notify: true,
- observer: '_valueChanged',
- },
- };
- }
-
- _valueChanged(newValue, oldValue) {
- /* In polymer 2 the following change was made:
- "Property change notifications (property-changed events) aren't fired when
- the value changes as a result of a binding from the host"
- (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
- To workaround this problem, we fire the event from the observer.
- In some cases this fire the event twice, but our code is
- ready for it.
- */
- const detail = {
- value: newValue,
- };
- this.dispatchEvent(new CustomEvent('value-changed', {detail}));
- }
-}
-
-customElements.define(GrEndpointParam.is, GrEndpointParam);
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
new file mode 100644
index 0000000..9e9f348
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.ts
@@ -0,0 +1,53 @@
+/**
+ * @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 {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 {customElement, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-endpoint-param': GrEndpointParam;
+ }
+}
+
+@customElement('gr-endpoint-param')
+export class GrEndpointParam extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ @property({type: String, reflectToAttribute: true})
+ name = '';
+
+ @property({
+ type: Object,
+ notify: true,
+ observer: '_valueChanged',
+ })
+ value?: unknown;
+
+ _valueChanged(value: unknown) {
+ /* In polymer 2 the following change was made:
+ "Property change notifications (property-changed events) aren't fired when
+ the value changes as a result of a binding from the host"
+ (see https://polymer-library.polymer-project.org/2.0/docs/about_20).
+ To workaround this problem, we fire the event from the observer.
+ In some cases this fire the event twice, but our code is
+ ready for it.
+ */
+ this.dispatchEvent(new CustomEvent('value-changed', {detail: {value}}));
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.js
deleted file mode 100644
index 9ee9c3d..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * @license
- * 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.
- */
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-
-class GrEndpointSlot extends PolymerElement {
- static get is() { return 'gr-endpoint-slot'; }
-
- static get properties() {
- return {
- name: String,
- };
- }
-}
-
-customElements.define(GrEndpointSlot.is, GrEndpointSlot);
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
new file mode 100644
index 0000000..4999716
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-slot/gr-endpoint-slot.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * 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.
+ */
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {customElement, property} from '@polymer/decorators';
+
+/**
+ * `gr-endpoint-slot` is used when need control over where
+ * the registered element should appear inside of the endpoint.
+ */
+@customElement('gr-endpoint-slot')
+export class GrEndpointSlot extends PolymerElement {
+ @property({type: String})
+ name!: string;
+}
+
+/**
+ * Mark name as required as `gr-endpoint-slot` without a name
+ * is meaningless.
+ *
+ * This should help catch errors when you assign an element without
+ * name to GrEndpointSlot type.
+ */
+export interface GrEndpointSlot extends PolymerElement {
+ name: string;
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
deleted file mode 100644
index 466f84a..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
+++ /dev/null
@@ -1,102 +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.
- */
-
-/** @constructor */
-export function GrEventHelper(element) {
- this.element = element;
- this._unsubscribers = [];
-}
-
-/**
- * Add a callback to arbitrary event.
- * The callback may return false to prevent event bubbling.
- *
- * @param {string} event Event name
- * @param {function(Event):boolean} callback
- * @return {function()} Unsubscribe function.
- */
-GrEventHelper.prototype.on = function(event, callback) {
- return this._listen(this.element, callback, {event});
-};
-
-/**
- * Alias of onClick
- *
- * @see onClick
- */
-GrEventHelper.prototype.onTap = function(callback) {
- return this._listen(this.element, callback);
-};
-
-/**
- * Add a callback to element click or touch.
- * The callback may return false to prevent event bubbling.
- *
- * @param {function(Event):boolean} callback
- * @return {function()} Unsubscribe function.
- */
-GrEventHelper.prototype.onClick = function(callback) {
- return this._listen(this.element, callback);
-};
-
-/**
- * Alias of captureClick
- *
- * @see captureClick
- */
-GrEventHelper.prototype.captureTap = function(callback) {
- return this._listen(this.element.parentElement, callback, {capture: true});
-};
-
-/**
- * Add a callback to element click or touch ahead of normal flow.
- * Callback is installed on parent during capture phase.
- * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
- * The callback may return false to cancel regular event listeners.
- *
- * @param {function(Event):boolean} callback
- * @return {function()} Unsubscribe function.
- */
-GrEventHelper.prototype.captureClick = function(callback) {
- return this._listen(this.element.parentElement, callback, {capture: true});
-};
-
-GrEventHelper.prototype._listen = function(container, callback, opt_options) {
- const capture = opt_options && opt_options.capture;
- const event = opt_options && opt_options.event || 'click';
- const handler = e => {
- if (e.path.indexOf(this.element) !== -1) {
- let mayContinue = true;
- try {
- mayContinue = callback(e);
- } catch (e) {
- console.warn(`Plugin error handing event: ${e}`);
- }
- if (mayContinue === false) {
- e.stopImmediatePropagation();
- e.stopPropagation();
- e.preventDefault();
- }
- }
- };
- container.addEventListener(event, handler, capture);
- const unsubscribe = () =>
- container.removeEventListener(event, handler, capture);
- this._unsubscribers.push(unsubscribe);
- return unsubscribe;
-};
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
new file mode 100644
index 0000000..02fcdec
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.ts
@@ -0,0 +1,95 @@
+/**
+ * @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.
+ */
+
+export interface ListenOptions {
+ event?: string;
+ capture?: boolean;
+}
+
+export class GrEventHelper {
+ constructor(readonly element: HTMLElement) {}
+
+ /**
+ * Add a callback to arbitrary event.
+ * The callback may return false to prevent event bubbling.
+ */
+ on(event: string, callback: (event: Event) => boolean) {
+ return this._listen(this.element, callback, {event});
+ }
+
+ /**
+ * Alias for @see onClick
+ */
+ onTap(callback: (event: Event) => boolean) {
+ return this.onClick(callback);
+ }
+
+ /**
+ * Add a callback to element click or touch.
+ * The callback may return false to prevent event bubbling.
+ */
+ onClick(callback: (event: Event) => boolean) {
+ return this._listen(this.element, callback);
+ }
+
+ /**
+ * Alias for @see captureClick
+ */
+ captureTap(callback: (event: Event) => boolean) {
+ this.captureClick(callback);
+ }
+
+ /**
+ * Add a callback to element click or touch ahead of normal flow.
+ * Callback is installed on parent during capture phase.
+ * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
+ * The callback may return false to cancel regular event listeners.
+ */
+ captureClick(callback: (event: Event) => boolean) {
+ const parent = this.element.parentElement!;
+ return this._listen(parent, callback, {capture: true});
+ }
+
+ _listen(
+ container: HTMLElement,
+ callback: (event: Event) => boolean,
+ options?: ListenOptions | null
+ ) {
+ const capture = options?.capture;
+ const event = options?.event || 'click';
+ const handler = (e: Event) => {
+ if (!e.path) return;
+ if (e.path.indexOf(this.element) !== -1) {
+ let mayContinue = true;
+ try {
+ mayContinue = callback(e);
+ } catch (exception) {
+ console.warn(`Plugin error handing event: ${exception}`);
+ }
+ if (mayContinue === false) {
+ e.stopImmediatePropagation();
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }
+ };
+ container.addEventListener(event, handler, capture);
+ const unsubscribe = () =>
+ container.removeEventListener(event, handler, capture);
+ return unsubscribe;
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
index e56278f..25c0d43 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
@@ -54,7 +54,7 @@
addListener(element.parentElement, 'tap', tapStub);
instance.onTap(() => false);
MockInteractions.tap(element);
- flushAsynchronousOperations();
+ flush();
assert.isFalse(tapStub.called);
});
@@ -63,7 +63,7 @@
element.parentElement.addEventListener('click', tapStub);
instance.onTap(() => false);
MockInteractions.tap(element);
- flushAsynchronousOperations();
+ flush();
assert.isFalse(tapStub.called);
});
@@ -86,7 +86,7 @@
addListener(element.parentElement, 'tap', tapStub);
instance.captureTap(() => false);
MockInteractions.tap(element);
- flushAsynchronousOperations();
+ flush();
assert.isFalse(tapStub.called);
});
@@ -95,7 +95,7 @@
element.addEventListener('click', tapStub);
instance.captureTap(() => false);
MockInteractions.tap(element);
- flushAsynchronousOperations();
+ flush();
assert.isFalse(tapStub.called);
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
deleted file mode 100644
index 16176b3..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
+++ /dev/null
@@ -1,82 +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 '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin.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-external-style_html.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-/** @extends PolymerElement */
-class GrExternalStyle extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-external-style'; }
-
- static get properties() {
- return {
- name: String,
- _stylesApplied: {
- type: Array,
- value() { return []; },
- },
- };
- }
-
- _applyStyle(name) {
- if (this._stylesApplied.includes(name)) { return; }
- this._stylesApplied.push(name);
-
- const s = document.createElement('style');
- s.setAttribute('include', name);
- const cs = document.createElement('custom-style');
- cs.appendChild(s);
- // When using Shadow DOM <custom-style> must be added to the <body>.
- // Within <gr-external-style> itself the styles would have no effect.
- const topEl = document.getElementsByTagName('body')[0];
- topEl.insertBefore(cs, topEl.firstChild);
- updateStyles();
- }
-
- _importAndApply() {
- pluginEndpoints.getAndImportPlugins(this.name)
- .then(() => {
- const moduleNames = pluginEndpoints.getModules(this.name);
- for (const name of moduleNames) {
- this._applyStyle(name);
- }
- });
- }
-
- /** @override */
- attached() {
- super.attached();
- this._importAndApply();
- }
-
- /** @override */
- ready() {
- super.ready();
- pluginLoader.awaitPluginsLoaded().then(() => this._importAndApply());
- }
-}
-
-customElements.define(GrExternalStyle.is, GrExternalStyle);
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
new file mode 100644
index 0000000..02786dd
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.ts
@@ -0,0 +1,89 @@
+/**
+ * @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 '../../shared/gr-js-api-interface/gr-js-api-interface';
+import {updateStyles} from '@polymer/polymer/lib/mixins/element-mixin';
+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-external-style_html';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {customElement, property} from '@polymer/decorators';
+
+@customElement('gr-external-style')
+class GrExternalStyle extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ // This is a required value for this component.
+ @property({type: String})
+ name!: string;
+
+ @property({type: Array})
+ _stylesApplied: string[] = [];
+
+ _applyStyle(name: string) {
+ if (this._stylesApplied.includes(name)) {
+ return;
+ }
+ this._stylesApplied.push(name);
+
+ const s = document.createElement('style');
+ s.setAttribute('include', name);
+ const cs = document.createElement('custom-style');
+ cs.appendChild(s);
+ // When using Shadow DOM <custom-style> must be added to the <body>.
+ // Within <gr-external-style> itself the styles would have no effect.
+ const topEl = document.getElementsByTagName('body')[0];
+ topEl.insertBefore(cs, topEl.firstChild);
+ updateStyles();
+ }
+
+ _importAndApply() {
+ getPluginEndpoints()
+ .getAndImportPlugins(this.name)
+ .then(() => {
+ const moduleNames = getPluginEndpoints().getModules(this.name);
+ for (const name of moduleNames) {
+ this._applyStyle(name);
+ }
+ });
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._importAndApply();
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => this._importAndApply());
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-external-style': GrExternalStyle;
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
index 6659207..ad30f48 100644
--- a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.js
@@ -18,8 +18,8 @@
import '../../../test/common-test-setup-karma.js';
import {resetPlugins} from '../../../test/test-utils.js';
import './gr-external-style.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {pluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
@@ -66,9 +66,9 @@
};
setup(() => {
- sinon.stub(pluginEndpoints, 'importUrl')
+ sinon.stub(getPluginEndpoints(), 'importUrl')
.callsFake( url => Promise.resolve());
- sinon.stub(pluginLoader, 'awaitPluginsLoaded')
+ sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
.returns(Promise.resolve());
});
@@ -81,7 +81,7 @@
test('imports plugin-provided module', async () => {
lateRegister();
await new Promise(flush);
- assert.isTrue(pluginEndpoints.importUrl.calledWith(new URL(TEST_URL)));
+ assert.isTrue(getPluginEndpoints().importUrl.calledWith(new URL(TEST_URL)));
});
test('applies plugin-provided styles', async () => {
@@ -96,7 +96,7 @@
plugin.registerStyleModule('foo', 'some-module');
await new Promise(flush);
// since loaded, should not call again
- assert.isFalse(pluginEndpoints.importUrl.calledOnce);
+ assert.isFalse(getPluginEndpoints().importUrl.calledOnce);
});
test('does not double apply', async () => {
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
deleted file mode 100644
index 46e37e1..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
+++ /dev/null
@@ -1,75 +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 '../../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 {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-
-/** @extends PolymerElement */
-class GrPluginHost extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get is() { return 'gr-plugin-host'; }
-
- static get properties() {
- return {
- config: {
- type: Object,
- observer: '_configChanged',
- },
- };
- }
-
- _configChanged(config) {
- const plugins = config.plugin;
- const htmlPlugins = (plugins && plugins.html_resource_paths || []);
- const jsPlugins = this._handleMigrations(
- plugins && plugins.js_resource_paths || [], htmlPlugins
- );
- const shouldLoadTheme = config.default_theme &&
- !pluginLoader.isPluginPreloaded('preloaded:gerrit-theme');
- const themeToLoad =
- shouldLoadTheme ? [config.default_theme] : [];
-
- // Theme should be loaded first if has one to have better UX
- const pluginsPending =
- themeToLoad.concat(jsPlugins, htmlPlugins);
-
- const pluginOpts = {};
-
- if (shouldLoadTheme) {
- // Theme needs to be loaded synchronous.
- pluginOpts[config.default_theme] = {sync: true};
- }
-
- pluginLoader.loadPlugins(pluginsPending, pluginOpts);
- }
-
- /**
- * Omit .js plugins that have .html counterparts.
- * For example, if plugin provides foo.js and foo.html, skip foo.js.
- */
- _handleMigrations(jsPlugins, htmlPlugins) {
- return jsPlugins.filter(url => {
- const counterpart = url.replace(/\.js$/, '.html');
- return !htmlPlugins.includes(counterpart);
- });
- }
-}
-
-customElements.define(GrPluginHost.is, GrPluginHost);
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
new file mode 100644
index 0000000..ed84406
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.ts
@@ -0,0 +1,77 @@
+/**
+ * @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 '../../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 {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {customElement, property} from '@polymer/decorators';
+import {ServerInfo} from '../../../types/common';
+
+@customElement('gr-plugin-host')
+class GrPluginHost extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ @property({type: Object, observer: '_configChanged'})
+ config?: ServerInfo;
+
+ _configChanged(config: ServerInfo) {
+ const plugins = config.plugin;
+ const htmlPlugins = (plugins && plugins.html_resource_paths) || [];
+ const jsPlugins = this._handleMigrations(
+ (plugins && plugins.js_resource_paths) || [],
+ htmlPlugins
+ );
+ const shouldLoadTheme =
+ !!config.default_theme &&
+ !getPluginLoader().isPluginPreloaded('preloaded:gerrit-theme');
+ // config.default_theme is defined when shouldLoadTheme is true
+ const themeToLoad: string[] = shouldLoadTheme
+ ? [config.default_theme!]
+ : [];
+
+ // Theme should be loaded first if has one to have better UX
+ const pluginsPending = themeToLoad.concat(jsPlugins, htmlPlugins);
+
+ const pluginOpts: {[key: string]: {sync: boolean}} = {};
+
+ if (shouldLoadTheme) {
+ // config.default_theme is defined when shouldLoadTheme is true
+ // Theme needs to be loaded synchronous.
+ pluginOpts[config.default_theme!] = {sync: true};
+ }
+
+ getPluginLoader().loadPlugins(pluginsPending, pluginOpts);
+ }
+
+ /**
+ * Omit .js plugins that have .html counterparts.
+ * For example, if plugin provides foo.js and foo.html, skip foo.js.
+ */
+ _handleMigrations(jsPlugins: string[], htmlPlugins: string[]) {
+ return jsPlugins.filter(url => {
+ const counterpart = url.replace(/\.js$/, '.html');
+ return !htmlPlugins.includes(counterpart);
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-plugin-host': GrPluginHost;
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
index 83e0a84..7a99dc4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-plugin-host.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
const basicFixture = fixtureFromElement('gr-plugin-host');
@@ -31,21 +31,21 @@
});
test('load plugins should be called', () => {
- sinon.stub(pluginLoader, 'loadPlugins');
+ sinon.stub(getPluginLoader(), 'loadPlugins');
element.config = {
plugin: {
html_resource_paths: ['plugins/foo/bar', 'plugins/baz'],
js_resource_paths: ['plugins/42'],
},
};
- assert.isTrue(pluginLoader.loadPlugins.calledOnce);
- assert.isTrue(pluginLoader.loadPlugins.calledWith([
+ assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
+ assert.isTrue(getPluginLoader().loadPlugins.calledWith([
'plugins/42', 'plugins/foo/bar', 'plugins/baz',
], {}));
});
test('theme plugins should be loaded if enabled', () => {
- sinon.stub(pluginLoader, 'loadPlugins');
+ sinon.stub(getPluginLoader(), 'loadPlugins');
element.config = {
default_theme: 'gerrit-theme.html',
plugin: {
@@ -53,23 +53,23 @@
js_resource_paths: ['plugins/42'],
},
};
- assert.isTrue(pluginLoader.loadPlugins.calledOnce);
- assert.isTrue(pluginLoader.loadPlugins.calledWith([
+ assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
+ assert.isTrue(getPluginLoader().loadPlugins.calledWith([
'gerrit-theme.html', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
], {'gerrit-theme.html': {sync: true}}));
});
test('skip theme if preloaded', () => {
- sinon.stub(pluginLoader, 'isPluginPreloaded')
+ sinon.stub(getPluginLoader(), 'isPluginPreloaded')
.withArgs('preloaded:gerrit-theme')
.returns(true);
- sinon.stub(pluginLoader, 'loadPlugins');
+ sinon.stub(getPluginLoader(), 'loadPlugins');
element.config = {
default_theme: '/oof',
plugin: {},
};
- assert.isTrue(pluginLoader.loadPlugins.calledOnce);
- assert.isTrue(pluginLoader.loadPlugins.calledWith([], {}));
+ assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
+ assert.isTrue(getPluginLoader().loadPlugins.calledWith([], {}));
});
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
new file mode 100644
index 0000000..de3eba5
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-types.ts
@@ -0,0 +1,103 @@
+/**
+ * @license
+ * 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.
+ */
+import {GrAttributeHelper} from './gr-attribute-helper/gr-attribute-helper';
+import {GrPluginRestApi} from '../shared/gr-js-api-interface/gr-plugin-rest-api';
+import {GrEventHelper} from './gr-event-helper/gr-event-helper';
+import {GrPopupInterface} from './gr-popup-interface/gr-popup-interface';
+import {ConfigInfo} from '../../types/common';
+import {GrChecksApi} from './gr-checks-api/gr-checks-api';
+
+interface GerritElementExtensions {
+ content?: HTMLElement & {hidden?: boolean};
+ change?: unknown;
+ revision?: unknown;
+ token?: string;
+ repoName?: string;
+ config?: ConfigInfo;
+}
+export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
+
+export interface HookApi {
+ onAttached(callback: HookCallback): HookApi;
+ onDetached(callback: HookCallback): HookApi;
+ getAllAttached(): HTMLElement[];
+ getLastAttached(): Promise<HTMLElement>;
+ getModuleName(): string;
+ handleInstanceDetached(instance: HTMLElement): void;
+ handleInstanceAttached(instance: HTMLElement): void;
+}
+
+export enum TargetElement {
+ CHANGE_ACTIONS = 'changeactions',
+ REPLY_DIALOG = 'replydialog',
+}
+
+// Note: for new events, naming convention should be: `a-b`
+export enum EventType {
+ HISTORY = 'history',
+ LABEL_CHANGE = 'labelchange',
+ SHOW_CHANGE = 'showchange',
+ SUBMIT_CHANGE = 'submitchange',
+ SHOW_REVISION_ACTIONS = 'show-revision-actions',
+ COMMIT_MSG_EDIT = 'commitmsgedit',
+ COMMENT = 'comment',
+ REVERT = 'revert',
+ REVERT_SUBMISSION = 'revert_submission',
+ POST_REVERT = 'postrevert',
+ ANNOTATE_DIFF = 'annotatediff',
+ ADMIN_MENU_LINKS = 'admin-menu-links',
+ HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
+}
+
+export interface RegisterOptions {
+ slot?: string;
+ replace: unknown;
+}
+
+export interface PanelInfo {
+ body: Element;
+ p: {[key: string]: any};
+ onUnload: () => void;
+}
+
+export interface SettingsInfo {
+ body: Element;
+ token?: string;
+ onUnload: () => void;
+ setTitle: () => void;
+ setWindowTitle: () => void;
+ show: () => void;
+}
+
+export interface PluginApi {
+ _url?: URL;
+ popup(): Promise<GrPopupInterface>;
+ popup(moduleName: string): Promise<GrPopupInterface>;
+ popup(moduleName?: string): Promise<GrPopupInterface | null>;
+ hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
+ getPluginName(): string;
+ on(eventName: string, target: any): void;
+ attributeHelper(element: Element): GrAttributeHelper;
+ checks(): GrChecksApi;
+ restApi(): GrPluginRestApi;
+ eventHelper(element: Node): GrEventHelper;
+ registerDynamicCustomComponent(
+ endpointName: string,
+ moduleName?: string,
+ options?: RegisterOptions
+ ): HookApi;
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
deleted file mode 100644
index eaecd29..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
+++ /dev/null
@@ -1,48 +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 '../../shared/gr-overlay/gr-overlay.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-plugin-popup_html.js';
-
-(function(window) {
- 'use strict';
-
- /** @extends PolymerElement */
- class GrPluginPopup extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-plugin-popup'; }
-
- get opened() {
- return this.$.overlay.opened;
- }
-
- open() {
- return this.$.overlay.open();
- }
-
- close() {
- this.$.overlay.close();
- }
- }
-
- customElements.define(GrPluginPopup.is, GrPluginPopup);
-})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
new file mode 100644
index 0000000..7c6587a
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.ts
@@ -0,0 +1,55 @@
+/**
+ * @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 '../../shared/gr-overlay/gr-overlay';
+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-plugin-popup_html';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {customElement} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-plugin-popup': GrPluginPopup;
+ }
+}
+
+export interface GrPluginPopup {
+ $: {
+ overlay: GrOverlay;
+ };
+}
+@customElement('gr-plugin-popup')
+export class GrPluginPopup extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ get opened() {
+ return this.$.overlay.opened;
+ }
+
+ open() {
+ return this.$.overlay.open();
+ }
+
+ close() {
+ this.$.overlay.close();
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
deleted file mode 100644
index e2dd047..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
+++ /dev/null
@@ -1,74 +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 './gr-plugin-popup.js';
-import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-/**
- * Plugin popup API.
- * Provides method for opening and closing popups from plugin.
- * opt_moduleName is a name of custom element that will be automatically
- * inserted on popup opening.
- *
- * @constructor
- * @param {!Object} plugin
- * @param {opt_moduleName=} string
- */
-export function GrPopupInterface(plugin, opt_moduleName) {
- this.plugin = plugin;
- this._openingPromise = null;
- this._popup = null;
- this._moduleName = opt_moduleName || null;
-}
-
-GrPopupInterface.prototype._getElement = function() {
- return dom(this._popup);
-};
-
-/**
- * Opens the popup, inserts it into DOM over current UI.
- * Creates the popup if not previously created. Creates popup content element,
- * if it was provided with constructor.
- *
- * @returns {!Promise<!Object>}
- */
-GrPopupInterface.prototype.open = function() {
- if (!this._openingPromise) {
- this._openingPromise =
- this.plugin.hook('plugin-overlay').getLastAttached()
- .then(hookEl => {
- const popup = document.createElement('gr-plugin-popup');
- if (this._moduleName) {
- const el = dom(popup).appendChild(
- document.createElement(this._moduleName));
- el.plugin = this.plugin;
- }
- this._popup = dom(hookEl).appendChild(popup);
- flush();
- return this._popup.open().then(() => this);
- });
- }
- return this._openingPromise;
-};
-
-/**
- * Hides the popup.
- */
-GrPopupInterface.prototype.close = function() {
- if (!this._popup) { return; }
- this._popup.close();
- this._openingPromise = null;
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
new file mode 100644
index 0000000..d45c263
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.ts
@@ -0,0 +1,84 @@
+/**
+ * @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 './gr-plugin-popup';
+import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GrPluginPopup} from './gr-plugin-popup';
+import {PluginApi} from '../gr-plugin-types';
+
+interface CustomPolymerPluginEl extends HTMLElement {
+ plugin: PluginApi;
+}
+
+/**
+ * Plugin popup API.
+ * Provides method for opening and closing popups from plugin.
+ * opt_moduleName is a name of custom element that will be automatically
+ * inserted on popup opening.
+ */
+export class GrPopupInterface {
+ private _openingPromise: Promise<GrPopupInterface> | null = null;
+
+ private _popup: GrPluginPopup | null = null;
+
+ constructor(
+ readonly plugin: PluginApi,
+ private _moduleName: string | null = null
+ ) {}
+
+ _getElement() {
+ // TODO(TS): maybe consider removing this if no one is using
+ // anything other than native methods on the return
+ return (dom(this._popup) as unknown) as HTMLElement;
+ }
+
+ /**
+ * Opens the popup, inserts it into DOM over current UI.
+ * Creates the popup if not previously created. Creates popup content element,
+ * if it was provided with constructor.
+ */
+ open(): Promise<GrPopupInterface> {
+ if (!this._openingPromise) {
+ this._openingPromise = this.plugin
+ .hook('plugin-overlay')
+ .getLastAttached()
+ .then(hookEl => {
+ const popup = document.createElement('gr-plugin-popup');
+ if (this._moduleName) {
+ const el = popup.appendChild(
+ document.createElement(this._moduleName) as CustomPolymerPluginEl
+ );
+ el.plugin = this.plugin;
+ }
+ this._popup = hookEl.appendChild(popup);
+ flush();
+ return this._popup.open().then(() => this);
+ });
+ }
+ return this._openingPromise;
+ }
+
+ /**
+ * Hides the popup.
+ */
+ close() {
+ if (!this._popup) {
+ return;
+ }
+ this._popup.close();
+ this._openingPromise = null;
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
index 9312332..be8836b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.js
@@ -17,11 +17,11 @@
import '../../../test/common-test-setup-karma.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GrPopupInterface} from './gr-popup-interface.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
+import {createIronOverlayBackdropStyleEl} from '../../../test/test-utils.js';
class GrUserTestPopupElement extends PolymerElement {
static get is() { return 'gr-user-test-popup'; }
@@ -40,8 +40,10 @@
let container;
let instance;
let plugin;
+ let ironOverlayBackdropStyleEl;
setup(() => {
+ ironOverlayBackdropStyleEl = createIronOverlayBackdropStyleEl();
pluginApi.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
container = containerFixture.instantiate();
@@ -52,6 +54,10 @@
});
});
+ teardown(() => {
+ ironOverlayBackdropStyleEl.remove();
+ });
+
suite('manual', () => {
setup(() => {
instance = new GrPopupInterface(plugin);
@@ -64,7 +70,7 @@
manual.id = 'foobar';
manual.innerHTML = 'manual content';
api._getElement().appendChild(manual);
- flushAsynchronousOperations();
+ flush();
assert.equal(
container.querySelector('#foobar').textContent, 'manual content');
done();
@@ -89,7 +95,7 @@
test('open', done => {
instance.open().then(api => {
assert.isNotNull(
- dom(container).querySelector('gr-user-test-popup'));
+ container.querySelector('gr-user-test-popup'));
done();
});
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
deleted file mode 100644
index 1a2cd28..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.js
+++ /dev/null
@@ -1,44 +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 {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-plugin-repo-command_html.js';
-
-class GrPluginRepoCommand extends PolymerElement {
- static get is() {
- return 'gr-plugin-repo-command';
- }
-
- static get properties() {
- return {
- title: String,
- repoName: String,
- config: Object,
- };
- }
-
- static get template() {
- return htmlTemplate;
- }
-
- _handleClick() {
- this.dispatchEvent(
- new CustomEvent('command-tap', {composed: true, bubbles: true})
- );
- }
-}
-
-customElements.define(GrPluginRepoCommand.is, GrPluginRepoCommand);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
new file mode 100644
index 0000000..b3a40c5
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-plugin-repo-command.ts
@@ -0,0 +1,40 @@
+/**
+ * @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 {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-plugin-repo-command_html';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-plugin-repo-command': GrPluginRepoCommand;
+ }
+}
+@customElement('gr-plugin-repo-command')
+export class GrPluginRepoCommand extends PolymerElement {
+ @property({type: String})
+ title = '';
+
+ static get template() {
+ return htmlTemplate;
+ }
+
+ _handleClick() {
+ this.dispatchEvent(
+ new CustomEvent('command-tap', {composed: true, bubbles: true})
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
deleted file mode 100644
index 04408f8..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.js
+++ /dev/null
@@ -1,57 +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 './gr-plugin-repo-command.js';
-
-/** @constructor */
-export function GrRepoApi(plugin) {
- this._hook = null;
- this.plugin = plugin;
-}
-
-GrRepoApi.prototype._createHook = function(title) {
- this._hook = this.plugin.hook('repo-command').onAttached(element => {
- const pluginCommand =
- document.createElement('gr-plugin-repo-command');
- pluginCommand.title = title;
- element.appendChild(pluginCommand);
- });
-};
-
-GrRepoApi.prototype.createCommand = function(title, callback) {
- if (this._hook) {
- console.warn('Already set up.');
- return this._hook;
- }
- this._createHook(title);
- this._hook.onAttached(element => {
- if (callback(element.repoName, element.config) === false) {
- element.hidden = true;
- }
- });
- return this;
-};
-
-GrRepoApi.prototype.onTap = function(callback) {
- if (!this._hook) {
- console.warn('Call createCommand first.');
- return this;
- }
- this._hook.onAttached(element => {
- this.plugin.eventHelper(element).on('command-tap', callback);
- });
- return this;
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
new file mode 100644
index 0000000..701a560
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api.ts
@@ -0,0 +1,69 @@
+/**
+ * @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 './gr-plugin-repo-command';
+import {ConfigInfo} from '../../../types/common';
+import {HookApi, PluginApi} from '../gr-plugin-types';
+
+type RepoCommandCallback = (repo?: string, config?: ConfigInfo) => boolean;
+
+/**
+ * Parameters provided on repo-command endpoint
+ */
+export interface GrRepoCommandEndpointEl extends HTMLElement {
+ repoName: string;
+ config: ConfigInfo;
+}
+
+export class GrRepoApi {
+ private _hook?: HookApi;
+
+ constructor(readonly plugin: PluginApi) {}
+
+ // TODO(TS): should mark as public since used in gr-change-metadata-api
+ _createHook(title: string) {
+ return this.plugin.hook('repo-command').onAttached(element => {
+ const pluginCommand = document.createElement('gr-plugin-repo-command');
+ pluginCommand.title = title;
+ element.appendChild(pluginCommand);
+ });
+ }
+
+ createCommand(title: string, callback: RepoCommandCallback) {
+ if (this._hook) {
+ console.warn('Already set up.');
+ return this._hook;
+ }
+ this._hook = this._createHook(title);
+ this._hook.onAttached(element => {
+ if (callback(element.repoName, element.config) === false) {
+ element.hidden = true;
+ }
+ });
+ return this;
+ }
+
+ onTap(callback: (event: Event) => boolean) {
+ if (!this._hook) {
+ console.warn('Call createCommand first.');
+ return this;
+ }
+ this._hook.onAttached(element => {
+ this.plugin.eventHelper(element).on('command-tap', callback);
+ });
+ return this;
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js
index c7f1b06..9e24fda 100644
--- a/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-repo-api/gr-repo-api_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
@@ -35,7 +35,7 @@
let plugin;
pluginApi.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
repoApi = plugin.project();
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
deleted file mode 100644
index 8050cd6..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.js
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Settings
- *
- * 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 '../../settings/gr-settings-view/gr-settings-item.js';
-import '../../settings/gr-settings-view/gr-settings-menu-item.js';
-
-/** @constructor */
-export function GrSettingsApi(plugin) {
- this._title = '(no title)';
- // Generate default screen URL token, specific to plugin, and unique(ish).
- this._token =
- plugin.getPluginName() + Math.random().toString(36)
- .substr(5);
- this.plugin = plugin;
-}
-
-GrSettingsApi.prototype.title = function(title) {
- this._title = title;
- return this;
-};
-
-GrSettingsApi.prototype.token = function(token) {
- this._token = token;
- return this;
-};
-
-GrSettingsApi.prototype.module = function(moduleName) {
- this._moduleName = moduleName;
- return this;
-};
-
-GrSettingsApi.prototype.build = function() {
- if (!this._moduleName) {
- throw new Error('Settings screen custom element not defined!');
- }
- const token = `x/${this.plugin.getPluginName()}/${this._token}`;
- this.plugin.hook('settings-menu-item').onAttached(el => {
- const menuItem = document.createElement('gr-settings-menu-item');
- menuItem.title = this._title;
- menuItem.href = `#${token}`;
- el.appendChild(menuItem);
- });
-
- return this.plugin.hook('settings-screen').onAttached(el => {
- const item = document.createElement('gr-settings-item');
- item.title = this._title;
- item.anchor = token;
- item.appendChild(document.createElement(this._moduleName));
- el.appendChild(item);
- });
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
new file mode 100644
index 0000000..c7f1ecd
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api.ts
@@ -0,0 +1,68 @@
+/**
+ * @license
+ * Copyright (C) 2017 The Android Open Source Settings
+ *
+ * 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 '../../settings/gr-settings-view/gr-settings-item';
+import '../../settings/gr-settings-view/gr-settings-menu-item';
+import {PluginApi} from '../gr-plugin-types';
+
+export class GrSettingsApi {
+ private _token: string;
+
+ private _title = '(no title)';
+
+ private _moduleName?: string;
+
+ constructor(readonly plugin: PluginApi) {
+ // Generate default screen URL token, specific to plugin, and unique(ish).
+ this._token = plugin.getPluginName() + Math.random().toString(36).substr(5);
+ }
+
+ title(newTitle: string) {
+ this._title = newTitle;
+ return this;
+ }
+
+ token(newToken: string) {
+ this._token = newToken;
+ return this;
+ }
+
+ module(newModuleName: string) {
+ this._moduleName = newModuleName;
+ return this;
+ }
+
+ build() {
+ if (!this._moduleName) {
+ throw new Error('Settings screen custom element not defined!');
+ }
+ const token = `x/${this.plugin.getPluginName()}/${this._token}`;
+ this.plugin.hook('settings-menu-item').onAttached(el => {
+ const menuItem = document.createElement('gr-settings-menu-item');
+ menuItem.title = this._title;
+ menuItem.setAttribute('href', `#${token}`);
+ el.appendChild(menuItem);
+ });
+ const moduleName = this._moduleName;
+ return this.plugin.hook('settings-screen').onAttached(el => {
+ const item = document.createElement('gr-settings-item');
+ item.title = this._title;
+ item.anchor = token;
+ item.appendChild(document.createElement(moduleName));
+ el.appendChild(item);
+ });
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.js b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.js
index 82d58fe..893f3e4 100644
--- a/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-settings-api/gr-settings-api_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
@@ -37,7 +37,7 @@
let plugin;
pluginApi.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
settingsApi = plugin.settings();
});
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
deleted file mode 100644
index 8a1b601..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.js
+++ /dev/null
@@ -1,75 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 {useShadow} from '@polymer/polymer/lib/utils/settings.js';
-
-let styleObjectCount = 0;
-
-/** @constructor */
-function GrStyleObject(rulesStr) {
- this._rulesStr = rulesStr;
- this._className = `__pg_js_api_class_${styleObjectCount}`;
- styleObjectCount++;
-}
-
-/**
- * Creates a new unique CSS class and injects it in a root node of the element
- * if it hasn't been added yet. A root node is an document or is the
- * associated shadowRoot. This class can be added to any element with the same
- * root node.
- *
- * @param {HTMLElement} element The element to get class name for.
- * @return {string} Appropriate class name for the element is returned
- */
-GrStyleObject.prototype.getClassName = function(element) {
- let rootNode = useShadow
- ? element.getRootNode() : document.body;
- if (rootNode === document) {
- rootNode = document.head;
- }
- if (!rootNode.__pg_js_api_style_tags) {
- rootNode.__pg_js_api_style_tags = {};
- }
- if (!rootNode.__pg_js_api_style_tags[this._className]) {
- const styleTag = document.createElement('style');
- styleTag.innerHTML = `.${this._className} { ${this._rulesStr} }`;
- rootNode.appendChild(styleTag);
- rootNode.__pg_js_api_style_tags[this._className] = true;
- }
- return this._className;
-};
-
-/**
- * Apply shared style to the element.
- *
- * @param {HTMLElement} element The element to apply style for
- */
-GrStyleObject.prototype.apply = function(element) {
- element.classList.add(this.getClassName(element));
-};
-
-export function GrStylesApi() {
-}
-
-/**
- * Creates a new GrStyleObject with specified style properties.
- *
- * @param {string} ruleStr with style properties.
- * @return {GrStyleObject}
- */
-GrStylesApi.prototype.css = function(ruleStr) {
- return new GrStyleObject(ruleStr);
-};
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
new file mode 100644
index 0000000..5c57208
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api.ts
@@ -0,0 +1,86 @@
+/**
+ * @license
+ * Copyright (C) 2019 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.
+ */
+
+/**
+ * @fileoverview We should consider dropping support for this API:
+ *
+ * 1. we need to try avoid using `innerHTML` for xss concerns
+ * 2. we have css variables which are more recommended way to custom styling
+ */
+
+let styleObjectCount = 0;
+
+interface PgElement extends Element {
+ __pg_js_api_style_tags: {
+ [className: string]: boolean;
+ };
+}
+
+export class GrStyleObject {
+ private className = '';
+
+ constructor(private readonly rulesStr: string) {
+ this.className = `__pg_js_api_class_${styleObjectCount}`;
+ styleObjectCount++;
+ }
+
+ /**
+ * Creates a new unique CSS class and injects it in a root node of the element
+ * if it hasn't been added yet. A root node is an document or is the
+ * associated shadowRoot. This class can be added to any element with the same
+ * root node.
+ */
+ getClassName(element: Element) {
+ let rootNodeEl = element.getRootNode();
+ if (rootNodeEl === document) {
+ rootNodeEl = document.head;
+ }
+ // TODO(TS): type casting to have correct interface
+ // maybe move this __pg_xxx to attribute
+ const rootNode: PgElement = rootNodeEl as PgElement;
+ if (!rootNode.__pg_js_api_style_tags) {
+ rootNode.__pg_js_api_style_tags = {};
+ }
+ if (!rootNode.__pg_js_api_style_tags[this.className]) {
+ const styleTag = document.createElement('style');
+ styleTag.innerHTML = `.${this.className} { ${this.rulesStr} }`;
+ rootNode.appendChild(styleTag);
+ rootNode.__pg_js_api_style_tags[this.className] = true;
+ }
+ return this.className;
+ }
+
+ /**
+ * Apply shared style to the element.
+ *
+ */
+ apply(element: Element) {
+ element.classList.add(this.getClassName(element));
+ }
+}
+
+/**
+ * TODO(TS): move to util
+ */
+export class GrStylesApi {
+ /**
+ * Creates a new GrStyleObject with specified style properties.
+ */
+ css(ruleStr: string) {
+ return new GrStyleObject(ruleStr);
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
index 5ccda28..c41b551 100644
--- a/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-styles-api/gr-styles-api_test.js
@@ -18,8 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import '../../shared/gr-js-api-interface/gr-js-api-interface.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
@@ -42,7 +41,7 @@
let plugin;
pluginApi.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
stylesApi = plugin.styles();
});
@@ -69,7 +68,7 @@
let plugin;
pluginApi.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
stylesApi = plugin.styles();
displayInlineStyle = stylesApi.css('display: inline');
displayNoneStyle = stylesApi.css('display: none');
@@ -96,9 +95,9 @@
const element1 = document.createElement('div');
const element2 = document.createElement('div');
const element3 = document.createElement('div');
- dom(parentElement).appendChild(element1);
- dom(parentElement).appendChild(element2);
- dom(element2).appendChild(element3);
+ parentElement.appendChild(element1);
+ parentElement.appendChild(element2);
+ element2.appendChild(element3);
if (parentElement === document.body) {
elementsToRemove.push(element1);
@@ -116,7 +115,7 @@
test('getClassName - elements inside polymer element', () => {
const polymerElement = document.createElement('gr-style-test-element');
- dom(document.body).appendChild(polymerElement);
+ document.body.appendChild(polymerElement);
elementsToRemove.push(polymerElement);
const contentElements = createNestedElements(polymerElement.$.wrapper);
@@ -150,7 +149,7 @@
test('apply - elements inside polymer element', () => {
const polymerElement = document.createElement('gr-style-test-element');
- dom(document.body).appendChild(polymerElement);
+ document.body.appendChild(polymerElement);
elementsToRemove.push(polymerElement);
const contentElements = createNestedElements(polymerElement.$.wrapper);
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
deleted file mode 100644
index 1e37603..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.js
+++ /dev/null
@@ -1,52 +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 {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-class CustomPluginHeader extends PolymerElement {
- static get is() {
- return 'gr-custom-plugin-header';
- }
-
- static get properties() {
- return {
- logoUrl: String,
- title: String,
- };
- }
-
- static get template() {
- return html`
- <style>
- img {
- width: 1em;
- height: 1em;
- vertical-align: middle;
- }
- .title {
- margin-left: var(--spacing-xs);
- }
- </style>
- <span>
- <img src="[[logoUrl]]" hidden\$="[[!logoUrl]]">
- <span class="title">[[title]]</span>
- </span>
-`;
- }
-}
-
-customElements.define(CustomPluginHeader.is, CustomPluginHeader);
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
new file mode 100644
index 0000000..7d82ff4
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
@@ -0,0 +1,53 @@
+/**
+ * @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 {PolymerElement} from '@polymer/polymer/polymer-element';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-custom-plugin-header': GrCustomPluginHeader;
+ }
+}
+
+@customElement('gr-custom-plugin-header')
+export class GrCustomPluginHeader extends PolymerElement {
+ @property({type: String})
+ logoUrl = '';
+
+ @property({type: String})
+ title = '';
+
+ static get template() {
+ return html`
+ <style>
+ img {
+ width: 1em;
+ height: 1em;
+ vertical-align: middle;
+ }
+ .title {
+ margin-left: var(--spacing-xs);
+ }
+ </style>
+ <span>
+ <img src="[[logoUrl]]" hidden$="[[!logoUrl]]" />
+ <span class="title">[[title]]</span>
+ </span>
+ `;
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
deleted file mode 100644
index 48b14d3..0000000
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
+++ /dev/null
@@ -1,34 +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 './gr-custom-plugin-header.js';
-
-/** @constructor */
-export function GrThemeApi(plugin) {
- this.plugin = plugin;
-}
-
-GrThemeApi.prototype.setHeaderLogoAndTitle = function(logoUrl, title) {
- this.plugin.hook('header-title', {replace: true}).onAttached(
- element => {
- const customHeader =
- document.createElement('gr-custom-plugin-header');
- customHeader.logoUrl = logoUrl;
- customHeader.title = title;
- element.appendChild(customHeader);
- });
-};
-
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
new file mode 100644
index 0000000..821e4bf
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.ts
@@ -0,0 +1,37 @@
+/**
+ * @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 './gr-custom-plugin-header';
+import {GrCustomPluginHeader} from './gr-custom-plugin-header';
+import {PluginApi} from '../gr-plugin-types';
+
+/**
+ * Defines api for theme, can be used to set header logo and title.
+ */
+export class GrThemeApi {
+ constructor(private readonly plugin: PluginApi) {}
+
+ setHeaderLogoAndTitle(logoUrl: string, title: string) {
+ this.plugin.hook('header-title', {replace: true}).onAttached(element => {
+ const customHeader: GrCustomPluginHeader = document.createElement(
+ 'gr-custom-plugin-header'
+ );
+ customHeader.logoUrl = logoUrl;
+ customHeader.title = title;
+ element.appendChild(customHeader);
+ });
+ }
+}
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js
index 8a70303..787ab0b 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import '../gr-endpoint-decorator/gr-endpoint-decorator.js';
-import {pluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
@@ -56,7 +56,7 @@
/** @override */
ready() { customHeader = this; },
});
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
});
test('sets logo and title', done => {
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
deleted file mode 100644
index 32921d9..0000000
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ /dev/null
@@ -1,233 +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 '@polymer/iron-input/iron-input.js';
-import '../../shared/gr-avatar/gr-avatar.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/gr-form-styles.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-account-info_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrAccountInfo extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-account-info'; }
- /**
- * Fired when account details are changed.
- *
- * @event account-detail-update
- */
-
- static get properties() {
- return {
- usernameMutable: {
- type: Boolean,
- notify: true,
- computed: '_computeUsernameMutable(_serverConfig, _account.username)',
- },
- nameMutable: {
- type: Boolean,
- notify: true,
- computed: '_computeNameMutable(_serverConfig)',
- },
- hasUnsavedChanges: {
- type: Boolean,
- notify: true,
- computed: '_computeHasUnsavedChanges(_hasNameChange, ' +
- '_hasUsernameChange, _hasStatusChange, _hasDisplayNameChange)',
- },
-
- _hasNameChange: Boolean,
- _hasUsernameChange: Boolean,
- _hasDisplayNameChange: Boolean,
- _hasStatusChange: Boolean,
- _loading: {
- type: Boolean,
- value: false,
- },
- _saving: {
- type: Boolean,
- value: false,
- },
- /** @type {?} */
- _account: Object,
- _serverConfig: Object,
- _username: {
- type: String,
- observer: '_usernameChanged',
- },
- _avatarChangeUrl: {
- type: String,
- value: '',
- },
- };
- }
-
- static get observers() {
- return [
- '_nameChanged(_account.name)',
- '_statusChanged(_account.status)',
- '_displayNameChanged(_account.display_name)',
- ];
- }
-
- loadData() {
- const promises = [];
-
- this._loading = true;
-
- promises.push(this.$.restAPI.getConfig().then(config => {
- this._serverConfig = config;
- }));
-
- promises.push(this.$.restAPI.getAccount().then(account => {
- this._hasNameChange = false;
- this._hasUsernameChange = false;
- this._hasDisplayNameChange = false;
- this._hasStatusChange = false;
- // Provide predefined value for username to trigger computation of
- // username mutability.
- account.username = account.username || '';
- this._account = account;
- this._username = account.username;
- }));
-
- promises.push(this.$.restAPI.getAvatarChangeUrl().then(url => {
- this._avatarChangeUrl = url;
- }));
-
- return Promise.all(promises).then(() => {
- this._loading = false;
- });
- }
-
- save() {
- if (!this.hasUnsavedChanges) {
- return Promise.resolve();
- }
-
- this._saving = true;
- // Set only the fields that have changed.
- // Must be done in sequence to avoid race conditions (@see Issue 5721)
- return this._maybeSetName()
- .then(() => this._maybeSetUsername())
- .then(() => this._maybeSetDisplayName())
- .then(() => this._maybeSetStatus())
- .then(() => {
- this._hasNameChange = false;
- this._hasDisplayNameChange = false;
- this._hasStatusChange = false;
- this._saving = false;
- this.dispatchEvent(new CustomEvent('account-detail-update', {
- composed: true, bubbles: true,
- }));
- });
- }
-
- _maybeSetName() {
- return this._hasNameChange && this.nameMutable ?
- this.$.restAPI.setAccountName(this._account.name) :
- Promise.resolve();
- }
-
- _maybeSetUsername() {
- return this._hasUsernameChange && this.usernameMutable ?
- this.$.restAPI.setAccountUsername(this._username) :
- Promise.resolve();
- }
-
- _maybeSetDisplayName() {
- return this._hasDisplayNameChange ?
- this.$.restAPI.setAccountDisplayName(this._account.display_name) :
- Promise.resolve();
- }
-
- _maybeSetStatus() {
- return this._hasStatusChange ?
- this.$.restAPI.setAccountStatus(this._account.status) :
- Promise.resolve();
- }
-
- _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged,
- displayNameChanged) {
- return nameChanged || usernameChanged || statusChanged
- || displayNameChanged;
- }
-
- _computeUsernameMutable(config, username) {
- // Polymer 2: check for undefined
- if ([
- config,
- username,
- ].includes(undefined)) {
- return undefined;
- }
-
- // Username may not be changed once it is set.
- return config.auth.editable_account_fields.includes('USER_NAME') &&
- !username;
- }
-
- _computeNameMutable(config) {
- return config.auth.editable_account_fields.includes('FULL_NAME');
- }
-
- _statusChanged() {
- if (this._loading) { return; }
- this._hasStatusChange = true;
- }
-
- _displayNameChanged() {
- if (this._loading) { return; }
- this._hasDisplayNameChange = true;
- }
-
- _usernameChanged() {
- if (this._loading || !this._account) { return; }
- this._hasUsernameChange =
- (this._account.username || '') !== (this._username || '');
- }
-
- _nameChanged() {
- if (this._loading) { return; }
- this._hasNameChange = true;
- }
-
- _handleKeydown(e) {
- if (e.keyCode === 13) { // Enter
- e.stopPropagation();
- this.save();
- }
- }
-
- _hideAvatarChangeUrl(avatarChangeUrl) {
- if (!avatarChangeUrl) {
- return 'hide';
- }
-
- return '';
- }
-}
-
-customElements.define(GrAccountInfo.is, GrAccountInfo);
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
new file mode 100644
index 0000000..9c781c8
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.ts
@@ -0,0 +1,280 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../shared/gr-avatar/gr-avatar';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/gr-form-styles';
+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-account-info_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {AccountInfo, ServerInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {EditableAccountField} from '../../../constants/constants';
+
+export interface GrAccountInfo {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-account-info')
+export class GrAccountInfo extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when account details are changed.
+ *
+ * @event account-detail-update
+ */
+
+ @property({
+ type: Boolean,
+ notify: true,
+ computed: '_computeUsernameMutable(_serverConfig, _account.username)',
+ })
+ usernameMutable?: boolean;
+
+ @property({
+ type: Boolean,
+ notify: true,
+ computed: '_computeNameMutable(_serverConfig)',
+ })
+ nameMutable?: boolean;
+
+ @property({
+ type: Boolean,
+ notify: true,
+ computed:
+ '_computeHasUnsavedChanges(_hasNameChange, ' +
+ '_hasUsernameChange, _hasStatusChange, _hasDisplayNameChange)',
+ })
+ hasUnsavedChanges?: boolean;
+
+ @property({type: Boolean})
+ _hasNameChange?: boolean;
+
+ @property({type: Boolean})
+ _hasUsernameChange?: boolean;
+
+ @property({type: Boolean})
+ _hasDisplayNameChange?: boolean;
+
+ @property({type: Boolean})
+ _hasStatusChange?: boolean;
+
+ @property({type: Boolean})
+ _loading = false;
+
+ @property({type: Boolean})
+ _saving = false;
+
+ @property({type: Object})
+ _account?: AccountInfo;
+
+ @property({type: Object})
+ _serverConfig?: ServerInfo;
+
+ @property({type: String, observer: '_usernameChanged'})
+ _username?: string;
+
+ @property({type: String})
+ _avatarChangeUrl = '';
+
+ loadData() {
+ const promises = [];
+
+ this._loading = true;
+
+ promises.push(
+ this.$.restAPI.getConfig().then(config => {
+ this._serverConfig = config;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getAccount().then(account => {
+ if (!account) return;
+ this._hasNameChange = false;
+ this._hasUsernameChange = false;
+ this._hasDisplayNameChange = false;
+ this._hasStatusChange = false;
+ // Provide predefined value for username to trigger computation of
+ // username mutability.
+ account.username = account.username || '';
+ this._account = account;
+ this._username = account.username;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getAvatarChangeUrl().then(url => {
+ this._avatarChangeUrl = url || '';
+ })
+ );
+
+ return Promise.all(promises).then(() => {
+ this._loading = false;
+ });
+ }
+
+ save() {
+ if (!this.hasUnsavedChanges) {
+ return Promise.resolve();
+ }
+
+ this._saving = true;
+ // Set only the fields that have changed.
+ // Must be done in sequence to avoid race conditions (@see Issue 5721)
+ return this._maybeSetName()
+ .then(() => this._maybeSetUsername())
+ .then(() => this._maybeSetDisplayName())
+ .then(() => this._maybeSetStatus())
+ .then(() => {
+ this._hasNameChange = false;
+ this._hasDisplayNameChange = false;
+ this._hasStatusChange = false;
+ this._saving = false;
+ this.dispatchEvent(
+ new CustomEvent('account-detail-update', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }
+
+ _maybeSetName() {
+ // Note that we are intentionally not acting on this._account.name being the
+ // empty string (which is falsy).
+ return this._hasNameChange && this.nameMutable && this._account?.name
+ ? this.$.restAPI.setAccountName(this._account.name)
+ : Promise.resolve();
+ }
+
+ _maybeSetUsername() {
+ // Note that we are intentionally not acting on this._username being the
+ // empty string (which is falsy).
+ return this._hasUsernameChange && this.usernameMutable && this._username
+ ? this.$.restAPI.setAccountUsername(this._username)
+ : Promise.resolve();
+ }
+
+ _maybeSetDisplayName() {
+ return this._hasDisplayNameChange &&
+ this._account?.display_name !== undefined
+ ? this.$.restAPI.setAccountDisplayName(this._account.display_name)
+ : Promise.resolve();
+ }
+
+ _maybeSetStatus() {
+ return this._hasStatusChange && this._account?.status !== undefined
+ ? this.$.restAPI.setAccountStatus(this._account.status)
+ : Promise.resolve();
+ }
+
+ _computeHasUnsavedChanges(
+ nameChanged: boolean,
+ usernameChanged: boolean,
+ statusChanged: boolean,
+ displayNameChanged: boolean
+ ) {
+ return (
+ nameChanged || usernameChanged || statusChanged || displayNameChanged
+ );
+ }
+
+ _computeUsernameMutable(config: ServerInfo, username?: string) {
+ // Polymer 2: check for undefined
+ if ([config, username].includes(undefined)) {
+ return undefined;
+ }
+
+ // Username may not be changed once it is set.
+ return (
+ config.auth.editable_account_fields.includes(
+ EditableAccountField.USER_NAME
+ ) && !username
+ );
+ }
+
+ _computeNameMutable(config: ServerInfo) {
+ return config.auth.editable_account_fields.includes(
+ EditableAccountField.FULL_NAME
+ );
+ }
+
+ @observe('_account.status')
+ _statusChanged() {
+ if (this._loading) {
+ return;
+ }
+ this._hasStatusChange = true;
+ }
+
+ @observe('_account.display_name')
+ _displayNameChanged() {
+ if (this._loading) {
+ return;
+ }
+ this._hasDisplayNameChange = true;
+ }
+
+ _usernameChanged() {
+ if (this._loading || !this._account) {
+ return;
+ }
+ this._hasUsernameChange =
+ (this._account.username || '') !== (this._username || '');
+ }
+
+ @observe('_account.name')
+ _nameChanged() {
+ if (this._loading) {
+ return;
+ }
+ this._hasNameChange = true;
+ }
+
+ _handleKeydown(e: KeyboardEvent) {
+ if (e.keyCode === 13) {
+ // Enter
+ e.stopPropagation();
+ this.save();
+ }
+ }
+
+ _hideAvatarChangeUrl(avatarChangeUrl: string) {
+ if (!avatarChangeUrl) {
+ return 'hide';
+ }
+
+ return '';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-account-info': GrAccountInfo;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
index 4a62bab..d359ad2 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-account-info.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-account-info');
@@ -27,7 +26,7 @@
let config;
function valueOf(title) {
- const sections = dom(element.root).querySelectorAll('section');
+ const sections = element.root.querySelectorAll('section');
let titleEl;
for (let i = 0; i < sections.length; i++) {
titleEl = sections[i].querySelector('.title');
@@ -303,12 +302,12 @@
// _usernameChanged is an observer, but call it here after setting
// _hasUsernameChange in the test to force recomputation.
element._usernameChanged();
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element._hasUsernameChange);
element.set('_username', 'test');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element._hasUsernameChange);
});
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
deleted file mode 100644
index e0da53d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
+++ /dev/null
@@ -1,64 +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-form-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-agreements-list_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-
-/**
- * @extends PolymerElement
- */
-class GrAgreementsList extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-agreements-list'; }
-
- static get properties() {
- return {
- _agreements: Array,
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.loadData();
- }
-
- loadData() {
- return this.$.restAPI.getAccountAgreements().then(agreements => {
- this._agreements = agreements;
- });
- }
-
- getUrl() {
- return getBaseUrl() + '/settings/new-agreement';
- }
-
- getUrlBase(item) {
- return getBaseUrl() + '/' + item;
- }
-}
-
-customElements.define(GrAgreementsList.is, GrAgreementsList);
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
new file mode 100644
index 0000000..5523be1
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.ts
@@ -0,0 +1,71 @@
+/**
+ * @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-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-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-agreements-list_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ContributorAgreementInfo} from '../../../types/common';
+
+export interface GrAgreementsList {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-agreements-list')
+export class GrAgreementsList extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Array})
+ _agreements?: ContributorAgreementInfo[];
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.loadData();
+ }
+
+ loadData() {
+ return this.$.restAPI.getAccountAgreements().then(agreements => {
+ this._agreements = agreements;
+ });
+ }
+
+ getUrl() {
+ return `${getBaseUrl()}/settings/new-agreement`;
+ }
+
+ getUrlBase(item: string) {
+ return `${getBaseUrl()}/${item}`;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-agreements-list': GrAgreementsList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
index ed0bdb3..0c785aa 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-agreements-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-agreements-list');
@@ -42,7 +41,7 @@
});
test('renders', () => {
- const rows = dom(element.root).querySelectorAll('tbody tr');
+ const rows = element.root.querySelectorAll('tbody tr');
assert.equal(rows.length, 1);
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
deleted file mode 100644
index 55ce596..0000000
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
+++ /dev/null
@@ -1,91 +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-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import '../../../styles/gr-form-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-table-editor_html.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-
-/**
- * @extends PolymerElement
- */
-class GrChangeTableEditor extends ChangeTableMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-table-editor'; }
-
- static get properties() {
- return {
- displayedColumns: {
- type: Array,
- notify: true,
- },
- showNumber: {
- type: Boolean,
- notify: true,
- },
- };
- }
-
- /**
- * Get the list of enabled column names from whichever checkboxes are
- * checked (excluding the number checkbox).
- *
- * @return {!Array<string>}
- */
- _getDisplayedColumns() {
- return Array.from(dom(this.root)
- .querySelectorAll('.checkboxContainer input:not([name=number])'))
- .filter(checkbox => checkbox.checked)
- .map(checkbox => checkbox.name);
- }
-
- /**
- * Handle a click on a checkbox container and relay the click to the checkbox it
- * contains.
- */
- _handleCheckboxContainerClick(e) {
- const checkbox = dom(e.target).querySelector('input');
- if (!checkbox) { return; }
- checkbox.click();
- }
-
- /**
- * Handle a click on the number checkbox and update the showNumber property
- * accordingly.
- */
- _handleNumberCheckboxClick(e) {
- this.showNumber = dom(e).rootTarget.checked;
- }
-
- /**
- * Handle a click on a displayed column checkboxes (excluding number) and
- * update the displayedColumns property accordingly.
- */
- _handleTargetClick(e) {
- this.set('displayedColumns', this._getDisplayedColumns());
- }
-}
-
-customElements.define(GrChangeTableEditor.is, GrChangeTableEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
new file mode 100644
index 0000000..c0d6126
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.ts
@@ -0,0 +1,92 @@
+/**
+ * @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-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../../styles/gr-form-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-table-editor_html';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {customElement, property} from '@polymer/decorators';
+
+@customElement('gr-change-table-editor')
+class GrChangeTableEditor extends ChangeTableMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Array, notify: true})
+ displayedColumns?: string[];
+
+ @property({type: Boolean, notify: true})
+ showNumber?: boolean;
+
+ /**
+ * Get the list of enabled column names from whichever checkboxes are
+ * checked (excluding the number checkbox).
+ */
+ _getDisplayedColumns() {
+ if (this.root === null) return [];
+ return (Array.from(
+ this.root.querySelectorAll('.checkboxContainer input:not([name=number])')
+ ) as HTMLInputElement[])
+ .filter(checkbox => checkbox.checked)
+ .map(checkbox => checkbox.name);
+ }
+
+ /**
+ * Handle a click on a checkbox container and relay the click to the checkbox it
+ * contains.
+ */
+ _handleCheckboxContainerClick(e: MouseEvent) {
+ if (e.target === null) return;
+ const checkbox = (e.target as HTMLElement).querySelector('input');
+ if (!checkbox) {
+ return;
+ }
+ checkbox.click();
+ }
+
+ /**
+ * Handle a click on the number checkbox and update the showNumber property
+ * accordingly.
+ */
+ _handleNumberCheckboxClick(e: MouseEvent) {
+ this.showNumber = ((dom(e) as EventApi)
+ .rootTarget as HTMLInputElement).checked;
+ }
+
+ /**
+ * Handle a click on a displayed column checkboxes (excluding number) and
+ * update the displayedColumns property accordingly.
+ */
+ _handleTargetClick() {
+ this.set('displayedColumns', this._getDisplayedColumns());
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-table-editor': GrChangeTableEditor;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
index 3fca9d2..42085ff 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.js
@@ -41,7 +41,7 @@
element.set('displayedColumns', columns);
element.showNumber = false;
- flushAsynchronousOperations();
+ flush();
});
test('renders', () => {
@@ -66,7 +66,7 @@
assert.isTrue(isChecked);
MockInteractions.tap(checkbox);
- flushAsynchronousOperations();
+ flush();
assert.equal(element.displayedColumns.length, displayedLength - 1);
});
@@ -80,7 +80,7 @@
'Branch',
'Updated',
]);
- flushAsynchronousOperations();
+ flush();
const checkbox = element.shadowRoot
.querySelector('table tr:nth-child(2) input');
const isChecked = checkbox.checked;
@@ -90,7 +90,7 @@
.querySelector('table').style.display, '');
MockInteractions.tap(checkbox);
- flushAsynchronousOperations();
+ flush();
assert.equal(element.displayedColumns.length,
displayedLength + 1);
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
deleted file mode 100644
index 023eee8..0000000
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.js
+++ /dev/null
@@ -1,172 +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-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-cla-view_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-
-/**
- * @extends PolymerElement
- */
-class GrClaView extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-cla-view'; }
-
- static get properties() {
- return {
- _groups: Object,
- /** @type {?} */
- _serverConfig: Object,
- _agreementsText: String,
- _agreementName: String,
- _signedAgreements: Array,
- _showAgreements: {
- type: Boolean,
- value: false,
- },
- _agreementsUrl: String,
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.loadData();
-
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: 'New Contributor Agreement'},
- composed: true, bubbles: true,
- }));
- }
-
- loadData() {
- const promises = [];
- promises.push(this.$.restAPI.getConfig(true).then(config => {
- this._serverConfig = config;
- }));
-
- promises.push(this.$.restAPI.getAccountGroups().then(groups => {
- this._groups = groups.sort((a, b) => a.name.localeCompare(b.name));
- }));
-
- promises.push(this.$.restAPI.getAccountAgreements().then(agreements => {
- this._signedAgreements = agreements || [];
- }));
-
- return Promise.all(promises);
- }
-
- _getAgreementsUrl(configUrl) {
- let url;
- if (!configUrl) {
- return '';
- }
- if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
- url = configUrl;
- } else {
- url = getBaseUrl() + '/' + configUrl;
- }
-
- return url;
- }
-
- _handleShowAgreement(e) {
- this._agreementName = e.target.getAttribute('data-name');
- this._agreementsUrl =
- this._getAgreementsUrl(e.target.getAttribute('data-url'));
- this._showAgreements = true;
- }
-
- _handleSaveAgreements(e) {
- this._createToast('Agreement saving...');
-
- const name = this._agreementName;
- return this.$.restAPI.saveAccountAgreement({name}).then(res => {
- let message = 'Agreement failed to be submitted, please try again';
- if (res.status === 200) {
- message = 'Agreement has been successfully submitted.';
- }
- this._createToast(message);
- this.loadData();
- this._agreementsText = '';
- this._showAgreements = false;
- });
- }
-
- _createToast(message) {
- this.dispatchEvent(new CustomEvent(
- 'show-alert', {detail: {message}, bubbles: true, composed: true}));
- }
-
- _computeShowAgreementsClass(agreements) {
- return agreements ? 'show' : '';
- }
-
- _disableAgreements(item, groups, signedAgreements) {
- if (!groups) return false;
- for (const group of groups) {
- if ((item && item.auto_verify_group &&
- item.auto_verify_group.id === group.id) ||
- signedAgreements.find(i => i.name === item.name)) {
- return true;
- }
- }
- return false;
- }
-
- _hideAgreements(item, groups, signedAgreements) {
- return this._disableAgreements(item, groups, signedAgreements) ?
- '' : 'hide';
- }
-
- _disableAgreementsText(text) {
- return text.toLowerCase() === 'i agree' ? false : true;
- }
-
- // This checks for auto_verify_group,
- // if specified it returns 'hideAgreementsTextBox' which
- // then hides the text box and submit button.
- _computeHideAgreementClass(name, config) {
- if (!config) return '';
- for (const key in config) {
- if (!config.hasOwnProperty(key)) {
- continue;
- }
- for (const prop in config[key]) {
- if (!config[key].hasOwnProperty(prop)) {
- continue;
- }
- if (name === config[key].name &&
- !config[key].auto_verify_group) {
- return 'hideAgreementsTextBox';
- }
- }
- }
- }
-}
-
-customElements.define(GrClaView.is, GrClaView);
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
new file mode 100644
index 0000000..28cd672
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view.ts
@@ -0,0 +1,221 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-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-cla-view_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {customElement, property} from '@polymer/decorators';
+import {
+ ServerInfo,
+ GroupInfo,
+ ContributorAgreementInfo,
+} from '../../../types/common';
+
+export interface GrClaView {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-cla-view': GrClaView;
+ }
+}
+
+@customElement('gr-cla-view')
+export class GrClaView extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ _groups?: GroupInfo[];
+
+ @property({type: Object})
+ _serverConfig?: ServerInfo;
+
+ @property({type: String})
+ _agreementsText?: string;
+
+ @property({type: String})
+ _agreementName?: string;
+
+ @property({type: Array})
+ _signedAgreements?: ContributorAgreementInfo[];
+
+ @property({type: Boolean})
+ _showAgreements = false;
+
+ @property({type: String})
+ _agreementsUrl?: string;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.loadData();
+
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: 'New Contributor Agreement'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ loadData() {
+ const promises = [];
+ promises.push(
+ this.$.restAPI.getConfig(true).then(config => {
+ this._serverConfig = config;
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getAccountGroups().then(groups => {
+ if (!groups) return;
+ this._groups = groups.sort((a, b) =>
+ (a.name || '').localeCompare(b.name || '')
+ );
+ })
+ );
+
+ promises.push(
+ this.$.restAPI
+ .getAccountAgreements()
+ .then((agreements: ContributorAgreementInfo[] | undefined) => {
+ this._signedAgreements = agreements || [];
+ })
+ );
+
+ return Promise.all(promises);
+ }
+
+ _getAgreementsUrl(configUrl: string) {
+ let url;
+ if (!configUrl) {
+ return '';
+ }
+ if (configUrl.startsWith('http:') || configUrl.startsWith('https:')) {
+ url = configUrl;
+ } else {
+ url = getBaseUrl() + '/' + configUrl;
+ }
+
+ return url;
+ }
+
+ _handleShowAgreement(e: Event) {
+ this._agreementName = (e.target as HTMLInputElement).getAttribute(
+ 'data-name'
+ )!;
+ const url = (e.target as HTMLInputElement).getAttribute('data-url')!;
+ this._agreementsUrl = this._getAgreementsUrl(url);
+ this._showAgreements = true;
+ }
+
+ _handleSaveAgreements() {
+ this._createToast('Agreement saving...');
+
+ const name = this._agreementName;
+ return this.$.restAPI.saveAccountAgreement({name}).then(res => {
+ let message = 'Agreement failed to be submitted, please try again';
+ if (res.status === 200) {
+ message = 'Agreement has been successfully submitted.';
+ }
+ this._createToast(message);
+ this.loadData();
+ this._agreementsText = '';
+ this._showAgreements = false;
+ });
+ }
+
+ _createToast(message: string) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ _computeShowAgreementsClass(showAgreements: boolean) {
+ return showAgreements ? 'show' : '';
+ }
+
+ _disableAgreements(
+ item: ContributorAgreementInfo,
+ groups: GroupInfo[],
+ signedAgreements: ContributorAgreementInfo[]
+ ) {
+ if (!groups) return false;
+ for (const group of groups) {
+ if (
+ (item &&
+ item.auto_verify_group &&
+ item.auto_verify_group.id === group.id) ||
+ signedAgreements.find(i => i.name === item.name)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ _hideAgreements(
+ item: ContributorAgreementInfo,
+ groups: GroupInfo[],
+ signedAgreements: ContributorAgreementInfo[]
+ ) {
+ return this._disableAgreements(item, groups, signedAgreements)
+ ? ''
+ : 'hide';
+ }
+
+ _disableAgreementsText(text: string) {
+ return text.toLowerCase() === 'i agree' ? false : true;
+ }
+
+ // This checks for auto_verify_group,
+ // if specified it returns 'hideAgreementsTextBox' which
+ // then hides the text box and submit button.
+ _computeHideAgreementClass(
+ name: string,
+ contributorAgreements: ContributorAgreementInfo[]
+ ) {
+ if (!contributorAgreements) return '';
+ return contributorAgreements.some(
+ (contributorAgreement: ContributorAgreementInfo) =>
+ name === contributorAgreement.name &&
+ !contributorAgreement.auto_verify_group
+ )
+ ? 'hideAgreementsTextBox'
+ : '';
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
index 6f89c49..aeacea4 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.js
@@ -165,7 +165,7 @@
element._computeHideAgreementClass(
auth.name, config.auth.contributor_agreements),
'hideAgreementsTextBox');
- assert.isUndefined(
+ assert.isNotOk(
element._computeHideAgreementClass(
auth.name, config2.auth.contributor_agreements));
});
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
deleted file mode 100644
index 6973292..0000000
--- a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.js
+++ /dev/null
@@ -1,97 +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-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/shared-styles.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.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-edit-preferences_html.js';
-
-/** @extends PolymerElement */
-class GrEditPreferences extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-edit-preferences'; }
-
- static get properties() {
- return {
- hasUnsavedChanges: {
- type: Boolean,
- notify: true,
- value: false,
- },
-
- /** @type {?} */
- editPrefs: Object,
- };
- }
-
- loadData() {
- return this.$.restAPI.getEditPreferences().then(prefs => {
- this.editPrefs = prefs;
- });
- }
-
- _handleEditPrefsChanged() {
- this.hasUnsavedChanges = true;
- }
-
- _handleEditSyntaxHighlightingChanged() {
- this.set('editPrefs.syntax_highlighting',
- this.$.editSyntaxHighlighting.checked);
- this._handleEditPrefsChanged();
- }
-
- _handleEditShowTabsChanged() {
- this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
- this._handleEditPrefsChanged();
- }
-
- _handleMatchBracketsChanged() {
- this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
- this._handleEditPrefsChanged();
- }
-
- _handleEditLineWrappingChanged() {
- this.set('editPrefs.line_wrapping', this.$.editShowLineWrapping.checked);
- this._handleEditPrefsChanged();
- }
-
- _handleIndentWithTabsChanged() {
- this.set('editPrefs.indent_with_tabs', this.$.showIndentWithTabs.checked);
- this._handleEditPrefsChanged();
- }
-
- _handleAutoCloseBracketsChanged() {
- this.set('editPrefs.auto_close_brackets',
- this.$.showAutoCloseBrackets.checked);
- this._handleEditPrefsChanged();
- }
-
- save() {
- return this.$.restAPI.saveEditPreferences(this.editPrefs).then(res => {
- this.hasUnsavedChanges = false;
- });
- }
-}
-
-customElements.define(GrEditPreferences.is, GrEditPreferences);
diff --git a/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
new file mode 100644
index 0000000..d49cb5a
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-edit-preferences/gr-edit-preferences.ts
@@ -0,0 +1,114 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+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-edit-preferences_html';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {EditPreferencesInfo} from '../../../types/common';
+
+export interface GrEditPreferences {
+ $: {
+ restAPI: RestApiService & Element;
+ editSyntaxHighlighting: HTMLInputElement;
+ showAutoCloseBrackets: HTMLInputElement;
+ showIndentWithTabs: HTMLInputElement;
+ showMatchBrackets: HTMLInputElement;
+ editShowLineWrapping: HTMLInputElement;
+ editShowTabs: HTMLInputElement;
+ };
+}
+@customElement('gr-edit-preferences')
+export class GrEditPreferences extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean, notify: true})
+ hasUnsavedChanges = false;
+
+ @property({type: Object})
+ editPrefs?: EditPreferencesInfo;
+
+ loadData() {
+ return this.$.restAPI.getEditPreferences().then(prefs => {
+ this.editPrefs = prefs;
+ });
+ }
+
+ _handleEditPrefsChanged() {
+ this.hasUnsavedChanges = true;
+ }
+
+ _handleEditSyntaxHighlightingChanged() {
+ this.set(
+ 'editPrefs.syntax_highlighting',
+ this.$.editSyntaxHighlighting.checked
+ );
+ this._handleEditPrefsChanged();
+ }
+
+ _handleEditShowTabsChanged() {
+ this.set('editPrefs.show_tabs', this.$.editShowTabs.checked);
+ this._handleEditPrefsChanged();
+ }
+
+ _handleMatchBracketsChanged() {
+ this.set('editPrefs.match_brackets', this.$.showMatchBrackets.checked);
+ this._handleEditPrefsChanged();
+ }
+
+ _handleEditLineWrappingChanged() {
+ this.set('editPrefs.line_wrapping', this.$.editShowLineWrapping.checked);
+ this._handleEditPrefsChanged();
+ }
+
+ _handleIndentWithTabsChanged() {
+ this.set('editPrefs.indent_with_tabs', this.$.showIndentWithTabs.checked);
+ this._handleEditPrefsChanged();
+ }
+
+ _handleAutoCloseBracketsChanged() {
+ this.set(
+ 'editPrefs.auto_close_brackets',
+ this.$.showAutoCloseBrackets.checked
+ );
+ this._handleEditPrefsChanged();
+ }
+
+ save() {
+ if (!this.editPrefs)
+ return Promise.reject(new Error('Missing edit preferences'));
+ return this.$.restAPI.saveEditPreferences(this.editPrefs).then(() => {
+ this.hasUnsavedChanges = false;
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-edit-preferences': GrEditPreferences;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
deleted file mode 100644
index 0cc5c2c..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
+++ /dev/null
@@ -1,110 +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 '@polymer/iron-input/iron-input.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-email-editor_html.js';
-
-/** @extends PolymerElement */
-class GrEmailEditor extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-email-editor'; }
-
- static get properties() {
- return {
- hasUnsavedChanges: {
- type: Boolean,
- notify: true,
- value: false,
- },
-
- _emails: Array,
- _emailsToRemove: {
- type: Array,
- value() { return []; },
- },
- /** @type {?string} */
- _newPreferred: {
- type: String,
- value: null,
- },
- };
- }
-
- loadData() {
- return this.$.restAPI.getAccountEmails().then(emails => {
- this._emails = emails;
- });
- }
-
- save() {
- const promises = [];
-
- for (const emailObj of this._emailsToRemove) {
- promises.push(this.$.restAPI.deleteAccountEmail(emailObj.email));
- }
-
- if (this._newPreferred) {
- promises.push(this.$.restAPI.setPreferredAccountEmail(
- this._newPreferred));
- }
-
- return Promise.all(promises).then(() => {
- this._emailsToRemove = [];
- this._newPreferred = null;
- this.hasUnsavedChanges = false;
- });
- }
-
- _handleDeleteButton(e) {
- const index = parseInt(dom(e).localTarget
- .getAttribute('data-index'), 10);
- const email = this._emails[index];
- this.push('_emailsToRemove', email);
- this.splice('_emails', index, 1);
- this.hasUnsavedChanges = true;
- }
-
- _handlePreferredControlClick(e) {
- if (e.target.classList.contains('preferredControl')) {
- e.target.firstElementChild.click();
- }
- }
-
- _handlePreferredChange(e) {
- const preferred = e.target.value;
- for (let i = 0; i < this._emails.length; i++) {
- if (preferred === this._emails[i].email) {
- this.set(['_emails', i, 'preferred'], true);
- this._newPreferred = preferred;
- this.hasUnsavedChanges = true;
- } else if (this._emails[i].preferred) {
- this.set(['_emails', i, 'preferred'], false);
- }
- }
- }
-}
-
-customElements.define(GrEmailEditor.is, GrEmailEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
new file mode 100644
index 0000000..fd10a16
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.ts
@@ -0,0 +1,123 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-email-editor_html';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {EmailInfo} from '../../../types/common';
+
+export interface GrEmailEditor {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-email-editor')
+export class GrEmailEditor extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean, notify: true})
+ hasUnsavedChanges = false;
+
+ @property({type: Array})
+ _emails: EmailInfo[] = [];
+
+ @property({type: Array})
+ _emailsToRemove: EmailInfo[] = [];
+
+ @property({type: String})
+ _newPreferred: string | null = null;
+
+ loadData() {
+ return this.$.restAPI.getAccountEmails().then(emails => {
+ this._emails = emails ?? [];
+ });
+ }
+
+ save() {
+ const promises: Promise<unknown>[] = [];
+
+ for (const emailObj of this._emailsToRemove) {
+ promises.push(this.$.restAPI.deleteAccountEmail(emailObj.email));
+ }
+
+ if (this._newPreferred) {
+ promises.push(
+ this.$.restAPI.setPreferredAccountEmail(this._newPreferred)
+ );
+ }
+
+ return Promise.all(promises).then(() => {
+ this._emailsToRemove = [];
+ this._newPreferred = null;
+ this.hasUnsavedChanges = false;
+ });
+ }
+
+ _handleDeleteButton(e: Event) {
+ const target = (dom(e) as EventApi).localTarget;
+ if (!(target instanceof Element)) return;
+ const indexStr = target.getAttribute('data-index');
+ if (indexStr === null) return;
+ const index = Number(indexStr);
+ const email = this._emails[index];
+ this.push('_emailsToRemove', email);
+ this.splice('_emails', index, 1);
+ this.hasUnsavedChanges = true;
+ }
+
+ _handlePreferredControlClick(e: Event) {
+ if (
+ e.target instanceof HTMLElement &&
+ e.target.classList.contains('preferredControl') &&
+ e.target.firstElementChild instanceof HTMLInputElement
+ ) {
+ e.target.firstElementChild.click();
+ }
+ }
+
+ _handlePreferredChange(e: Event) {
+ if (!(e.target instanceof HTMLInputElement)) return;
+ const preferred = e.target.value;
+ for (let i = 0; i < this._emails.length; i++) {
+ if (preferred === this._emails[i].email) {
+ this.set(['_emails', i, 'preferred'], true);
+ this._newPreferred = preferred;
+ this.hasUnsavedChanges = true;
+ } else if (this._emails[i].preferred) {
+ this.set(['_emails', i, 'preferred'], false);
+ }
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-email-editor': GrEmailEditor;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.js
deleted file mode 100644
index 805b8c8..0000000
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.js
+++ /dev/null
@@ -1,138 +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 '../../../test/common-test-setup-karma.js';
-import './gr-email-editor.js';
-
-const basicFixture = fixtureFromElement('gr-email-editor');
-
-suite('gr-email-editor tests', () => {
- let element;
-
- setup(done => {
- const emails = [
- {email: 'email@one.com'},
- {email: 'email@two.com', preferred: true},
- {email: 'email@three.com'},
- ];
-
- stub('gr-rest-api-interface', {
- getAccountEmails() { return Promise.resolve(emails); },
- });
-
- element = basicFixture.instantiate();
-
- element.loadData().then(flush(done));
- });
-
- test('renders', () => {
- const rows = element.shadowRoot
- .querySelector('table').querySelectorAll('tbody tr');
-
- assert.equal(rows.length, 3);
-
- assert.isFalse(rows[0].querySelector('input[type=radio]').checked);
- assert.isNotOk(rows[0].querySelector('gr-button').disabled);
-
- assert.isTrue(rows[1].querySelector('input[type=radio]').checked);
- assert.isOk(rows[1].querySelector('gr-button').disabled);
-
- assert.isFalse(rows[2].querySelector('input[type=radio]').checked);
- assert.isNotOk(rows[2].querySelector('gr-button').disabled);
-
- assert.isFalse(element.hasUnsavedChanges);
- });
-
- test('edit preferred', () => {
- const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
- const radios = element.shadowRoot
- .querySelector('table').querySelectorAll('input[type=radio]');
-
- assert.isFalse(element.hasUnsavedChanges);
- assert.isNotOk(element._newPreferred);
- assert.equal(element._emailsToRemove.length, 0);
- assert.equal(element._emails.length, 3);
- assert.isNotOk(radios[0].checked);
- assert.isOk(radios[1].checked);
- assert.isFalse(preferredChangedSpy.called);
-
- radios[0].click();
-
- assert.isTrue(element.hasUnsavedChanges);
- assert.isOk(element._newPreferred);
- assert.equal(element._emailsToRemove.length, 0);
- assert.equal(element._emails.length, 3);
- assert.isOk(radios[0].checked);
- assert.isNotOk(radios[1].checked);
- assert.isTrue(preferredChangedSpy.called);
- });
-
- test('delete email', () => {
- const buttons = element.shadowRoot
- .querySelector('table').querySelectorAll('gr-button');
-
- assert.isFalse(element.hasUnsavedChanges);
- assert.isNotOk(element._newPreferred);
- assert.equal(element._emailsToRemove.length, 0);
- assert.equal(element._emails.length, 3);
-
- buttons[2].click();
-
- assert.isTrue(element.hasUnsavedChanges);
- assert.isNotOk(element._newPreferred);
- assert.equal(element._emailsToRemove.length, 1);
- assert.equal(element._emails.length, 2);
-
- assert.equal(element._emailsToRemove[0].email, 'email@three.com');
- });
-
- test('save changes', done => {
- const deleteEmailStub =
- sinon.stub(element.$.restAPI, 'deleteAccountEmail');
- const setPreferredStub = sinon.stub(element.$.restAPI,
- 'setPreferredAccountEmail');
- const rows = element.shadowRoot
- .querySelector('table').querySelectorAll('tbody tr');
-
- assert.isFalse(element.hasUnsavedChanges);
- assert.isNotOk(element._newPreferred);
- assert.equal(element._emailsToRemove.length, 0);
- assert.equal(element._emails.length, 3);
-
- // Delete the first email and set the last as preferred.
- rows[0].querySelector('gr-button').click();
- rows[2].querySelector('input[type=radio]').click();
-
- assert.isTrue(element.hasUnsavedChanges);
- assert.equal(element._newPreferred, 'email@three.com');
- assert.equal(element._emailsToRemove.length, 1);
- assert.equal(element._emailsToRemove[0].email, 'email@one.com');
- assert.equal(element._emails.length, 2);
-
- // Save the changes.
- element.save().then(() => {
- assert.equal(deleteEmailStub.callCount, 1);
- assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
-
- assert.isTrue(setPreferredStub.called);
- assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
-
- done();
- });
- });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
new file mode 100644
index 0000000..18ff95c
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.ts
@@ -0,0 +1,153 @@
+/**
+ * @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 '../../../test/common-test-setup-karma.js';
+import './gr-email-editor.js';
+import {GrEmailEditor} from './gr-email-editor';
+
+const basicFixture = fixtureFromElement('gr-email-editor');
+
+suite('gr-email-editor tests', () => {
+ let element: GrEmailEditor;
+
+ setup(async () => {
+ const emails = [
+ {email: 'email@one.com'},
+ {email: 'email@two.com', preferred: true},
+ {email: 'email@three.com'},
+ ];
+
+ stub('gr-rest-api-interface', {
+ getAccountEmails() {
+ return Promise.resolve(emails);
+ },
+ });
+
+ element = basicFixture.instantiate();
+
+ await element.loadData();
+ await flush();
+ });
+
+ test('renders', () => {
+ const rows = element
+ .shadowRoot!.querySelector('table')!
+ .querySelectorAll('tbody tr') as NodeListOf<HTMLTableRowElement>;
+
+ assert.equal(rows.length, 3);
+
+ assert.isFalse(
+ (rows[0].querySelector('input[type=radio]') as HTMLInputElement).checked
+ );
+ assert.isNotOk(rows[0].querySelector('gr-button')!.disabled);
+
+ assert.isTrue(
+ (rows[1].querySelector('input[type=radio]') as HTMLInputElement).checked
+ );
+ assert.isOk(rows[1].querySelector('gr-button')!.disabled);
+
+ assert.isFalse(
+ (rows[2].querySelector('input[type=radio]') as HTMLInputElement).checked
+ );
+ assert.isNotOk(rows[2].querySelector('gr-button')!.disabled);
+
+ assert.isFalse(element.hasUnsavedChanges);
+ });
+
+ test('edit preferred', () => {
+ const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
+ const radios = element
+ .shadowRoot!.querySelector('table')!
+ .querySelectorAll('input[type=radio]') as NodeListOf<HTMLInputElement>;
+
+ assert.isFalse(element.hasUnsavedChanges);
+ assert.isNotOk(element._newPreferred);
+ assert.equal(element._emailsToRemove.length, 0);
+ assert.equal(element._emails.length, 3);
+ assert.isNotOk(radios[0].checked);
+ assert.isOk(radios[1].checked);
+ assert.isFalse(preferredChangedSpy.called);
+
+ radios[0].click();
+
+ assert.isTrue(element.hasUnsavedChanges);
+ assert.isOk(element._newPreferred);
+ assert.equal(element._emailsToRemove.length, 0);
+ assert.equal(element._emails.length, 3);
+ assert.isOk(radios[0].checked);
+ assert.isNotOk(radios[1].checked);
+ assert.isTrue(preferredChangedSpy.called);
+ });
+
+ test('delete email', () => {
+ const buttons = element
+ .shadowRoot!.querySelector('table')!
+ .querySelectorAll('gr-button');
+
+ assert.isFalse(element.hasUnsavedChanges);
+ assert.isNotOk(element._newPreferred);
+ assert.equal(element._emailsToRemove.length, 0);
+ assert.equal(element._emails.length, 3);
+
+ buttons[2].click();
+
+ assert.isTrue(element.hasUnsavedChanges);
+ assert.isNotOk(element._newPreferred);
+ assert.equal(element._emailsToRemove.length, 1);
+ assert.equal(element._emails.length, 2);
+
+ assert.equal(element._emailsToRemove[0].email, 'email@three.com');
+ });
+
+ test('save changes', done => {
+ const deleteEmailStub = sinon.stub(element.$.restAPI, 'deleteAccountEmail');
+ const setPreferredStub = sinon.stub(
+ element.$.restAPI,
+ 'setPreferredAccountEmail'
+ );
+
+ const rows = element
+ .shadowRoot!.querySelector('table')!
+ .querySelectorAll('tbody tr');
+
+ assert.isFalse(element.hasUnsavedChanges);
+ assert.isNotOk(element._newPreferred);
+ assert.equal(element._emailsToRemove.length, 0);
+ assert.equal(element._emails.length, 3);
+
+ // Delete the first email and set the last as preferred.
+ rows[0].querySelector('gr-button')!.click();
+ (rows[2].querySelector('input[type=radio]')! as HTMLInputElement).click();
+
+ assert.isTrue(element.hasUnsavedChanges);
+ assert.equal(element._newPreferred, 'email@three.com');
+ assert.equal(element._emailsToRemove.length, 1);
+ assert.equal(element._emailsToRemove[0].email, 'email@one.com');
+ assert.equal(element._emails.length, 2);
+
+ // Save the changes.
+ element.save().then(() => {
+ assert.equal(deleteEmailStub.callCount, 1);
+ assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
+
+ assert.isTrue(setPreferredStub.called);
+ assert.equal(setPreferredStub.getCall(0).args[0], 'email@three.com');
+
+ done();
+ });
+ });
+});
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
deleted file mode 100644
index 6c6ad01..0000000
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.js
+++ /dev/null
@@ -1,124 +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 '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-gpg-editor_html.js';
-
-/** @extends PolymerElement */
-class GrGpgEditor extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-gpg-editor'; }
-
- static get properties() {
- return {
- hasUnsavedChanges: {
- type: Boolean,
- value: false,
- notify: true,
- },
- _keys: Array,
- /** @type {?} */
- _keyToView: Object,
- _newKey: {
- type: String,
- value: '',
- },
- _keysToRemove: {
- type: Array,
- value() { return []; },
- },
- };
- }
-
- loadData() {
- this._keys = [];
- return this.$.restAPI.getAccountGPGKeys().then(keys => {
- if (!keys) {
- return;
- }
- this._keys = Object.keys(keys)
- .map(key => {
- const gpgKey = keys[key];
- gpgKey.id = key;
- return gpgKey;
- });
- });
- }
-
- save() {
- const promises = this._keysToRemove.map(key => {
- this.$.restAPI.deleteAccountGPGKey(key.id);
- });
-
- return Promise.all(promises).then(() => {
- this._keysToRemove = [];
- this.hasUnsavedChanges = false;
- });
- }
-
- _showKey(e) {
- const el = dom(e).localTarget;
- const index = parseInt(el.getAttribute('data-index'), 10);
- this._keyToView = this._keys[index];
- this.$.viewKeyOverlay.open();
- }
-
- _closeOverlay() {
- this.$.viewKeyOverlay.close();
- }
-
- _handleDeleteKey(e) {
- const el = dom(e).localTarget;
- const index = parseInt(el.getAttribute('data-index'), 10);
- this.push('_keysToRemove', this._keys[index]);
- this.splice('_keys', index, 1);
- this.hasUnsavedChanges = true;
- }
-
- _handleAddKey() {
- this.$.addButton.disabled = true;
- this.$.newKey.disabled = true;
- return this.$.restAPI.addAccountGPGKey({add: [this._newKey.trim()]})
- .then(key => {
- this.$.newKey.disabled = false;
- this._newKey = '';
- this.loadData();
- })
- .catch(() => {
- this.$.addButton.disabled = false;
- this.$.newKey.disabled = false;
- });
- }
-
- _computeAddButtonDisabled(newKey) {
- return !newKey.length;
- }
-}
-
-customElements.define(GrGpgEditor.is, GrGpgEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
new file mode 100644
index 0000000..21e414b
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor.ts
@@ -0,0 +1,136 @@
+/**
+ * @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 '../../shared/gr-button/gr-button';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-gpg-editor_html';
+import {customElement, property} from '@polymer/decorators';
+import {GpgKeyInfo, GpgKeyId} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export interface GrGpgEditor {
+ $: {
+ restAPI: RestApiService & Element;
+ viewKeyOverlay: GrOverlay;
+ addButton: GrButton;
+ newKey: IronAutogrowTextareaElement;
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-gpg-editor': GrGpgEditor;
+ }
+}
+@customElement('gr-gpg-editor')
+export class GrGpgEditor extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean, notify: true})
+ hasUnsavedChanges = false;
+
+ @property({type: Array})
+ _keys: GpgKeyInfo[] = [];
+
+ @property({type: Object})
+ _keyToView?: GpgKeyInfo;
+
+ @property({type: String})
+ _newKey = '';
+
+ @property({type: Array})
+ _keysToRemove: GpgKeyInfo[] = [];
+
+ loadData() {
+ this._keys = [];
+ return this.$.restAPI.getAccountGPGKeys().then(keys => {
+ if (!keys) {
+ return;
+ }
+ this._keys = Object.keys(keys).map(key => {
+ const gpgKey = keys[key];
+ gpgKey.id = key as GpgKeyId;
+ return gpgKey;
+ });
+ });
+ }
+
+ save() {
+ const promises = this._keysToRemove.map(key =>
+ this.$.restAPI.deleteAccountGPGKey(key.id!)
+ );
+
+ return Promise.all(promises).then(() => {
+ this._keysToRemove = [];
+ this.hasUnsavedChanges = false;
+ });
+ }
+
+ _showKey(e: Event) {
+ const el = (dom(e) as EventApi).localTarget as Element;
+ const index = Number(el.getAttribute('data-index')!);
+ this._keyToView = this._keys[index];
+ this.$.viewKeyOverlay.open();
+ }
+
+ _closeOverlay() {
+ this.$.viewKeyOverlay.close();
+ }
+
+ _handleDeleteKey(e: Event) {
+ const el = (dom(e) as EventApi).localTarget as Element;
+ const index = Number(el.getAttribute('data-index')!);
+ this.push('_keysToRemove', this._keys[index]);
+ this.splice('_keys', index, 1);
+ this.hasUnsavedChanges = true;
+ }
+
+ _handleAddKey() {
+ this.$.addButton.disabled = true;
+ this.$.newKey.disabled = true;
+ return this.$.restAPI
+ .addAccountGPGKey({add: [this._newKey.trim()]})
+ .then(() => {
+ this.$.newKey.disabled = false;
+ this._newKey = '';
+ this.loadData();
+ })
+ .catch(() => {
+ this.$.addButton.disabled = false;
+ this.$.newKey.disabled = false;
+ });
+ }
+
+ _computeAddButtonDisabled(newKey: string) {
+ return !newKey.length;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
index 5281e17..2792176 100644
--- a/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-gpg-editor/gr-gpg-editor_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-gpg-editor.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-gpg-editor');
@@ -61,7 +60,7 @@
});
test('renders', () => {
- const rows = dom(element.root).querySelectorAll('tbody tr');
+ const rows = element.root.querySelectorAll('tbody tr');
assert.equal(rows.length, 2);
@@ -82,7 +81,7 @@
assert.isFalse(element.hasUnsavedChanges);
// Get the delete button for the last row.
- const button = dom(element.root).querySelector(
+ const button = element.root.querySelector(
'tbody tr:last-of-type td:nth-child(6) gr-button');
MockInteractions.tap(button);
@@ -106,7 +105,7 @@
const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
// Get the show button for the last row.
- const button = dom(element.root).querySelector(
+ const button = element.root.querySelector(
'tbody tr:last-of-type td:nth-child(4) gr-button');
MockInteractions.tap(button);
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
deleted file mode 100644
index 429a7c7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
+++ /dev/null
@@ -1,59 +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 '../../../styles/shared-styles.js';
-import '../../../styles/gr-form-styles.js';
-import '../../shared/gr-rest-api-interface/gr-rest-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-group-list_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/** @extends PolymerElement */
-class GrGroupList extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-group-list'; }
-
- static get properties() {
- return {
- _groups: Array,
- };
- }
-
- loadData() {
- return this.$.restAPI.getAccountGroups().then(groups => {
- this._groups = groups.sort((a, b) => a.name.localeCompare(b.name));
- });
- }
-
- _computeVisibleToAll(group) {
- return group.options.visible_to_all ? 'Yes' : 'No';
- }
-
- _computeGroupPath(group) {
- if (!group || !group.id) { return; }
-
- // Group ID is already encoded from the API
- // Decode it here to match with our router encoding behavior
- return GerritNav.getUrlForGroup(decodeURIComponent(group.id));
- }
-}
-
-customElements.define(GrGroupList.is, GrGroupList);
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
new file mode 100644
index 0000000..d631c53
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.ts
@@ -0,0 +1,73 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../../../styles/gr-form-styles';
+import '../../shared/gr-rest-api-interface/gr-rest-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-group-list_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {customElement, property} from '@polymer/decorators';
+import {GroupInfo, GroupId} from '../../../types/common';
+
+export interface GrGroupList {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-group-list': GrGroupList;
+ }
+}
+@customElement('gr-group-list')
+export class GrGroupList extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Array})
+ _groups: GroupInfo[] = [];
+
+ loadData() {
+ return this.$.restAPI.getAccountGroups().then(groups => {
+ if (!groups) return;
+ this._groups = groups.sort((a, b) =>
+ (a.name || '').localeCompare(b.name || '')
+ );
+ });
+ }
+
+ _computeVisibleToAll(group: GroupInfo) {
+ return group.options && group.options.visible_to_all ? 'Yes' : 'No';
+ }
+
+ _computeGroupPath(group: GroupInfo) {
+ if (!group || !group.id) {
+ return;
+ }
+
+ // Group ID is already encoded from the API
+ // Decode it here to match with our router encoding behavior
+ return GerritNav.getUrlForGroup(decodeURIComponent(group.id) as GroupId);
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
index bfd42ac..e19345a 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-group-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
const basicFixture = fixtureFromElement('gr-group-list');
@@ -57,7 +56,7 @@
test('renders', () => {
const rows = Array.from(
- dom(element.root).querySelectorAll('tbody tr'));
+ element.root.querySelectorAll('tbody tr'));
assert.equal(rows.length, 3);
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
deleted file mode 100644
index 164bdee..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
+++ /dev/null
@@ -1,81 +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 '../../../styles/gr-form-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.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-http-password_html.js';
-
-/** @extends PolymerElement */
-class GrHttpPassword extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-http-password'; }
-
- static get properties() {
- return {
- _username: String,
- _generatedPassword: String,
- _passwordUrl: String,
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.loadData();
- }
-
- loadData() {
- const promises = [];
-
- promises.push(this.$.restAPI.getAccount().then(account => {
- this._username = account.username;
- }));
-
- promises.push(this.$.restAPI.getConfig().then(info => {
- this._passwordUrl = info.auth.http_password_url || null;
- }));
-
- return Promise.all(promises);
- }
-
- _handleGenerateTap() {
- this._generatedPassword = 'Generating...';
- this.$.generatedPasswordOverlay.open();
- this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
- this._generatedPassword = newPassword;
- });
- }
-
- _closeOverlay() {
- this.$.generatedPasswordOverlay.close();
- }
-
- _generatedPasswordOverlayClosed() {
- this._generatedPassword = '';
- }
-}
-
-customElements.define(GrHttpPassword.is, GrHttpPassword);
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
new file mode 100644
index 0000000..02683e3
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.ts
@@ -0,0 +1,106 @@
+/**
+ * @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 '../../../styles/gr-form-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+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-http-password_html';
+import {property, customElement} from '@polymer/decorators';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-http-password': GrHttpPassword;
+ }
+}
+
+export interface GrHttpPassword {
+ $: {
+ restAPI: RestApiService & Element;
+ generatedPasswordOverlay: GrOverlay;
+ };
+}
+
+@customElement('gr-http-password')
+export class GrHttpPassword extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ _username?: string;
+
+ @property({type: String})
+ _generatedPassword?: string;
+
+ @property({type: String})
+ _passwordUrl: string | null = null;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.loadData();
+ }
+
+ loadData() {
+ const promises = [];
+
+ promises.push(
+ this.$.restAPI.getAccount().then(account => {
+ if (account) {
+ this._username = account.username;
+ }
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getConfig().then(info => {
+ if (info) {
+ this._passwordUrl = info.auth.http_password_url || null;
+ } else {
+ this._passwordUrl = null;
+ }
+ })
+ );
+
+ return Promise.all(promises);
+ }
+
+ _handleGenerateTap() {
+ this._generatedPassword = 'Generating...';
+ this.$.generatedPasswordOverlay.open();
+ this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
+ this._generatedPassword = newPassword;
+ });
+ }
+
+ _closeOverlay() {
+ this.$.generatedPasswordOverlay.close();
+ }
+
+ _generatedPasswordOverlayClosed() {
+ this._generatedPassword = '';
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
deleted file mode 100644
index eee06e3..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.js
+++ /dev/null
@@ -1,115 +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/shared-styles.js';
-import '../../../styles/gr-form-styles.js';
-import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.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 {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-identities_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-
-const AUTH = [
- 'OPENID',
- 'OAUTH',
-];
-
-/**
- * @extends PolymerElement
- */
-class GrIdentities extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-identities'; }
-
- static get properties() {
- return {
- _identities: Object,
- _idName: String,
- serverConfig: Object,
- _showLinkAnotherIdentity: {
- type: Boolean,
- computed: '_computeShowLinkAnotherIdentity(serverConfig)',
- },
- };
- }
-
- loadData() {
- return this.$.restAPI.getExternalIds().then(id => {
- this._identities = id;
- });
- }
-
- _computeIdentity(id) {
- return id && id.startsWith('mailto:') ? '' : id;
- }
-
- _computeHideDeleteClass(canDelete) {
- return canDelete ? 'show' : '';
- }
-
- _handleDeleteItemConfirm() {
- this.$.overlay.close();
- return this.$.restAPI.deleteAccountIdentity([this._idName])
- .then(() => { this.loadData(); });
- }
-
- _handleConfirmDialogCancel() {
- this.$.overlay.close();
- }
-
- _handleDeleteItem(e) {
- const name = e.model.get('item.identity');
- if (!name) { return; }
- this._idName = name;
- this.$.overlay.open();
- }
-
- _computeIsTrusted(item) {
- return item ? '' : 'Untrusted';
- }
-
- filterIdentities(item) {
- return !item.identity.startsWith('username:');
- }
-
- _computeShowLinkAnotherIdentity(config) {
- if (config && config.auth &&
- config.auth.git_basic_auth_policy) {
- return AUTH.includes(
- config.auth.git_basic_auth_policy.toUpperCase());
- }
-
- return false;
- }
-
- _computeLinkAnotherIdentity() {
- const baseUrl = getBaseUrl() || '';
- let pathname = window.location.pathname;
- if (baseUrl) {
- pathname = '/' + pathname.substring(baseUrl.length);
- }
- return baseUrl + '/login/' + encodeURIComponent(pathname) + '?link';
- }
-}
-
-customElements.define(GrIdentities.is, GrIdentities);
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
new file mode 100644
index 0000000..5f2b6a5
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities.ts
@@ -0,0 +1,130 @@
+/**
+ * @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/shared-styles';
+import '../../../styles/gr-form-styles';
+import '../../admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-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-identities_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {AccountExternalIdInfo, ServerInfo} from '../../../types/common';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {PolymerDomRepeatEvent} from '../../../types/types';
+
+const AUTH = ['OPENID', 'OAUTH'];
+
+export interface GrIdentities {
+ $: {
+ restAPI: RestApiService & Element;
+ overlay: GrOverlay;
+ };
+}
+
+@customElement('gr-identities')
+export class GrIdentities extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Array})
+ _identities: AccountExternalIdInfo[] = [];
+
+ @property({type: String})
+ _idName?: string;
+
+ @property({type: Object})
+ serverConfig?: ServerInfo;
+
+ @property({
+ type: Boolean,
+ computed: '_computeShowLinkAnotherIdentity(serverConfig)',
+ })
+ _showLinkAnotherIdentity?: boolean;
+
+ loadData() {
+ return this.$.restAPI.getExternalIds().then(id => {
+ this._identities = id ?? [];
+ });
+ }
+
+ _computeIdentity(id: string) {
+ return id && id.startsWith('mailto:') ? '' : id;
+ }
+
+ _computeHideDeleteClass(canDelete?: boolean) {
+ return canDelete ? 'show' : '';
+ }
+
+ _handleDeleteItemConfirm() {
+ this.$.overlay.close();
+ return this.$.restAPI.deleteAccountIdentity([this._idName!]).then(() => {
+ this.loadData();
+ });
+ }
+
+ _handleConfirmDialogCancel() {
+ this.$.overlay.close();
+ }
+
+ _handleDeleteItem(e: PolymerDomRepeatEvent<AccountExternalIdInfo>) {
+ const name = e.model.item.identity;
+ if (!name) {
+ return;
+ }
+ this._idName = name;
+ this.$.overlay.open();
+ }
+
+ _computeIsTrusted(item?: boolean) {
+ return item ? '' : 'Untrusted';
+ }
+
+ filterIdentities(item: AccountExternalIdInfo) {
+ return !item.identity.startsWith('username:');
+ }
+
+ _computeShowLinkAnotherIdentity(config?: ServerInfo) {
+ if (config?.auth?.git_basic_auth_policy) {
+ return AUTH.includes(config.auth.git_basic_auth_policy.toUpperCase());
+ }
+
+ return false;
+ }
+
+ _computeLinkAnotherIdentity() {
+ const baseUrl = getBaseUrl() || '';
+ let pathname = window.location.pathname;
+ if (baseUrl) {
+ pathname = '/' + pathname.substring(baseUrl.length);
+ }
+ return baseUrl + '/login/' + encodeURIComponent(pathname) + '?link';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-identities': GrIdentities;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
index e01c58b..8af5bd0 100644
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-identities.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-identities');
@@ -52,7 +51,7 @@
test('renders', () => {
const rows = Array.from(
- dom(element.root).querySelectorAll('tbody tr'));
+ element.root.querySelectorAll('tbody tr'));
assert.equal(rows.length, 2);
@@ -66,7 +65,7 @@
test('renders email', () => {
const rows = Array.from(
- dom(element.root).querySelectorAll('tbody tr'));
+ element.root.querySelectorAll('tbody tr'));
assert.equal(rows.length, 2);
@@ -101,7 +100,7 @@
test('_handleDeleteItem opens modal', () => {
const deleteBtn =
- dom(element.root).querySelector('.deleteButton');
+ element.root.querySelector('.deleteButton');
const deleteItem = sinon.stub(element, '_handleDeleteItem');
MockInteractions.tap(deleteBtn);
assert.isTrue(deleteItem.called);
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
deleted file mode 100644
index b68915a..0000000
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ /dev/null
@@ -1,91 +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 '@polymer/iron-input/iron-input.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import '../../../styles/gr-form-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-menu-editor_html.js';
-
-/** @extends PolymerElement */
-class GrMenuEditor extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-menu-editor'; }
-
- static get properties() {
- return {
- menuItems: Array,
- _newName: String,
- _newUrl: String,
- };
- }
-
- _handleMoveUpButton(e) {
- const index = Number(dom(e).localTarget.dataset.index);
- if (index === 0) { return; }
- const row = this.menuItems[index];
- const prev = this.menuItems[index - 1];
- this.splice('menuItems', index - 1, 2, row, prev);
- }
-
- _handleMoveDownButton(e) {
- const index = Number(dom(e).localTarget.dataset.index);
- if (index === this.menuItems.length - 1) { return; }
- const row = this.menuItems[index];
- const next = this.menuItems[index + 1];
- this.splice('menuItems', index, 2, next, row);
- }
-
- _handleDeleteButton(e) {
- const index = Number(dom(e).localTarget.dataset.index);
- this.splice('menuItems', index, 1);
- }
-
- _handleAddButton() {
- if (this._computeAddDisabled(this._newName, this._newUrl)) { return; }
-
- this.splice('menuItems', this.menuItems.length, 0, {
- name: this._newName,
- url: this._newUrl,
- target: '_blank',
- });
-
- this._newName = '';
- this._newUrl = '';
- }
-
- _computeAddDisabled(newName, newUrl) {
- return !newName.length || !newUrl.length;
- }
-
- _handleInputKeydown(e) {
- if (e.keyCode === 13) {
- e.stopPropagation();
- this._handleAddButton();
- }
- }
-}
-
-customElements.define(GrMenuEditor.is, GrMenuEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
new file mode 100644
index 0000000..0498b35
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.ts
@@ -0,0 +1,110 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import '../../../styles/gr-form-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-menu-editor_html';
+import {customElement, property} from '@polymer/decorators';
+import {TopMenuItemInfo} from '../../../types/common';
+
+@customElement('gr-menu-editor')
+export class GrMenuEditor extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Array})
+ menuItems!: TopMenuItemInfo[];
+
+ @property({type: String})
+ _newName?: string;
+
+ @property({type: String})
+ _newUrl?: string;
+
+ _handleMoveUpButton(e: Event) {
+ const target = (dom(e) as EventApi).localTarget;
+ if (!(target instanceof HTMLElement)) return;
+ const index = Number(target.dataset['index']);
+ if (index === 0) {
+ return;
+ }
+ const row = this.menuItems[index];
+ const prev = this.menuItems[index - 1];
+ this.splice('menuItems', index - 1, 2, row, prev);
+ }
+
+ _handleMoveDownButton(e: Event) {
+ const target = (dom(e) as EventApi).localTarget;
+ if (!(target instanceof HTMLElement)) return;
+ const index = Number(target.dataset['index']);
+ if (index === this.menuItems.length - 1) {
+ return;
+ }
+ const row = this.menuItems[index];
+ const next = this.menuItems[index + 1];
+ this.splice('menuItems', index, 2, next, row);
+ }
+
+ _handleDeleteButton(e: Event) {
+ const target = (dom(e) as EventApi).localTarget;
+ if (!(target instanceof HTMLElement)) return;
+ const index = Number(target.dataset['index']);
+ this.splice('menuItems', index, 1);
+ }
+
+ _handleAddButton() {
+ if (this._computeAddDisabled(this._newName, this._newUrl)) {
+ return;
+ }
+
+ this.splice('menuItems', this.menuItems.length, 0, {
+ name: this._newName,
+ url: this._newUrl,
+ target: '_blank',
+ });
+
+ this._newName = '';
+ this._newUrl = '';
+ }
+
+ _computeAddDisabled(newName?: string, newUrl?: string) {
+ return !newName?.length || !newUrl?.length;
+ }
+
+ _handleInputKeydown(e: KeyboardEvent) {
+ if (e.keyCode === 13) {
+ e.stopPropagation();
+ this._handleAddButton();
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-menu-editor': GrMenuEditor;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
deleted file mode 100644
index fe4a61c..0000000
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ /dev/null
@@ -1,163 +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 '@polymer/iron-input/iron-input.js';
-import '../../../styles/gr-form-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.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-registration-dialog_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrRegistrationDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-registration-dialog'; }
- /**
- * Fired when account details are changed.
- *
- * @event account-detail-update
- */
-
- /**
- * Fired when the close button is pressed.
- *
- * @event close
- */
-
- static get properties() {
- return {
- settingsUrl: String,
- /** @type {?} */
- _account: {
- type: Object,
- value: () => {
- // Prepopulate possibly undefined fields with values to trigger
- // computed bindings.
- return {email: null, name: null, username: null};
- },
- },
- _usernameMutable: {
- type: Boolean,
- computed: '_computeUsernameMutable(_serverConfig, _account.username)',
- },
- _loading: {
- type: Boolean,
- value: true,
- observer: '_loadingChanged',
- },
- _saving: {
- type: Boolean,
- value: false,
- },
- _serverConfig: Object,
- };
- }
-
- /** @override */
- ready() {
- super.ready();
- this._ensureAttribute('role', 'dialog');
- }
-
- loadData() {
- this._loading = true;
-
- const loadAccount = this.$.restAPI.getAccount().then(account => {
- // Using Object.assign here allows preservation of the default values
- // supplied in the value generating function of this._account, unless
- // they are overridden by properties in the account from the response.
- this._account = Object.assign({}, this._account, account);
- });
-
- const loadConfig = this.$.restAPI.getConfig().then(config => {
- this._serverConfig = config;
- });
-
- return Promise.all([loadAccount, loadConfig]).then(() => {
- this._loading = false;
- });
- }
-
- _save() {
- this._saving = true;
- const promises = [
- this.$.restAPI.setAccountName(this.$.name.value),
- this.$.restAPI.setPreferredAccountEmail(this.$.email.value || ''),
- ];
-
- if (this._usernameMutable) {
- promises.push(this.$.restAPI.setAccountUsername(this.$.username.value));
- }
-
- return Promise.all(promises).then(() => {
- this._saving = false;
- this.dispatchEvent(new CustomEvent('account-detail-update', {
- composed: true, bubbles: true,
- }));
- });
- }
-
- _handleSave(e) {
- e.preventDefault();
- this._save().then(this.close.bind(this));
- }
-
- _handleClose(e) {
- e.preventDefault();
- this.close();
- }
-
- close() {
- this._saving = true; // disable buttons indefinitely
- this.dispatchEvent(new CustomEvent('close', {
- composed: true, bubbles: true,
- }));
- }
-
- _computeSaveDisabled(name, email, saving) {
- return !name || !email || saving;
- }
-
- _computeUsernameMutable(config, username) {
- // Polymer 2: check for undefined
- if ([
- config,
- username,
- ].includes(undefined)) {
- return undefined;
- }
-
- return config.auth.editable_account_fields.includes('USER_NAME') &&
- !username;
- }
-
- _computeUsernameClass(usernameMutable) {
- return usernameMutable ? '' : 'hide';
- }
-
- _loadingChanged() {
- this.classList.toggle('loading', this._loading);
- }
-}
-
-customElements.define(GrRegistrationDialog.is, GrRegistrationDialog);
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
new file mode 100644
index 0000000..0e73062
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.ts
@@ -0,0 +1,177 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../../styles/gr-form-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+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-registration-dialog_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {ServerInfo, AccountDetailInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {EditableAccountField} from '../../../constants/constants';
+
+export interface GrRegistrationDialog {
+ $: {
+ restAPI: RestApiService & Element;
+ name: HTMLInputElement;
+ username: HTMLInputElement;
+ email: HTMLSelectElement;
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-registration-dialog': GrRegistrationDialog;
+ }
+}
+
+@customElement('gr-registration-dialog')
+export class GrRegistrationDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when account details are changed.
+ *
+ * @event account-detail-update
+ */
+
+ /**
+ * Fired when the close button is pressed.
+ *
+ * @event close
+ */
+ @property({type: String})
+ settingsUrl?: string;
+
+ @property({type: Object})
+ _account: Partial<AccountDetailInfo> = {};
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Boolean})
+ _saving = false;
+
+ @property({type: Object})
+ _serverConfig?: ServerInfo;
+
+ @property({
+ computed: '_computeUsernameMutable(_serverConfig,_account.username)',
+ type: Boolean,
+ })
+ _usernameMutable = false;
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._ensureAttribute('role', 'dialog');
+ }
+
+ _computeUsernameMutable(config?: ServerInfo, username?: string) {
+ // Polymer 2: check for undefined
+ // username is not being checked for undefined as we want to avoid
+ // setting it null explicitly to trigger the computation
+ if (config === undefined) {
+ return false;
+ }
+
+ return (
+ config.auth.editable_account_fields.includes(
+ EditableAccountField.USER_NAME
+ ) && !username
+ );
+ }
+
+ loadData() {
+ this._loading = true;
+
+ const loadAccount = this.$.restAPI.getAccount().then(account => {
+ this._account = {...this._account, ...account};
+ });
+
+ const loadConfig = this.$.restAPI.getConfig().then(config => {
+ this._serverConfig = config;
+ });
+
+ return Promise.all([loadAccount, loadConfig]).then(() => {
+ this._loading = false;
+ });
+ }
+
+ _save() {
+ this._saving = true;
+ const promises = [
+ this.$.restAPI.setAccountName(this.$.name.value),
+ this.$.restAPI.setPreferredAccountEmail(this.$.email.value || ''),
+ ];
+
+ if (this._usernameMutable) {
+ promises.push(this.$.restAPI.setAccountUsername(this.$.username.value));
+ }
+
+ return Promise.all(promises).then(() => {
+ this._saving = false;
+ this.dispatchEvent(
+ new CustomEvent('account-detail-update', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }
+
+ _handleSave(e: Event) {
+ e.preventDefault();
+ this._save().then(() => this.close());
+ }
+
+ _handleClose(e: Event) {
+ e.preventDefault();
+ this.close();
+ }
+
+ close() {
+ this._saving = true; // disable buttons indefinitely
+ this.dispatchEvent(
+ new CustomEvent('close', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _computeSaveDisabled(name?: string, email?: string, saving?: boolean) {
+ return !name || !email || saving;
+ }
+
+ _computeUsernameClass(usernameMutable: boolean) {
+ return usernameMutable ? '' : 'hide';
+ }
+
+ @observe('_loading')
+ _loadingChanged() {
+ this.classList.toggle('loading', this._loading);
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
deleted file mode 100644
index 2455cec..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.js
+++ /dev/null
@@ -1,38 +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 {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-settings-item_html.js';
-
-/** @extends PolymerElement */
-class GrSettingsItem extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-settings-item'; }
-
- static get properties() {
- return {
- anchor: String,
- title: String,
- };
- }
-}
-
-customElements.define(GrSettingsItem.is, GrSettingsItem);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
new file mode 100644
index 0000000..5d80a84d
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
@@ -0,0 +1,42 @@
+/**
+ * @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 {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-settings-item_html';
+import {property, customElement} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-settings-item': GrSettingsItem;
+ }
+}
+
+@customElement('gr-settings-item')
+class GrSettingsItem extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ anchor?: string;
+
+ @property({type: String})
+ title = '';
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
deleted file mode 100644
index 4d839f8..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.js
+++ /dev/null
@@ -1,39 +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-page-nav-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-settings-menu-item_html.js';
-
-/** @extends PolymerElement */
-class GrSettingsMenuItem extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-settings-menu-item'; }
-
- static get properties() {
- return {
- href: String,
- title: String,
- };
- }
-}
-
-customElements.define(GrSettingsMenuItem.is, GrSettingsMenuItem);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
new file mode 100644
index 0000000..e288d20
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
@@ -0,0 +1,43 @@
+/**
+ * @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-page-nav-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-settings-menu-item_html';
+import {property, customElement} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-settings-menu-item': GrSettingsMenuItem;
+ }
+}
+
+@customElement('gr-settings-menu-item')
+class GrSettingsMenuItem extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ href?: string;
+
+ @property({type: String})
+ title = '';
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
deleted file mode 100644
index 06d9183..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ /dev/null
@@ -1,506 +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 '@polymer/iron-input/iron-input.js';
-import '@polymer/paper-toggle-button/paper-toggle-button.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/gr-menu-page-styles.js';
-import '../../../styles/gr-page-nav-styles.js';
-import '../../../styles/shared-styles.js';
-import {applyTheme as applyDarkTheme, removeTheme as removeDarkTheme} from '../../../styles/themes/dark-theme.js';
-import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.js';
-import '../gr-change-table-editor/gr-change-table-editor.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-date-formatter/gr-date-formatter.js';
-import '../../shared/gr-diff-preferences/gr-diff-preferences.js';
-import '../../shared/gr-page-nav/gr-page-nav.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../shared/gr-select/gr-select.js';
-import '../gr-account-info/gr-account-info.js';
-import '../gr-agreements-list/gr-agreements-list.js';
-import '../gr-edit-preferences/gr-edit-preferences.js';
-import '../gr-email-editor/gr-email-editor.js';
-import '../gr-gpg-editor/gr-gpg-editor.js';
-import '../gr-group-list/gr-group-list.js';
-import '../gr-http-password/gr-http-password.js';
-import '../gr-identities/gr-identities.js';
-import '../gr-menu-editor/gr-menu-editor.js';
-import '../gr-ssh-editor/gr-ssh-editor.js';
-import '../gr-watched-projects-editor/gr-watched-projects-editor.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-settings-view_html.js';
-import {getDocsBaseUrl} from '../../../utils/url-util.js';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin.js';
-
-const PREFS_SECTION_FIELDS = [
- 'changes_per_page',
- 'date_format',
- 'time_format',
- 'email_strategy',
- 'diff_view',
- 'publish_comments_on_push',
- 'work_in_progress_by_default',
- 'default_base_for_merges',
- 'signed_off_by',
- 'email_format',
- 'size_bar_in_change_table',
- 'relative_date_in_change_table',
-];
-
-const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
- 'Documentation';
-const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
-const ABSOLUTE_URL_PATTERN = /^https?:/;
-const TRAILING_SLASH_PATTERN = /\/$/;
-
-const HTTP_AUTH = [
- 'HTTP',
- 'HTTP_LDAP',
-];
-
-/**
- * @extends PolymerElement
- */
-class GrSettingsView extends ChangeTableMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-settings-view'; }
- /**
- * Fired when the title of the page should change.
- *
- * @event title-change
- */
-
- /**
- * Fired with email confirmation text, or when the page reloads.
- *
- * @event show-alert
- */
-
- static get properties() {
- return {
- prefs: {
- type: Object,
- value() { return {}; },
- },
- params: {
- type: Object,
- value() { return {}; },
- },
- _accountInfoChanged: Boolean,
- _changeTableColumnsNotDisplayed: Array,
- /** @type {?} */
- _localPrefs: {
- type: Object,
- value() { return {}; },
- },
- _localChangeTableColumns: {
- type: Array,
- value() { return []; },
- },
- _localMenu: {
- type: Array,
- value() { return []; },
- },
- _loading: {
- type: Boolean,
- value: true,
- },
- _changeTableChanged: {
- type: Boolean,
- value: false,
- },
- _prefsChanged: {
- type: Boolean,
- value: false,
- },
- /** @type {?} */
- _diffPrefsChanged: Boolean,
- /** @type {?} */
- _editPrefsChanged: Boolean,
- _menuChanged: {
- type: Boolean,
- value: false,
- },
- _watchedProjectsChanged: {
- type: Boolean,
- value: false,
- },
- _keysChanged: {
- type: Boolean,
- value: false,
- },
- _gpgKeysChanged: {
- type: Boolean,
- value: false,
- },
- _newEmail: String,
- _addingEmail: {
- type: Boolean,
- value: false,
- },
- _lastSentVerificationEmail: {
- type: String,
- value: null,
- },
- /** @type {?} */
- _serverConfig: Object,
- /** @type {?string} */
- _docsBaseUrl: String,
- _emailsChanged: Boolean,
-
- /**
- * For testing purposes.
- */
- _loadingPromise: Object,
-
- _showNumber: Boolean,
-
- _isDark: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_handlePrefsChanged(_localPrefs.*)',
- '_handleMenuChanged(_localMenu.splices)',
- '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
- ];
- }
-
- /** @override */
- attached() {
- super.attached();
- // Polymer 2: anchor tag won't work on shadow DOM
- // we need to manually calling scrollIntoView when hash changed
- this.listen(window, 'location-change', '_handleLocationChange');
- this.dispatchEvent(new CustomEvent('title-change', {
- detail: {title: 'Settings'},
- composed: true, bubbles: true,
- }));
-
- this._isDark = !!window.localStorage.getItem('dark-theme');
-
- const promises = [
- this.$.accountInfo.loadData(),
- this.$.watchedProjectsEditor.loadData(),
- this.$.groupList.loadData(),
- this.$.identities.loadData(),
- this.$.editPrefs.loadData(),
- this.$.diffPrefs.loadData(),
- ];
-
- promises.push(this.$.restAPI.getPreferences().then(prefs => {
- this.prefs = prefs;
- this._showNumber = !!prefs.legacycid_in_change_table;
- this._copyPrefs('_localPrefs', 'prefs');
- this._cloneMenu(prefs.my);
- this._cloneChangeTableColumns();
- }));
-
- promises.push(this.$.restAPI.getConfig().then(config => {
- this._serverConfig = config;
- const configPromises = [];
-
- if (this._serverConfig && this._serverConfig.sshd) {
- configPromises.push(this.$.sshEditor.loadData());
- }
-
- if (this._serverConfig &&
- this._serverConfig.receive &&
- this._serverConfig.receive.enable_signed_push) {
- configPromises.push(this.$.gpgEditor.loadData());
- }
-
- configPromises.push(
- getDocsBaseUrl(config, this.$.restAPI)
- .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
-
- return Promise.all(configPromises);
- }));
-
- if (this.params.emailToken) {
- promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
- message => {
- if (message) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message},
- composed: true, bubbles: true,
- }));
- }
- this.$.emailEditor.loadData();
- }));
- } else {
- promises.push(this.$.emailEditor.loadData());
- }
-
- this._loadingPromise = Promise.all(promises).then(() => {
- this._loading = false;
-
- // Handle anchor tag for initial load
- this._handleLocationChange();
- });
- }
-
- /** @override */
- detached() {
- super.detached();
- this.unlisten(window, 'location-change', '_handleLocationChange');
- }
-
- _handleLocationChange() {
- // Handle anchor tag after dom attached
- const urlHash = window.location.hash;
- if (urlHash) {
- // Use shadowRoot for Polymer 2
- const elem = (this.shadowRoot || document).querySelector(urlHash);
- if (elem) {
- elem.scrollIntoView();
- }
- }
- }
-
- reloadAccountDetail() {
- Promise.all([
- this.$.accountInfo.loadData(),
- this.$.emailEditor.loadData(),
- ]);
- }
-
- _isLoading() {
- return this._loading || this._loading === undefined;
- }
-
- _copyPrefs(to, from) {
- for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
- this.set([to, PREFS_SECTION_FIELDS[i]],
- this[from][PREFS_SECTION_FIELDS[i]]);
- }
- }
-
- _cloneMenu(prefs) {
- const menu = [];
- for (const item of prefs) {
- menu.push({
- name: item.name,
- url: item.url,
- target: item.target,
- });
- }
- this._localMenu = menu;
- }
-
- _cloneChangeTableColumns() {
- let columns = this.getVisibleColumns(this.prefs.change_table);
-
- if (columns.length === 0) {
- columns = this.columnNames;
- this._changeTableColumnsNotDisplayed = [];
- } else {
- this._changeTableColumnsNotDisplayed = this.getComplementColumns(
- this.prefs.change_table);
- }
- this._localChangeTableColumns = columns;
- }
-
- _formatChangeTableColumns(changeTableArray) {
- return changeTableArray.map(item => {
- return {column: item};
- });
- }
-
- _handleChangeTableChanged() {
- if (this._isLoading()) { return; }
- this._changeTableChanged = true;
- }
-
- _handlePrefsChanged(prefs) {
- if (this._isLoading()) { return; }
- this._prefsChanged = true;
- }
-
- _handleRelativeDateInChangeTable() {
- this.set('_localPrefs.relative_date_in_change_table',
- this.$.relativeDateInChangeTable.checked);
- }
-
- _handleShowSizeBarsInFileListChanged() {
- this.set('_localPrefs.size_bar_in_change_table',
- this.$.showSizeBarsInFileList.checked);
- }
-
- _handlePublishCommentsOnPushChanged() {
- this.set('_localPrefs.publish_comments_on_push',
- this.$.publishCommentsOnPush.checked);
- }
-
- _handleWorkInProgressByDefault() {
- this.set('_localPrefs.work_in_progress_by_default',
- this.$.workInProgressByDefault.checked);
- }
-
- _handleInsertSignedOff() {
- this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
- }
-
- _handleMenuChanged() {
- if (this._isLoading()) { return; }
- this._menuChanged = true;
- }
-
- _handleSaveAccountInfo() {
- this.$.accountInfo.save();
- }
-
- _handleSavePreferences() {
- this._copyPrefs('prefs', '_localPrefs');
-
- return this.$.restAPI.savePreferences(this.prefs).then(() => {
- this._prefsChanged = false;
- });
- }
-
- _handleSaveChangeTable() {
- this.set('prefs.change_table', this._localChangeTableColumns);
- this.set('prefs.legacycid_in_change_table', this._showNumber);
- this._cloneChangeTableColumns();
- return this.$.restAPI.savePreferences(this.prefs).then(() => {
- this._changeTableChanged = false;
- });
- }
-
- _handleSaveDiffPreferences() {
- this.$.diffPrefs.save();
- }
-
- _handleSaveEditPreferences() {
- this.$.editPrefs.save();
- }
-
- _handleSaveMenu() {
- this.set('prefs.my', this._localMenu);
- this._cloneMenu(this.prefs.my);
- return this.$.restAPI.savePreferences(this.prefs).then(() => {
- this._menuChanged = false;
- });
- }
-
- _handleResetMenuButton() {
- return this.$.restAPI.getDefaultPreferences().then(data => {
- if (data && data.my) {
- this._cloneMenu(data.my);
- }
- });
- }
-
- _handleSaveWatchedProjects() {
- this.$.watchedProjectsEditor.save();
- }
-
- _computeHeaderClass(changed) {
- return changed ? 'edited' : '';
- }
-
- _handleSaveEmails() {
- this.$.emailEditor.save();
- }
-
- _handleNewEmailKeydown(e) {
- if (e.keyCode === 13) { // Enter
- e.stopPropagation();
- this._handleAddEmailButton();
- }
- }
-
- _isNewEmailValid(newEmail) {
- return newEmail && newEmail.includes('@');
- }
-
- _computeAddEmailButtonEnabled(newEmail, addingEmail) {
- return this._isNewEmailValid(newEmail) && !addingEmail;
- }
-
- _handleAddEmailButton() {
- if (!this._isNewEmailValid(this._newEmail)) { return; }
-
- this._addingEmail = true;
- this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
- this._addingEmail = false;
-
- // If it was unsuccessful.
- if (response.status < 200 || response.status >= 300) { return; }
-
- this._lastSentVerificationEmail = this._newEmail;
- this._newEmail = '';
- });
- }
-
- _getFilterDocsLink(docsBaseUrl) {
- let base = docsBaseUrl;
- if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
- base = GERRIT_DOCS_BASE_URL;
- }
-
- // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
- base = base.replace(TRAILING_SLASH_PATTERN, '');
-
- return base + GERRIT_DOCS_FILTER_PATH;
- }
-
- _handleToggleDark() {
- if (this._isDark) {
- window.localStorage.removeItem('dark-theme');
- removeDarkTheme();
- } else {
- window.localStorage.setItem('dark-theme', 'true');
- applyDarkTheme();
- }
- this._isDark = !!window.localStorage.getItem('dark-theme');
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: `Theme changed to ${this._isDark ? 'dark' : 'light'}.`,
- },
- bubbles: true,
- composed: true,
- }));
- }
-
- _showHttpAuth(config) {
- if (config && config.auth &&
- config.auth.git_basic_auth_policy) {
- return HTTP_AUTH.includes(
- config.auth.git_basic_auth_policy.toUpperCase());
- }
-
- return false;
- }
-
- /**
- * Work around a issue on iOS when clicking turns into double tap
- */
- _onTapDarkToggle(e) {
- e.preventDefault();
- }
-}
-
-customElements.define(GrSettingsView.is, GrSettingsView);
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
new file mode 100644
index 0000000..9f9840b
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.ts
@@ -0,0 +1,578 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-form-styles';
+import '../../../styles/gr-menu-page-styles';
+import '../../../styles/gr-page-nav-styles';
+import '../../../styles/shared-styles';
+import {
+ applyTheme as applyDarkTheme,
+ removeTheme as removeDarkTheme,
+} from '../../../styles/themes/dark-theme';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../gr-change-table-editor/gr-change-table-editor';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-date-formatter/gr-date-formatter';
+import '../../shared/gr-diff-preferences/gr-diff-preferences';
+import '../../shared/gr-page-nav/gr-page-nav';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../shared/gr-select/gr-select';
+import '../gr-account-info/gr-account-info';
+import '../gr-agreements-list/gr-agreements-list';
+import '../gr-edit-preferences/gr-edit-preferences';
+import '../gr-email-editor/gr-email-editor';
+import '../gr-gpg-editor/gr-gpg-editor';
+import '../gr-group-list/gr-group-list';
+import '../gr-http-password/gr-http-password';
+import '../gr-identities/gr-identities';
+import '../gr-menu-editor/gr-menu-editor';
+import '../gr-ssh-editor/gr-ssh-editor';
+import '../gr-watched-projects-editor/gr-watched-projects-editor';
+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-settings-view_html';
+import {getDocsBaseUrl} from '../../../utils/url-util';
+import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
+import {customElement, property, observe} from '@polymer/decorators';
+import {AppElementParams} from '../../gr-app-types';
+import {GrAccountInfo} from '../gr-account-info/gr-account-info';
+import {GrWatchedProjectsEditor} from '../gr-watched-projects-editor/gr-watched-projects-editor';
+import {GrGroupList} from '../gr-group-list/gr-group-list';
+import {GrIdentities} from '../gr-identities/gr-identities';
+import {GrEditPreferences} from '../gr-edit-preferences/gr-edit-preferences';
+import {GrDiffPreferences} from '../../shared/gr-diff-preferences/gr-diff-preferences';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ PreferencesInput,
+ ServerInfo,
+ TopMenuItemInfo,
+} from '../../../types/common';
+import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
+import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
+import {GerritView} from '../../core/gr-navigation/gr-navigation';
+import {GrEmailEditor} from '../gr-email-editor/gr-email-editor';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
+ 'changes_per_page',
+ 'date_format',
+ 'time_format',
+ 'email_strategy',
+ 'diff_view',
+ 'publish_comments_on_push',
+ 'work_in_progress_by_default',
+ 'default_base_for_merges',
+ 'signed_off_by',
+ 'email_format',
+ 'size_bar_in_change_table',
+ 'relative_date_in_change_table',
+];
+
+const GERRIT_DOCS_BASE_URL =
+ 'https://gerrit-review.googlesource.com/' + 'Documentation';
+const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
+const ABSOLUTE_URL_PATTERN = /^https?:/;
+const TRAILING_SLASH_PATTERN = /\/$/;
+
+const HTTP_AUTH = ['HTTP', 'HTTP_LDAP'];
+
+enum CopyPrefsDirection {
+ PrefsToLocalPrefs,
+ LocalPrefsToPrefs,
+}
+
+type LocalMenuItemInfo = Omit<TopMenuItemInfo, 'id'>;
+
+export interface GrSettingsView {
+ $: {
+ restAPI: RestApiService & Element;
+ accountInfo: GrAccountInfo;
+ watchedProjectsEditor: GrWatchedProjectsEditor;
+ groupList: GrGroupList;
+ identities: GrIdentities;
+ editPrefs: GrEditPreferences;
+ diffPrefs: GrDiffPreferences;
+ sshEditor: GrSshEditor;
+ gpgEditor: GrGpgEditor;
+ emailEditor: GrEmailEditor;
+ insertSignedOff: HTMLInputElement;
+ workInProgressByDefault: HTMLInputElement;
+ showSizeBarsInFileList: HTMLInputElement;
+ publishCommentsOnPush: HTMLInputElement;
+ relativeDateInChangeTable: HTMLInputElement;
+ };
+}
+
+@customElement('gr-settings-view')
+export class GrSettingsView extends ChangeTableMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the title of the page should change.
+ *
+ * @event title-change
+ */
+
+ /**
+ * Fired with email confirmation text, or when the page reloads.
+ *
+ * @event show-alert
+ */
+
+ @property({type: Object})
+ prefs: PreferencesInput = {};
+
+ @property({type: Object})
+ params?: AppElementParams;
+
+ @property({type: Boolean})
+ _accountInfoChanged?: boolean;
+
+ @property({type: Array})
+ _changeTableColumnsNotDisplayed?: string[];
+
+ @property({type: Object})
+ _localPrefs: PreferencesInput = {};
+
+ @property({type: Array})
+ _localChangeTableColumns: string[] = [];
+
+ @property({type: Array})
+ _localMenu: LocalMenuItemInfo[] = [];
+
+ @property({type: Boolean})
+ _loading = true;
+
+ @property({type: Boolean})
+ _changeTableChanged = false;
+
+ @property({type: Boolean})
+ _prefsChanged = false;
+
+ @property({type: Boolean})
+ _diffPrefsChanged?: boolean;
+
+ @property({type: Boolean})
+ _editPrefsChanged?: boolean;
+
+ @property({type: Boolean})
+ _menuChanged = false;
+
+ @property({type: Boolean})
+ _watchedProjectsChanged = false;
+
+ @property({type: Boolean})
+ _keysChanged = false;
+
+ @property({type: Boolean})
+ _gpgKeysChanged = false;
+
+ @property({type: String})
+ _newEmail?: string;
+
+ @property({type: Boolean})
+ _addingEmail = false;
+
+ @property({type: String})
+ _lastSentVerificationEmail?: string | null = null;
+
+ @property({type: Object})
+ _serverConfig?: ServerInfo;
+
+ @property({type: String})
+ _docsBaseUrl?: string | null;
+
+ @property({type: Boolean})
+ _emailsChanged?: boolean;
+
+ @property({type: Boolean})
+ _showNumber?: boolean;
+
+ @property({type: Boolean})
+ _isDark = false;
+
+ public _testOnly_loadingPromise?: Promise<void>;
+
+ /** @override */
+ attached() {
+ super.attached();
+ // Polymer 2: anchor tag won't work on shadow DOM
+ // we need to manually calling scrollIntoView when hash changed
+ this.listen(window, 'location-change', '_handleLocationChange');
+ this.dispatchEvent(
+ new CustomEvent('title-change', {
+ detail: {title: 'Settings'},
+ composed: true,
+ bubbles: true,
+ })
+ );
+
+ this._isDark = !!window.localStorage.getItem('dark-theme');
+
+ const promises: Array<Promise<unknown>> = [
+ this.$.accountInfo.loadData(),
+ this.$.watchedProjectsEditor.loadData(),
+ this.$.groupList.loadData(),
+ this.$.identities.loadData(),
+ this.$.editPrefs.loadData(),
+ this.$.diffPrefs.loadData(),
+ ];
+
+ promises.push(
+ this.$.restAPI.getPreferences().then(prefs => {
+ if (!prefs) {
+ throw new Error('getPreferences returned undefined');
+ }
+ this.prefs = prefs;
+ this._showNumber = !!prefs.legacycid_in_change_table;
+ this._copyPrefs(CopyPrefsDirection.PrefsToLocalPrefs);
+ this._cloneMenu(prefs.my);
+ this._cloneChangeTableColumns(prefs.change_table);
+ })
+ );
+
+ promises.push(
+ this.$.restAPI.getConfig().then(config => {
+ this._serverConfig = config;
+ const configPromises: Array<Promise<void>> = [];
+
+ if (this._serverConfig && this._serverConfig.sshd) {
+ configPromises.push(this.$.sshEditor.loadData());
+ }
+
+ if (
+ this._serverConfig &&
+ this._serverConfig.receive &&
+ this._serverConfig.receive.enable_signed_push
+ ) {
+ configPromises.push(this.$.gpgEditor.loadData());
+ }
+
+ configPromises.push(
+ getDocsBaseUrl(config, this.$.restAPI).then(baseUrl => {
+ this._docsBaseUrl = baseUrl;
+ })
+ );
+
+ return Promise.all(configPromises);
+ })
+ );
+
+ if (
+ this.params &&
+ this.params.view === GerritView.SETTINGS &&
+ this.params.emailToken
+ ) {
+ promises.push(
+ this.$.restAPI.confirmEmail(this.params.emailToken).then(message => {
+ if (message) {
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ this.$.emailEditor.loadData();
+ })
+ );
+ } else {
+ promises.push(this.$.emailEditor.loadData());
+ }
+
+ this._testOnly_loadingPromise = Promise.all(promises).then(() => {
+ this._loading = false;
+
+ // Handle anchor tag for initial load
+ this._handleLocationChange();
+ });
+ }
+
+ detached() {
+ super.detached();
+ this.unlisten(window, 'location-change', '_handleLocationChange');
+ }
+
+ _handleLocationChange() {
+ // Handle anchor tag after dom attached
+ const urlHash = window.location.hash;
+ if (urlHash) {
+ // Use shadowRoot for Polymer 2
+ const elem = (this.shadowRoot || document).querySelector(urlHash);
+ if (elem) {
+ elem.scrollIntoView();
+ }
+ }
+ }
+
+ reloadAccountDetail() {
+ Promise.all([this.$.accountInfo.loadData(), this.$.emailEditor.loadData()]);
+ }
+
+ _isLoading() {
+ return this._loading || this._loading === undefined;
+ }
+
+ _copyPrefs(direction: CopyPrefsDirection) {
+ let to;
+ let from;
+ if (direction === CopyPrefsDirection.LocalPrefsToPrefs) {
+ from = this._localPrefs;
+ to = 'prefs';
+ } else {
+ from = this.prefs;
+ to = '_localPrefs';
+ }
+ for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
+ this.set([to, PREFS_SECTION_FIELDS[i]], from[PREFS_SECTION_FIELDS[i]]);
+ }
+ }
+
+ _cloneMenu(prefs: TopMenuItemInfo[]) {
+ const menu = [];
+ for (const item of prefs) {
+ menu.push({
+ name: item.name,
+ url: item.url,
+ target: item.target,
+ });
+ }
+ this._localMenu = menu;
+ }
+
+ _cloneChangeTableColumns(changeTable: string[]) {
+ let columns = this.getVisibleColumns(changeTable);
+
+ if (columns.length === 0) {
+ columns = this.columnNames;
+ this._changeTableColumnsNotDisplayed = [];
+ } else {
+ this._changeTableColumnsNotDisplayed = this.getComplementColumns(
+ changeTable
+ );
+ }
+ this._localChangeTableColumns = columns;
+ }
+
+ @observe('_localChangeTableColumns', '_showNumber')
+ _handleChangeTableChanged() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._changeTableChanged = true;
+ }
+
+ @observe('_localPrefs.*')
+ _handlePrefsChanged() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._prefsChanged = true;
+ }
+
+ _handleRelativeDateInChangeTable() {
+ this.set(
+ '_localPrefs.relative_date_in_change_table',
+ this.$.relativeDateInChangeTable.checked
+ );
+ }
+
+ _handleShowSizeBarsInFileListChanged() {
+ this.set(
+ '_localPrefs.size_bar_in_change_table',
+ this.$.showSizeBarsInFileList.checked
+ );
+ }
+
+ _handlePublishCommentsOnPushChanged() {
+ this.set(
+ '_localPrefs.publish_comments_on_push',
+ this.$.publishCommentsOnPush.checked
+ );
+ }
+
+ _handleWorkInProgressByDefault() {
+ this.set(
+ '_localPrefs.work_in_progress_by_default',
+ this.$.workInProgressByDefault.checked
+ );
+ }
+
+ _handleInsertSignedOff() {
+ this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
+ }
+
+ @observe('_localMenu.splices')
+ _handleMenuChanged() {
+ if (this._isLoading()) {
+ return;
+ }
+ this._menuChanged = true;
+ }
+
+ _handleSaveAccountInfo() {
+ this.$.accountInfo.save();
+ }
+
+ _handleSavePreferences() {
+ this._copyPrefs(CopyPrefsDirection.LocalPrefsToPrefs);
+
+ return this.$.restAPI.savePreferences(this.prefs).then(() => {
+ this._prefsChanged = false;
+ });
+ }
+
+ _handleSaveChangeTable() {
+ this.set('prefs.change_table', this._localChangeTableColumns);
+ this.set('prefs.legacycid_in_change_table', this._showNumber);
+ this._cloneChangeTableColumns(this._localChangeTableColumns);
+ return this.$.restAPI.savePreferences(this.prefs).then(() => {
+ this._changeTableChanged = false;
+ });
+ }
+
+ _handleSaveDiffPreferences() {
+ this.$.diffPrefs.save();
+ }
+
+ _handleSaveEditPreferences() {
+ this.$.editPrefs.save();
+ }
+
+ _handleSaveMenu() {
+ this.set('prefs.my', this._localMenu);
+ this._cloneMenu(this._localMenu);
+ return this.$.restAPI.savePreferences(this.prefs).then(() => {
+ this._menuChanged = false;
+ });
+ }
+
+ _handleResetMenuButton() {
+ return this.$.restAPI.getDefaultPreferences().then(data => {
+ if (data?.my) {
+ this._cloneMenu(data.my);
+ }
+ });
+ }
+
+ _handleSaveWatchedProjects() {
+ this.$.watchedProjectsEditor.save();
+ }
+
+ _computeHeaderClass(changed?: boolean) {
+ return changed ? 'edited' : '';
+ }
+
+ _handleSaveEmails() {
+ this.$.emailEditor.save();
+ }
+
+ _handleNewEmailKeydown(e: CustomKeyboardEvent) {
+ if (e.keyCode === 13) {
+ // Enter
+ e.stopPropagation();
+ this._handleAddEmailButton();
+ }
+ }
+
+ _isNewEmailValid(newEmail?: string): newEmail is string {
+ return !!newEmail && newEmail.includes('@');
+ }
+
+ _computeAddEmailButtonEnabled(newEmail?: string, addingEmail?: boolean) {
+ return this._isNewEmailValid(newEmail) && !addingEmail;
+ }
+
+ _handleAddEmailButton() {
+ if (!this._isNewEmailValid(this._newEmail)) return;
+
+ this._addingEmail = true;
+ this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
+ this._addingEmail = false;
+
+ // If it was unsuccessful.
+ if (response.status < 200 || response.status >= 300) {
+ return;
+ }
+
+ this._lastSentVerificationEmail = this._newEmail;
+ this._newEmail = '';
+ });
+ }
+
+ _getFilterDocsLink(docsBaseUrl?: string) {
+ let base = docsBaseUrl;
+ if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
+ base = GERRIT_DOCS_BASE_URL;
+ }
+
+ // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
+ base = base.replace(TRAILING_SLASH_PATTERN, '');
+
+ return base + GERRIT_DOCS_FILTER_PATH;
+ }
+
+ _handleToggleDark() {
+ if (this._isDark) {
+ window.localStorage.removeItem('dark-theme');
+ removeDarkTheme();
+ } else {
+ window.localStorage.setItem('dark-theme', 'true');
+ applyDarkTheme();
+ }
+ this._isDark = !!window.localStorage.getItem('dark-theme');
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: `Theme changed to ${this._isDark ? 'dark' : 'light'}.`,
+ },
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ _showHttpAuth(config?: ServerInfo) {
+ if (config && config.auth && config.auth.git_basic_auth_policy) {
+ return HTTP_AUTH.includes(
+ config.auth.git_basic_auth_policy.toUpperCase()
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Work around a issue on iOS when clicking turns into double tap
+ */
+ _onTapDarkToggle(e: Event) {
+ e.preventDefault();
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-settings-view': GrSettingsView;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
index e80e646..78f84b1 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_html.ts
@@ -175,6 +175,9 @@
<select>
<option value="CC_ON_OWN_COMMENTS">Every comment</option>
<option value="ENABLED">Only comments left by others</option>
+ <option value="ATTENTION_SET_ONLY"
+ >Only when I am in the attention set</option
+ >
<option value="DISABLED">None</option>
</select>
</gr-select>
@@ -494,6 +497,15 @@
</td>
</tr>
<tr>
+ <td>Changes requesting my attention</td>
+ <td>
+ <code class="queryExample">
+ "Gerrit-Attention: <em>Your Name</em>
+ <<em>your.email@example.com</em>>"
+ </code>
+ </td>
+ </tr>
+ <tr>
<td>Changes from a specific owner</td>
<td>
<code class="queryExample">
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
index c0ec344..0535e15 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
@@ -18,7 +18,8 @@
import '../../../test/common-test-setup-karma.js';
import {getComputedStyleValue} from '../../../utils/dom-util.js';
import './gr-settings-view.js';
-import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {GerritView} from '../../core/gr-navigation/gr-navigation.js';
const basicFixture = fixtureFromElement('gr-settings-view');
const blankFixture = fixtureFromElement('div');
@@ -95,7 +96,7 @@
element = basicFixture.instantiate();
// Allow the element to render.
- element._loadingPromise.then(done);
+ element._testOnly_loadingPromise.then(done);
});
test('theme changing', () => {
@@ -249,14 +250,14 @@
assertMenusEqual(element._localMenu, preferences.my);
const menu = element.$.menu.firstElementChild;
- let tableRows = dom(menu.root).querySelectorAll('tbody tr');
+ let tableRows = menu.root.querySelectorAll('tbody tr');
assert.equal(tableRows.length, preferences.my.length);
// Add a menu item:
element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
flush();
- tableRows = dom(menu.root).querySelectorAll('tbody tr');
+ tableRows = menu.root.querySelectorAll('tbody tr');
assert.equal(tableRows.length, preferences.my.length + 1);
assert.isTrue(element._menuChanged);
@@ -485,7 +486,7 @@
.callsFake(
() => new Promise(
resolve => { resolveConfirm = resolve; }));
- element.params = {emailToken: 'foo'};
+ element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
element.attached();
});
@@ -499,7 +500,7 @@
});
test('user emails are loaded after email confirmed', done => {
- element._loadingPromise.then(() => {
+ element._testOnly_loadingPromise.then(() => {
assert.isTrue(element.$.emailEditor.loadData.calledOnce);
done();
});
@@ -508,7 +509,7 @@
test('show-alert is fired when email is confirmed', done => {
sinon.spy(element, 'dispatchEvent');
- element._loadingPromise.then(() => {
+ element._testOnly_loadingPromise.then(() => {
assert.equal(
element.dispatchEvent.lastCall.args[0].type, 'show-alert');
assert.deepEqual(
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
deleted file mode 100644
index d869128..0000000
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
+++ /dev/null
@@ -1,119 +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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '../../../styles/gr-form-styles.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-copy-clipboard/gr-copy-clipboard.js';
-import '../../shared/gr-overlay/gr-overlay.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-ssh-editor_html.js';
-
-/** @extends PolymerElement */
-class GrSshEditor extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-ssh-editor'; }
-
- static get properties() {
- return {
- hasUnsavedChanges: {
- type: Boolean,
- value: false,
- notify: true,
- },
- _keys: Array,
- /** @type {?} */
- _keyToView: Object,
- _newKey: {
- type: String,
- value: '',
- },
- _keysToRemove: {
- type: Array,
- value() { return []; },
- },
- };
- }
-
- loadData() {
- return this.$.restAPI.getAccountSSHKeys().then(keys => {
- this._keys = keys;
- });
- }
-
- save() {
- const promises = this._keysToRemove.map(key => {
- this.$.restAPI.deleteAccountSSHKey(key.seq);
- });
-
- return Promise.all(promises).then(() => {
- this._keysToRemove = [];
- this.hasUnsavedChanges = false;
- });
- }
-
- _getStatusLabel(isValid) {
- return isValid ? 'Valid' : 'Invalid';
- }
-
- _showKey(e) {
- const el = dom(e).localTarget;
- const index = parseInt(el.getAttribute('data-index'), 10);
- this._keyToView = this._keys[index];
- this.$.viewKeyOverlay.open();
- }
-
- _closeOverlay() {
- this.$.viewKeyOverlay.close();
- }
-
- _handleDeleteKey(e) {
- const el = dom(e).localTarget;
- const index = parseInt(el.getAttribute('data-index'), 10);
- this.push('_keysToRemove', this._keys[index]);
- this.splice('_keys', index, 1);
- this.hasUnsavedChanges = true;
- }
-
- _handleAddKey() {
- this.$.addButton.disabled = true;
- this.$.newKey.disabled = true;
- return this.$.restAPI.addAccountSSHKey(this._newKey.trim())
- .then(key => {
- this.$.newKey.disabled = false;
- this._newKey = '';
- this.push('_keys', key);
- })
- .catch(() => {
- this.$.addButton.disabled = false;
- this.$.newKey.disabled = false;
- });
- }
-
- _computeAddButtonDisabled(newKey) {
- return !newKey.length;
- }
-}
-
-customElements.define(GrSshEditor.is, GrSshEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
new file mode 100644
index 0000000..507caef
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.ts
@@ -0,0 +1,132 @@
+/**
+ * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-form-styles';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
+import '../../shared/gr-overlay/gr-overlay';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-ssh-editor_html';
+import {property, customElement} from '@polymer/decorators';
+import {SshKeyInfo} from '../../../types/common';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export interface GrSshEditor {
+ $: {
+ restAPI: RestApiService & Element;
+ addButton: GrButton;
+ newKey: IronAutogrowTextareaElement;
+ viewKeyOverlay: GrOverlay;
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-ssh-editor': GrSshEditor;
+ }
+}
+@customElement('gr-ssh-editor')
+export class GrSshEditor extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean, notify: true})
+ hasUnsavedChanges = false;
+
+ @property({type: Array})
+ _keys: SshKeyInfo[] = [];
+
+ @property({type: Object})
+ _keyToView?: SshKeyInfo;
+
+ @property({type: String})
+ _newKey = '';
+
+ @property({type: Array})
+ _keysToRemove: SshKeyInfo[] = [];
+
+ loadData() {
+ return this.$.restAPI.getAccountSSHKeys().then(keys => {
+ if (!keys) return;
+ this._keys = keys;
+ });
+ }
+
+ save() {
+ const promises = this._keysToRemove.map(key =>
+ this.$.restAPI.deleteAccountSSHKey(`${key.seq}`)
+ );
+ return Promise.all(promises).then(() => {
+ this._keysToRemove = [];
+ this.hasUnsavedChanges = false;
+ });
+ }
+
+ _getStatusLabel(isValid: boolean) {
+ return isValid ? 'Valid' : 'Invalid';
+ }
+
+ _showKey(e: Event) {
+ const el = (dom(e) as EventApi).localTarget as GrButton;
+ const index = Number(el.getAttribute('data-index')!);
+ this._keyToView = this._keys[index];
+ this.$.viewKeyOverlay.open();
+ }
+
+ _closeOverlay() {
+ this.$.viewKeyOverlay.close();
+ }
+
+ _handleDeleteKey(e: Event) {
+ const el = (dom(e) as EventApi).localTarget as GrButton;
+ const index = Number(el.getAttribute('data-index')!);
+ this.push('_keysToRemove', this._keys[index]);
+ this.splice('_keys', index, 1);
+ this.hasUnsavedChanges = true;
+ }
+
+ _handleAddKey() {
+ this.$.addButton.disabled = true;
+ this.$.newKey.disabled = true;
+ return this.$.restAPI
+ .addAccountSSHKey(this._newKey.trim())
+ .then(key => {
+ this.$.newKey.disabled = false;
+ this._newKey = '';
+ this.push('_keys', key);
+ })
+ .catch(() => {
+ this.$.addButton.disabled = false;
+ this.$.newKey.disabled = false;
+ });
+ }
+
+ _computeAddButtonDisabled(newKey: string) {
+ return !newKey.length;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
index d4a0372..29ba692 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-ssh-editor.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-ssh-editor');
@@ -52,7 +51,7 @@
});
test('renders', () => {
- const rows = dom(element.root).querySelectorAll('tbody tr');
+ const rows = element.root.querySelectorAll('tbody tr');
assert.equal(rows.length, 2);
@@ -73,7 +72,7 @@
assert.isFalse(element.hasUnsavedChanges);
// Get the delete button for the last row.
- const button = dom(element.root).querySelector(
+ const button = element.root.querySelector(
'tbody tr:last-of-type td:nth-child(5) gr-button');
MockInteractions.tap(button);
@@ -97,7 +96,7 @@
const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
// Get the show button for the last row.
- const button = dom(element.root).querySelector(
+ const button = element.root.querySelector(
'tbody tr:last-of-type td:nth-child(3) gr-button');
MockInteractions.tap(button);
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
deleted file mode 100644
index 2af1bc7..0000000
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
+++ /dev/null
@@ -1,192 +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 '@polymer/iron-input/iron-input.js';
-import '../../shared/gr-autocomplete/gr-autocomplete.js';
-import '../../shared/gr-button/gr-button.js';
-import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import '../../../styles/gr-form-styles.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-watched-projects-editor_html.js';
-
-const NOTIFICATION_TYPES = [
- {name: 'Changes', key: 'notify_new_changes'},
- {name: 'Patches', key: 'notify_new_patch_sets'},
- {name: 'Comments', key: 'notify_all_comments'},
- {name: 'Submits', key: 'notify_submitted_changes'},
- {name: 'Abandons', key: 'notify_abandoned_changes'},
-];
-
-/** @extends PolymerElement */
-class GrWatchedProjectsEditor extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-watched-projects-editor'; }
-
- static get properties() {
- return {
- hasUnsavedChanges: {
- type: Boolean,
- value: false,
- notify: true,
- },
-
- _projects: Array,
- _projectsToRemove: {
- type: Array,
- value() { return []; },
- },
- _query: {
- type: Function,
- value() {
- return this._getProjectSuggestions.bind(this);
- },
- },
- };
- }
-
- loadData() {
- return this.$.restAPI.getWatchedProjects().then(projs => {
- this._projects = projs;
- });
- }
-
- save() {
- let deletePromise;
- if (this._projectsToRemove.length) {
- deletePromise = this.$.restAPI.deleteWatchedProjects(
- this._projectsToRemove);
- } else {
- deletePromise = Promise.resolve();
- }
-
- return deletePromise
- .then(() => this.$.restAPI.saveWatchedProjects(this._projects))
- .then(projects => {
- this._projects = projects;
- this._projectsToRemove = [];
- this.hasUnsavedChanges = false;
- });
- }
-
- _getTypes() {
- return NOTIFICATION_TYPES;
- }
-
- _getTypeCount() {
- return this._getTypes().length;
- }
-
- _computeCheckboxChecked(project, key) {
- return project.hasOwnProperty(key);
- }
-
- _getProjectSuggestions(input) {
- return this.$.restAPI.getSuggestedProjects(input)
- .then(response => {
- const projects = [];
- for (const key in response) {
- if (!response.hasOwnProperty(key)) { continue; }
- projects.push({
- name: key,
- value: response[key],
- });
- }
- return projects;
- });
- }
-
- _handleRemoveProject(e) {
- const el = dom(e).localTarget;
- const index = parseInt(el.getAttribute('data-index'), 10);
- const project = this._projects[index];
- this.splice('_projects', index, 1);
- this.push('_projectsToRemove', project);
- this.hasUnsavedChanges = true;
- }
-
- _canAddProject(project, text, filter) {
- if ((!project || !project.id) && !text) { return false; }
-
- // This will only be used if not using the auto complete
- if (!project && text) { return true; }
-
- // Check if the project with filter is already in the list. Compare
- // filters using == to coalesce null and undefined.
- for (let i = 0; i < this._projects.length; i++) {
- if (this._projects[i].project === project.id &&
- this._projects[i].filter == filter) {
- return false;
- }
- }
-
- return true;
- }
-
- _getNewProjectIndex(name, filter) {
- let i;
- for (i = 0; i < this._projects.length; i++) {
- if (this._projects[i].project > name ||
- (this._projects[i].project === name &&
- this._projects[i].filter > filter)) {
- break;
- }
- }
- return i;
- }
-
- _handleAddProject() {
- const newProject = this.$.newProject.value;
- const newProjectName = this.$.newProject.text;
- const filter = this.$.newFilter.value || null;
-
- if (!this._canAddProject(newProject, newProjectName, filter)) { return; }
-
- const insertIndex = this._getNewProjectIndex(newProjectName, filter);
-
- this.splice('_projects', insertIndex, 0, {
- project: newProjectName,
- filter,
- _is_local: true,
- });
-
- this.$.newProject.clear();
- this.$.newFilter.bindValue = '';
- this.hasUnsavedChanges = true;
- }
-
- _handleCheckboxChange(e) {
- const el = dom(e).localTarget;
- const index = parseInt(el.getAttribute('data-index'), 10);
- const key = el.getAttribute('data-key');
- const checked = el.checked;
- this.set(['_projects', index, key], !!checked);
- this.hasUnsavedChanges = true;
- }
-
- _handleNotifCellClick(e) {
- const checkbox = dom(e.target).querySelector('input');
- if (checkbox) { checkbox.click(); }
- }
-}
-
-customElements.define(GrWatchedProjectsEditor.is, GrWatchedProjectsEditor);
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
new file mode 100644
index 0000000..15f9c6b
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.ts
@@ -0,0 +1,258 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../shared/gr-autocomplete/gr-autocomplete';
+import '../../shared/gr-button/gr-button';
+import '../../shared/gr-rest-api-interface/gr-rest-api-interface';
+import '../../../styles/gr-form-styles';
+import '../../../styles/shared-styles';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-watched-projects-editor_html';
+import {customElement, property} from '@polymer/decorators';
+import {
+ AutocompleteQuery,
+ GrAutocomplete,
+ AutocompleteSuggestion,
+} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {ProjectWatchInfo} from '../../../types/common';
+
+const NOTIFICATION_TYPES = [
+ {name: 'Changes', key: 'notify_new_changes'},
+ {name: 'Patches', key: 'notify_new_patch_sets'},
+ {name: 'Comments', key: 'notify_all_comments'},
+ {name: 'Submits', key: 'notify_submitted_changes'},
+ {name: 'Abandons', key: 'notify_abandoned_changes'},
+];
+
+export interface GrWatchedProjectsEditor {
+ $: {
+ restAPI: RestApiService & Element;
+ newFilter: HTMLInputElement;
+ newProject: GrAutocomplete;
+ };
+}
+@customElement('gr-watched-projects-editor')
+export class GrWatchedProjectsEditor extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean, notify: true})
+ hasUnsavedChanges = false;
+
+ @property({type: Array})
+ _projects?: ProjectWatchInfo[];
+
+ @property({type: Array})
+ _projectsToRemove: ProjectWatchInfo[] = [];
+
+ @property({type: Object})
+ _query?: AutocompleteQuery;
+
+ constructor() {
+ super();
+ this._query = input => this._getProjectSuggestions(input);
+ }
+
+ loadData() {
+ return this.$.restAPI.getWatchedProjects().then(projs => {
+ this._projects = projs;
+ });
+ }
+
+ save() {
+ let deletePromise;
+ if (this._projectsToRemove.length) {
+ deletePromise = this.$.restAPI.deleteWatchedProjects(
+ this._projectsToRemove
+ );
+ } else {
+ deletePromise = Promise.resolve(undefined);
+ }
+
+ return deletePromise
+ .then(() => {
+ if (this._projects) {
+ return this.$.restAPI.saveWatchedProjects(this._projects);
+ } else {
+ return Promise.resolve(undefined);
+ }
+ })
+ .then(projects => {
+ this._projects = projects;
+ this._projectsToRemove = [];
+ this.hasUnsavedChanges = false;
+ });
+ }
+
+ _getTypes() {
+ return NOTIFICATION_TYPES;
+ }
+
+ _getTypeCount() {
+ return this._getTypes().length;
+ }
+
+ _computeCheckboxChecked(project: ProjectWatchInfo, key: string) {
+ return hasOwnProperty(project, key);
+ }
+
+ _getProjectSuggestions(input: string) {
+ return this.$.restAPI.getSuggestedProjects(input).then(response => {
+ const projects: AutocompleteSuggestion[] = [];
+ for (const key in response) {
+ if (!hasOwnProperty(response, key)) {
+ continue;
+ }
+ projects.push({
+ name: key,
+ value: response[key].id,
+ });
+ }
+ return projects;
+ });
+ }
+
+ _handleRemoveProject(e: Event) {
+ const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
+ const dataIndex = el.getAttribute('data-index');
+ if (dataIndex === null || !this._projects) return;
+ const index = Number(dataIndex);
+ const project = this._projects[index];
+ this.splice('_projects', index, 1);
+ this.push('_projectsToRemove', project);
+ this.hasUnsavedChanges = true;
+ }
+
+ _canAddProject(
+ project: string | null,
+ text: string | null,
+ filter: string | null
+ ) {
+ if (project === null && text === null) {
+ return false;
+ }
+
+ // This will only be used if not using the auto complete
+ if (!project && text) {
+ return true;
+ }
+
+ if (!this._projects) return true;
+ // Check if the project with filter is already in the list.
+ for (let i = 0; i < this._projects.length; i++) {
+ if (
+ this._projects[i].project === project &&
+ this.areFiltersEqual(this._projects[i].filter, filter)
+ ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ _getNewProjectIndex(name: string, filter: string | null) {
+ if (!this._projects) return;
+ let i;
+ for (i = 0; i < this._projects.length; i++) {
+ const projectFilter = this._projects[i].filter;
+ if (
+ this._projects[i].project > name ||
+ (this._projects[i].project === name &&
+ this.isFilterDefined(projectFilter) &&
+ this.isFilterDefined(filter) &&
+ projectFilter! > filter!)
+ ) {
+ break;
+ }
+ }
+ return i;
+ }
+
+ _handleAddProject() {
+ const newProject = this.$.newProject.value;
+ const newProjectName = this.$.newProject.text;
+ const filter = this.$.newFilter.value || null;
+
+ if (!this._canAddProject(newProject, newProjectName, filter)) {
+ return;
+ }
+
+ const insertIndex = this._getNewProjectIndex(newProjectName, filter);
+
+ if (insertIndex !== undefined) {
+ this.splice('_projects', insertIndex, 0, {
+ project: newProjectName,
+ filter,
+ _is_local: true,
+ });
+ }
+
+ this.$.newProject.clear();
+ this.$.newFilter.value = '';
+ this.hasUnsavedChanges = true;
+ }
+
+ _handleCheckboxChange(e: Event) {
+ const el = (dom(e) as EventApi).localTarget as HTMLInputElement;
+ if (el === null) return;
+ const dataIndex = el.getAttribute('data-index');
+ const key = el.getAttribute('data-key');
+ if (dataIndex === null || key === null) return;
+ const index = Number(dataIndex);
+ const checked = el.checked;
+ this.set(['_projects', index, key], !!checked);
+ this.hasUnsavedChanges = true;
+ }
+
+ _handleNotifCellClick(e: Event) {
+ if (e.target === null) return;
+ const checkbox = (e.target as HTMLElement).querySelector('input');
+ if (checkbox) {
+ checkbox.click();
+ }
+ }
+
+ isFilterDefined(filter: string | null | undefined) {
+ return filter !== null && filter !== undefined;
+ }
+
+ areFiltersEqual(
+ filter1: string | null | undefined,
+ filter2: string | null | undefined
+ ) {
+ // null and undefined are equal
+ if (!this.isFilterDefined(filter1) && !this.isFilterDefined(filter2)) {
+ return true;
+ }
+ return filter1 === filter2;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-watched-projects-editor': GrWatchedProjectsEditor;
+ }
+}
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
index d42f579..2fd8900 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
@@ -121,24 +121,23 @@
test('_canAddProject', () => {
assert.isFalse(element._canAddProject(null, null, null));
- assert.isFalse(element._canAddProject({}, null, null));
// Can add a project that is not in the list.
- assert.isTrue(element._canAddProject({id: 'project d'}, null, null));
- assert.isTrue(element._canAddProject({id: 'project d'}, null, 'filter 3'));
+ assert.isTrue(element._canAddProject('project d', null, null));
+ assert.isTrue(element._canAddProject('project d', null, 'filter 3'));
// Cannot add a project that is in the list with no filter.
- assert.isFalse(element._canAddProject({id: 'project a'}, null, null));
+ assert.isFalse(element._canAddProject('project a', null, null));
// Can add a project that is in the list if the filter differs.
- assert.isTrue(element._canAddProject({id: 'project a'}, null, 'filter 4'));
+ assert.isTrue(element._canAddProject('project a', null, 'filter 4'));
// Cannot add a project that is in the list with the same filter.
- assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 1'));
- assert.isFalse(element._canAddProject({id: 'project b'}, null, 'filter 2'));
+ assert.isFalse(element._canAddProject('project b', null, 'filter 1'));
+ assert.isFalse(element._canAddProject('project b', null, 'filter 2'));
// Can add a project that is in the list using a new filter.
- assert.isTrue(element._canAddProject({id: 'project b'}, null, 'filter 3'));
+ assert.isTrue(element._canAddProject('project b', null, 'filter 3'));
// Can add a project that is not added by the auto complete
assert.isTrue(element._canAddProject(null, 'test', null));
@@ -159,7 +158,7 @@
});
test('_handleAddProject', () => {
- element.$.newProject.value = {id: 'project d'};
+ element.$.newProject.value = 'project d';
element.$.newProject.setText('project d');
element.$.newFilter.bindValue = '';
@@ -172,7 +171,7 @@
});
test('_handleAddProject with invalid inputs', () => {
- element.$.newProject.value = {id: 'project b'};
+ element.$.newProject.value = 'project b';
element.$.newProject.setText('project b');
element.$.newFilter.bindValue = 'filter 1';
element.$.newFilter.value = 'filter 1';
@@ -188,7 +187,7 @@
.querySelector('table tbody tr:nth-child(2) gr-button');
MockInteractions.tap(button);
- flushAsynchronousOperations();
+ flush();
const rows = element.shadowRoot
.querySelector('table tbody').querySelectorAll('tr');
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
deleted file mode 100644
index 7e40f48..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ /dev/null
@@ -1,115 +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 '../gr-account-link/gr-account-link.js';
-import '../gr-button/gr-button.js';
-import '../gr-icons/gr-icons.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.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-account-chip_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrAccountChip extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-account-chip'; }
- /**
- * Fired to indicate a key was pressed while this chip was focused.
- *
- * @event account-chip-keydown
- */
-
- /**
- * Fired to indicate this chip should be removed, i.e. when the x button is
- * clicked or when the remove function is called.
- *
- * @event remove
- */
-
- static get properties() {
- return {
- account: Object,
- /**
- * Optional ChangeInfo object, typically comes from the change page or
- * from a row in a list of search results. This is needed for some change
- * related features like adding the user as a reviewer.
- */
- change: Object,
- voteableText: String,
- disabled: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- removable: {
- type: Boolean,
- value: false,
- },
- /**
- * Should attention set related features be shown in the component? Note
- * that the information whether the user is in the attention set or not is
- * part of the ChangeInfo object in the change property.
- */
- highlightAttention: {
- type: Boolean,
- value: false,
- },
- showAvatar: {
- type: Boolean,
- reflectToAttribute: true,
- },
- transparentBackground: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- /** @override */
- ready() {
- super.ready();
- this._getHasAvatars().then(hasAvatars => {
- this.showAvatar = hasAvatars;
- });
- }
-
- _getBackgroundClass(transparent) {
- return transparent ? 'transparentBackground' : '';
- }
-
- _handleRemoveTap(e) {
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('remove', {
- detail: {account: this.account},
- composed: true, bubbles: true,
- }));
- }
-
- _getHasAvatars() {
- return this.$.restAPI.getConfig()
- .then(cfg => Promise.resolve(!!(
- cfg && cfg.plugin && cfg.plugin.has_avatars
- )));
- }
-}
-
-customElements.define(GrAccountChip.is, GrAccountChip);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
new file mode 100644
index 0000000..9623442
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.ts
@@ -0,0 +1,133 @@
+/**
+ * @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 '../gr-account-link/gr-account-link';
+import '../gr-button/gr-button';
+import '../gr-icons/gr-icons';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+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-account-chip_html';
+import {customElement, property} from '@polymer/decorators';
+import {AccountInfo, ChangeInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export interface GrAccountChip {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-account-chip')
+export class GrAccountChip extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired to indicate a key was pressed while this chip was focused.
+ *
+ * @event account-chip-keydown
+ */
+
+ /**
+ * Fired to indicate this chip should be removed, i.e. when the x button is
+ * clicked or when the remove function is called.
+ *
+ * @event remove
+ */
+
+ @property({type: Object})
+ account?: AccountInfo;
+
+ /**
+ * Optional ChangeInfo object, typically comes from the change page or
+ * from a row in a list of search results. This is needed for some change
+ * related features like adding the user as a reviewer.
+ */
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ /**
+ * Should this user be considered to be in the attention set, regardless
+ * of the current state of the change object?
+ */
+ @property({type: Boolean})
+ forceAttention = false;
+
+ @property({type: String})
+ voteableText?: string;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ disabled = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ removable = false;
+
+ /**
+ * Should attention set related features be shown in the component? Note
+ * that the information whether the user is in the attention set or not is
+ * part of the ChangeInfo object in the change property.
+ */
+ @property({type: Boolean})
+ highlightAttention = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ showAvatar?: boolean;
+
+ @property({type: Boolean})
+ transparentBackground = false;
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._getHasAvatars().then(hasAvatars => {
+ this.showAvatar = hasAvatars;
+ });
+ }
+
+ _getBackgroundClass(transparent: boolean) {
+ return transparent ? 'transparentBackground' : '';
+ }
+
+ _handleRemoveTap(e: MouseEvent) {
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('remove', {
+ detail: {account: this.account},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _getHasAvatars() {
+ return this.$.restAPI
+ .getConfig()
+ .then(cfg =>
+ Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars))
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-account-chip': GrAccountChip;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
index f77b8cc..991104f 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
@@ -24,13 +24,21 @@
}
.container {
align-items: center;
- background: var(--chip-background-color);
- border-radius: 0.75em;
+ background-color: var(--background-color-primary);
+ /** round */
+ border-radius: var(--account-chip-border-radius, 20px);
+ border: 1px solid var(--border-color);
display: inline-flex;
- padding: 0 var(--spacing-m);
+ padding: 0 1px;
+
+ --account-label-padding-horizontal: 6px;
+ --gr-account-label-text-style: {
+ color: var(--deemphasized-text-color);
+ }
}
:host([show-avatar]) .container {
- padding-left: 0;
+ }
+ :host([removable]) .container {
}
gr-button.remove {
--gr-remove-button-style: {
@@ -39,8 +47,9 @@
font-weight: var(--font-weight-normal);
height: 0.6em;
line-height: 10px;
- margin-left: var(--spacing-xs);
- padding: 0;
+ /* This cancels most of the --account-label-padding-horizontal. */
+ margin-left: -4px;
+ padding: 0 2px 0 0;
text-decoration: none;
}
}
@@ -69,7 +78,6 @@
.transparentBackground,
gr-button.transparentBackground {
background-color: transparent;
- padding: 0;
}
:host([disabled]) {
opacity: 0.6;
@@ -84,6 +92,7 @@
<gr-account-link
account="[[account]]"
change="[[change]]"
+ force-attention="[[forceAttention]]"
highlight-attention="[[highlightAttention]]"
voteable-text="[[voteableText]]"
>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
deleted file mode 100644
index 807aa81..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.js
+++ /dev/null
@@ -1,118 +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 '../../../styles/shared-styles.js';
-import '../gr-autocomplete/gr-autocomplete.js';
-import '../gr-rest-api-interface/gr-rest-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-account-entry_html.js';
-
-/**
- * gr-account-entry is an element for entering account
- * and/or group with autocomplete support.
- *
- * @extends PolymerElement
- */
-class GrAccountEntry extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-account-entry'; }
- /**
- * Fired when an account is entered.
- *
- * @event add
- */
-
- /**
- * When allowAnyInput is true, account-text-changed is fired when input text
- * changed. This is needed so that the reply dialog's save button can be
- * enabled for arbitrary cc's, which don't need a 'commit'.
- *
- * @event account-text-changed
- */
-
- static get properties() {
- return {
- allowAnyInput: Boolean,
- borderless: Boolean,
- placeholder: String,
-
- // suggestFrom = 0 to enable default suggestions.
- suggestFrom: {
- type: Number,
- value: 0,
- },
-
- /** @type {!function(string): !Promise<Array<{name, value}>>} */
- querySuggestions: {
- type: Function,
- notify: true,
- value() {
- return input => Promise.resolve([]);
- },
- },
-
- _config: Object,
- /** The value of the autocomplete entry. */
- _inputText: {
- type: String,
- observer: '_inputTextChanged',
- },
-
- };
- }
-
- get focusStart() {
- return this.$.input.focusStart;
- }
-
- focus() {
- this.$.input.focus();
- }
-
- clear() {
- this.$.input.clear();
- }
-
- setText(text) {
- this.$.input.setText(text);
- }
-
- getText() {
- return this.$.input.text;
- }
-
- _handleInputCommit(e) {
- this.dispatchEvent(new CustomEvent('add', {
- detail: {value: e.detail.value},
- composed: true, bubbles: true,
- }));
- this.$.input.focus();
- }
-
- _inputTextChanged(text) {
- if (text.length && this.allowAnyInput) {
- this.dispatchEvent(new CustomEvent(
- 'account-text-changed', {bubbles: true, composed: true}));
- }
- }
-}
-
-customElements.define(GrAccountEntry.is, GrAccountEntry);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
new file mode 100644
index 0000000..925480f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry.ts
@@ -0,0 +1,120 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../gr-autocomplete/gr-autocomplete';
+import '../gr-rest-api-interface/gr-rest-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-account-entry_html';
+import {customElement, property} from '@polymer/decorators';
+import {GrAutocomplete} from '../gr-autocomplete/gr-autocomplete';
+
+export interface GrAccountEntry {
+ $: {
+ input: GrAutocomplete;
+ };
+}
+/**
+ * gr-account-entry is an element for entering account
+ * and/or group with autocomplete support.
+ */
+@customElement('gr-account-entry')
+export class GrAccountEntry extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when an account is entered.
+ *
+ * @event add
+ */
+
+ /**
+ * When allowAnyInput is true, account-text-changed is fired when input text
+ * changed. This is needed so that the reply dialog's save button can be
+ * enabled for arbitrary cc's, which don't need a 'commit'.
+ *
+ * @event account-text-changed
+ */
+
+ @property({type: Boolean})
+ allowAnyInput?: boolean;
+
+ @property({type: Boolean})
+ borderless?: boolean;
+
+ @property({type: String})
+ placeholder?: string;
+
+ @property({type: Number})
+ suggestFrom = 0;
+
+ @property({type: Object, notify: true})
+ querySuggestions = () => Promise.resolve([]);
+
+ @property({type: String, observer: '_inputTextChanged'})
+ _inputText?: string;
+
+ get focusStart() {
+ return this.$.input.focusStart;
+ }
+
+ focus() {
+ this.$.input.focus();
+ }
+
+ clear() {
+ this.$.input.clear();
+ }
+
+ setText(text: string) {
+ this.$.input.setText(text);
+ }
+
+ getText() {
+ return this.$.input.text;
+ }
+
+ _handleInputCommit(e: CustomEvent) {
+ this.dispatchEvent(
+ new CustomEvent('add', {
+ detail: {value: e.detail.value},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this.$.input.focus();
+ }
+
+ _inputTextChanged(text: string) {
+ if (text.length && this.allowAnyInput) {
+ this.dispatchEvent(
+ new CustomEvent('account-text-changed', {bubbles: true, composed: true})
+ );
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-account-entry': GrAccountEntry;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
index 396145b..4430c65 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
@@ -38,10 +38,8 @@
some_other_property: 'other value',
};
- setup(done => {
+ setup(() => {
element = basicFixture.instantiate();
-
- return flush(done);
});
suite('stubbed values for querySuggestions', () => {
@@ -81,7 +79,7 @@
// Spy on query, as that is called when _updateSuggestions proceeds.
const suggestSpy = sinon.spy(element.$.input, 'query');
element.setText('test text');
- flushAsynchronousOperations();
+ flush();
assert.equal(element.$.input.$.input.value, 'test text');
assert.isFalse(suggestSpy.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
deleted file mode 100644
index efb7c0e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ /dev/null
@@ -1,113 +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 '@polymer/iron-icon/iron-icon.js';
-import '../../../styles/shared-styles.js';
-import '../gr-avatar/gr-avatar.js';
-import '../gr-hovercard-account/gr-hovercard-account.js';
-import '../gr-rest-api-interface/gr-rest-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-account-label_html.js';
-import {getDisplayName} from '../../../utils/display-name-util.js';
-
-/**
- * @extends PolymerElement
- */
-class GrAccountLabel extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-account-label'; }
-
- static get properties() {
- return {
- /**
- * @type {{ name: string, status: string }}
- */
- account: Object,
- /**
- * Optional ChangeInfo object, typically comes from the change page or
- * from a row in a list of search results. This is needed for some change
- * related features like adding the user as a reviewer.
- */
- change: Object,
- voteableText: String,
- /**
- * Should attention set related features be shown in the component? Note
- * that the information whether the user is in the attention set or not is
- * part of the ChangeInfo object in the change property.
- */
- highlightAttention: {
- type: Boolean,
- value: false,
- },
- blurred: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- hideHovercard: {
- type: Boolean,
- value: false,
- },
- hideAvatar: {
- type: Boolean,
- value: false,
- },
- hideStatus: {
- type: Boolean,
- value: false,
- },
- /**
- * This is a ServerInfo response object.
- */
- _config: {
- type: Object,
- value: null,
- },
- };
- }
-
- /** @override */
- ready() {
- super.ready();
- this.$.restAPI.getConfig().then(config => { this._config = config; });
- }
-
- get isAttentionSetEnabled() {
- return !!this._config && !!this._config.change
- && !!this._config.change.enable_attention_set
- && !!this.highlightAttention && !!this.change && !!this.account;
- }
-
- get hasAttention() {
- if (!this.isAttentionSetEnabled || !this.change.attention_set) return false;
- return this.change.attention_set.hasOwnProperty(this.account._account_id);
- }
-
- _computeShowAttentionIcon(config, highlightAttention, account, change) {
- return this.isAttentionSetEnabled && this.hasAttention;
- }
-
- _computeName(account, config) {
- return getDisplayName(config, account);
- }
-}
-
-customElements.define(GrAccountLabel.is, GrAccountLabel);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
new file mode 100644
index 0000000..be23cb3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.ts
@@ -0,0 +1,285 @@
+/**
+ * @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 '@polymer/iron-icon/iron-icon';
+import '../../../styles/shared-styles';
+import '../gr-avatar/gr-avatar';
+import '../gr-hovercard-account/gr-hovercard-account';
+import '../gr-rest-api-interface/gr-rest-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-account-label_html';
+import {appContext} from '../../../services/app-context';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {isServiceUser} from '../../../utils/account-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {ChangeInfo, AccountInfo, ServerInfo} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+export interface GrAccountLabel {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-account-label')
+export class GrAccountLabel extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ account!: AccountInfo;
+
+ @property({type: Object})
+ _selfAccount?: AccountInfo;
+
+ /**
+ * Optional ChangeInfo object, typically comes from the change page or
+ * from a row in a list of search results. This is needed for some change
+ * related features like adding the user as a reviewer.
+ */
+ @property({type: Object})
+ change!: ChangeInfo;
+
+ @property({type: String})
+ voteableText?: string;
+
+ /**
+ * Should this user be considered to be in the attention set, regardless
+ * of the current state of the change object?
+ */
+ @property({type: Boolean})
+ forceAttention = false;
+
+ /**
+ * Only show the first name in the account label.
+ */
+ @property({type: Boolean})
+ firstName = false;
+
+ /**
+ * Should attention set related features be shown in the component? Note
+ * that the information whether the user is in the attention set or not is
+ * part of the ChangeInfo object in the change property.
+ */
+ @property({type: Boolean})
+ highlightAttention = false;
+
+ @property({type: Boolean})
+ hideHovercard = false;
+
+ @property({type: Boolean})
+ hideAvatar = false;
+
+ @property({
+ type: Boolean,
+ reflectToAttribute: true,
+ computed:
+ '_computeCancelLeftPadding(hideAvatar, _config, ' +
+ 'highlightAttention, account, change, forceAttention)',
+ })
+ cancelLeftPadding = false;
+
+ @property({type: Boolean})
+ hideStatus = false;
+
+ @property({type: Object})
+ _config?: ServerInfo;
+
+ reporting: ReportingService;
+
+ constructor() {
+ super();
+ this.reporting = appContext.reportingService;
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this.$.restAPI.getConfig().then(config => {
+ this._config = config;
+ });
+ this.$.restAPI.getAccount().then(account => {
+ this._selfAccount = account;
+ });
+ this.addEventListener('attention-set-updated', () => {
+ // For re-evaluation of everything that depends on 'change'.
+ this.change = {...this.change};
+ });
+ }
+
+ _isAttentionSetEnabled(
+ config: ServerInfo | undefined,
+ highlight: boolean,
+ account: AccountInfo,
+ change: ChangeInfo
+ ) {
+ return (
+ !!config &&
+ !!config.change &&
+ !!config.change.enable_attention_set &&
+ !!highlight &&
+ !!change &&
+ !!account &&
+ !isServiceUser(account)
+ );
+ }
+
+ _computeCancelLeftPadding(
+ hideAvatar: boolean,
+ config: ServerInfo | undefined,
+ highlight: boolean,
+ account: AccountInfo,
+ change: ChangeInfo,
+ force: boolean
+ ) {
+ return (
+ !hideAvatar &&
+ !this._hasAttention(config, highlight, account, change, force)
+ );
+ }
+
+ _hasAttention(
+ config: ServerInfo | undefined,
+ highlight: boolean,
+ account: AccountInfo,
+ change: ChangeInfo,
+ force: boolean
+ ) {
+ return (
+ force || this._hasUnforcedAttention(config, highlight, account, change)
+ );
+ }
+
+ _hasUnforcedAttention(
+ config: ServerInfo | undefined,
+ highlight: boolean,
+ account: AccountInfo,
+ change: ChangeInfo
+ ) {
+ return (
+ this._isAttentionSetEnabled(config, highlight, account, change) &&
+ change.attention_set &&
+ !!account._account_id &&
+ hasOwnProperty(change.attention_set, account._account_id)
+ );
+ }
+
+ _computeHasAttentionClass(
+ config: ServerInfo | undefined,
+ highlight: boolean,
+ account: AccountInfo,
+ change: ChangeInfo,
+ force: boolean
+ ) {
+ return this._hasAttention(config, highlight, account, change, force)
+ ? 'hasAttention'
+ : '';
+ }
+
+ _computeName(
+ account?: AccountInfo,
+ config?: ServerInfo,
+ firstName?: boolean
+ ) {
+ return getDisplayName(config, account, firstName);
+ }
+
+ _handleRemoveAttentionClick(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!this.account._account_id) return;
+
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: 'Saving attention set update ...',
+ dismissOnNavigation: true,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+
+ // We are deliberately updating the UI before making the API call. It is a
+ // risk that we are taking to achieve a better UX for 99.9% of the cases.
+ const selfName = getDisplayName(this._config, this._selfAccount);
+ const reason = `Removed by ${selfName} by clicking the attention icon`;
+ if (this.change.attention_set)
+ delete this.change.attention_set[this.account._account_id];
+ // For re-evaluation of everything that depends on 'change'.
+ this.change = {...this.change};
+
+ this.reporting.reportInteraction(
+ 'attention-icon-remove',
+ this._reportingDetails()
+ );
+ this.$.restAPI
+ .removeFromAttentionSet(
+ this.change._number,
+ this.account._account_id,
+ reason
+ )
+ .then(() => {
+ this.dispatchEvent(
+ new CustomEvent('hide-alert', {bubbles: true, composed: true})
+ );
+ });
+ }
+
+ _reportingDetails() {
+ const targetId = this.account._account_id;
+ const ownerId =
+ (this.change && this.change.owner && this.change.owner._account_id) || -1;
+ const selfId = this._selfAccount?._account_id || -1;
+ const reviewers =
+ this.change && this.change.reviewers && this.change.reviewers.REVIEWER
+ ? [...this.change.reviewers.REVIEWER]
+ : [];
+ const reviewerIds = reviewers
+ .map(r => r._account_id)
+ .filter(rId => rId !== ownerId);
+ return {
+ actionByOwner: selfId === ownerId,
+ actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
+ targetIsOwner: targetId === ownerId,
+ targetIsReviewer: reviewerIds.includes(targetId),
+ targetIsSelf: targetId === selfId,
+ };
+ }
+
+ _computeAttentionIconTitle(
+ config: ServerInfo | undefined,
+ highlight: boolean,
+ account: AccountInfo,
+ change: ChangeInfo
+ ) {
+ return this._hasUnforcedAttention(config, highlight, account, change)
+ ? 'Click to remove the user from the attention set'
+ : 'Disabled. Use "Modify" to make changes.';
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-account-label': GrAccountLabel;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
index c26e304..1d8b13e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
@@ -19,30 +19,48 @@
export const htmlTemplate = html`
<style include="shared-styles">
:host {
- display: inline;
+ display: inline-block;
+ vertical-align: top;
position: relative;
border-radius: var(--label-border-radius);
+ max-width: var(--account-max-length, 200px);
+ box-sizing: border-box;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ padding: 0 var(--account-label-padding-horizontal, 0);
+ }
+ /* If the first element is the avatar, then we cancel the left padding, so
+ we can fit nicely into the gr-account-chip rounding.
+ The obvious alternative of 'chip has padding' and 'avatar gets negative
+ margin' does not work, because we need 'overflow:hidden' on the label. */
+ :host([cancel-left-padding]) {
+ padding-left: 0;
}
:host::after {
content: var(--account-label-suffix);
}
- :host(:not([blurred])) .overlay {
- display: none;
- }
- .overlay {
- position: absolute;
- pointer-events: none;
- height: var(--line-height-normal);
- right: 0;
- left: 0;
+ :host([deselected]) {
background-color: var(--background-color-primary);
- opacity: 0.5;
- border-radius: var(--label-border-radius);
+ border: 1px solid var(--comment-separator-color);
+ border-radius: 8px;
+ color: var(--deemphasized-text-color);
+ }
+ :host([selected]) {
+ background-color: var(--chip-selected-background-color);
+ border: 1px solid var(--chip-selected-background-color);
+ border-radius: 8px;
+ color: var(--chip-selected-text-color);
+ }
+ :host([selected]) iron-icon.attention {
+ color: var(--chip-selected-text-color);
}
gr-avatar {
- height: var(--line-height-normal);
- width: var(--line-height-normal);
+ height: calc(var(--line-height-normal) - 2px);
+ width: calc(var(--line-height-normal) - 2px);
vertical-align: top;
+ position: relative;
+ top: 1px;
}
.text {
@apply --gr-account-label-text-style;
@@ -50,12 +68,15 @@
.text:hover {
@apply --gr-account-label-text-hover-style;
}
- iron-icon.attention {
- width: 14px;
- height: 14px;
+ #attentionButton {
+ /* This negates the 4px horizontal padding, which we appreciate as a
+ larger click target, but which we don't want to consume space. :-) */
+ margin: 0 -4px 0 -4px;
vertical-align: top;
- position: relative;
- top: 3px;
+ }
+ iron-icon.attention {
+ width: 12px;
+ height: 12px;
}
iron-icon.status {
width: 14px;
@@ -64,11 +85,14 @@
position: relative;
top: 2px;
}
+ .hasAttention .name {
+ font-weight: var(--font-weight-bold);
+ }
</style>
- <div class="overlay"></div>
<span>
<template is="dom-if" if="[[!hideHovercard]]">
<gr-hovercard-account
+ for="hovercardTarget"
account="[[account]]"
change="[[change]]"
highlight-attention="[[highlightAttention]]"
@@ -78,15 +102,29 @@
</template>
<template
is="dom-if"
- if="[[_computeShowAttentionIcon(_config, highlightAttention, account, change)]]"
+ if="[[_hasAttention(_config, highlightAttention, account, change, forceAttention)]]"
>
- <iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
+ <gr-button
+ id="attentionButton"
+ link=""
+ aria-label="Remove user from attention set"
+ on-click="_handleRemoveAttentionClick"
+ disabled="[[!_hasUnforcedAttention(_config, highlightAttention, account, change)]]"
+ has-tooltip="[[_hasUnforcedAttention(_config, highlightAttention, account, change)]]"
+ title="[[_computeAttentionIconTitle(_config, highlightAttention, account, change)]]"
+ ><iron-icon class="attention" icon="gr-icons:attention"></iron-icon>
+ </gr-button>
</template>
+ </span>
+ <span
+ id="hovercardTarget"
+ class$="[[_computeHasAttentionClass(_config, highlightAttention, account, change, forceAttention)]]"
+ >
<template is="dom-if" if="[[!hideAvatar]]">
<gr-avatar account="[[account]]" image-size="32"></gr-avatar>
</template>
<span class="text">
- <span class="name">[[_computeName(account, _config)]]</span>
+ <span class="name">[[_computeName(account, _config, firstName)]]</span>
<template is="dom-if" if="[[!hideStatus]]">
<template is="dom-if" if="[[account.status]]">
<iron-icon class="status" icon="gr-icons:calendar"></iron-icon>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
index 94274a7..2e54db2 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
@@ -23,6 +23,10 @@
suite('gr-account-label tests', () => {
let element;
+ function createAccount(name, id) {
+ return {name, _account_id: id};
+ }
+
setup(() => {
stub('gr-rest-api-interface', {
getConfig() { return Promise.resolve({}); },
@@ -76,5 +80,33 @@
'TestAnon');
});
});
+
+ suite('attention set', () => {
+ setup(() => {
+ element.highlightAttention = true;
+ element._config = {
+ change: {enable_attention_set: true},
+ user: {anonymous_coward_name: 'Anonymous Coward'},
+ };
+ element._selfAccount = createAccount('kermit', 31);
+ element.account = createAccount('ernie', 42);
+ element.change = {attention_set: {42: {}}};
+ flush();
+ });
+
+ test('show attention button', () => {
+ assert.ok(element.shadowRoot.querySelector('#attentionButton'));
+ });
+
+ test('tap attention button', () => {
+ const apiStub = sinon.stub(element.$.restAPI, 'removeFromAttentionSet')
+ .callsFake(() => Promise.resolve());
+ const button = element.shadowRoot.querySelector('#attentionButton');
+ assert.ok(button);
+ MockInteractions.tap(button);
+ assert.isTrue(apiStub.calledOnce);
+ assert.equal(apiStub.lastCall.args[1], 42);
+ });
+ });
});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
deleted file mode 100644
index eff1953..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ /dev/null
@@ -1,74 +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 '../gr-account-label/gr-account-label.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-account-link_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/**
- * @extends PolymerElement
- */
-class GrAccountLink extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-account-link'; }
-
- static get properties() {
- return {
- voteableText: String,
- account: Object,
- /**
- * Optional ChangeInfo object, typically comes from the change page or
- * from a row in a list of search results. This is needed for some change
- * related features like adding the user as a reviewer.
- */
- change: Object,
- /**
- * Should attention set related features be shown in the component? Note
- * that the information whether the user is in the attention set or not is
- * part of the ChangeInfo object in the change property.
- */
- highlightAttention: {
- type: Boolean,
- value: false,
- },
- hideAvatar: {
- type: Boolean,
- value: false,
- },
- hideStatus: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- _computeOwnerLink(account) {
- if (!account) { return; }
- return GerritNav.getUrlForOwner(
- account.email || account.username || account.name ||
- account._account_id);
- }
-}
-
-customElements.define(GrAccountLink.is, GrAccountLink);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
new file mode 100644
index 0000000..be54f4e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
@@ -0,0 +1,94 @@
+/**
+ * @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 '../gr-account-label/gr-account-label';
+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-account-link_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {AccountInfo, ChangeInfo} from '../../../types/common';
+
+@customElement('gr-account-link')
+class GrAccountLink extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ voteableText?: string;
+
+ @property({type: Object})
+ account?: AccountInfo;
+
+ /**
+ * Optional ChangeInfo object, typically comes from the change page or
+ * from a row in a list of search results. This is needed for some change
+ * related features like adding the user as a reviewer.
+ */
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ /**
+ * Should this user be considered to be in the attention set, regardless
+ * of the current state of the change object?
+ */
+ @property({type: Boolean})
+ forceAttention = false;
+
+ /**
+ * Should attention set related features be shown in the component? Note
+ * that the information whether the user is in the attention set or not is
+ * part of the ChangeInfo object in the change property.
+ */
+ @property({type: Boolean})
+ highlightAttention = false;
+
+ @property({type: Boolean})
+ hideAvatar = false;
+
+ @property({type: Boolean})
+ hideStatus = false;
+
+ /**
+ * Only show the first name in the account label.
+ */
+ @property({type: Boolean})
+ firstName = false;
+
+ _computeOwnerLink(account?: AccountInfo) {
+ if (!account) {
+ return;
+ }
+ return GerritNav.getUrlForOwner(
+ account.email ||
+ account.username ||
+ account.name ||
+ `${account._account_id}`
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-account-link': GrAccountLink;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts
index dfcef4e..be4db01 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts
@@ -20,14 +20,7 @@
<style include="shared-styles">
:host {
display: inline-block;
- /* Setting this really high, so all the following rules don't change
- anything, only if --account-max-length is actually set to something
- smaller like 20ch. */
- max-width: var(--account-max-length, 500px);
- overflow: hidden;
- text-overflow: ellipsis;
vertical-align: top;
- white-space: nowrap;
}
a {
color: var(--primary-text-color);
@@ -44,9 +37,11 @@
<gr-account-label
account="[[account]]"
change="[[change]]"
+ force-attention="[[forceAttention]]"
highlight-attention="[[highlightAttention]]"
hide-avatar="[[hideAvatar]]"
hide-status="[[hideStatus]]"
+ first-name="[[firstName]]"
voteable-text="[[voteableText]]"
>
</gr-account-label>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
deleted file mode 100644
index 6f5f9e4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.js
+++ /dev/null
@@ -1,370 +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 '../gr-account-chip/gr-account-chip.js';
-import '../gr-account-entry/gr-account-entry.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-account-list_html.js';
-import {appContext} from '../../../services/app-context.js';
-
-const VALID_EMAIL_ALERT = 'Please input a valid email.';
-
-/**
- * @extends PolymerElement
- */
-class GrAccountList extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-account-list'; }
- /**
- * Fired when user inputs an invalid email address.
- *
- * @event show-alert
- */
-
- static get properties() {
- return {
- accounts: {
- type: Array,
- value() { return []; },
- notify: true,
- },
- change: Object,
- filter: Function,
- placeholder: String,
- disabled: {
- type: Function,
- value: false,
- },
-
- /**
- * Returns suggestions and convert them to list item
- *
- * @type {Gerrit.GrSuggestionsProvider}
- */
- suggestionsProvider: {
- type: Object,
- },
-
- /**
- * Needed for template checking since value is initially set to null.
- *
- * @type {?Object}
- */
- pendingConfirmation: {
- type: Object,
- value: null,
- notify: true,
- },
- readonly: {
- type: Boolean,
- value: false,
- },
- /**
- * When true, allows for non-suggested inputs to be added.
- */
- allowAnyInput: {
- type: Boolean,
- value: false,
- },
-
- /**
- * Array of values (groups/accounts) that are removable. When this prop is
- * undefined, all values are removable.
- */
- removableValues: Array,
- maxCount: {
- type: Number,
- value: 0,
- },
-
- /**
- * Returns suggestion items
- *
- * @type {!function(string): Promise<Array<Gerrit.GrSuggestionItem>>}
- */
- _querySuggestions: {
- type: Function,
- value() {
- return this._getSuggestions.bind(this);
- },
- },
-
- /**
- * Set to true to disable suggestions on empty input.
- */
- skipSuggestOnEmpty: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('remove',
- e => this._handleRemove(e));
- }
-
- get accountChips() {
- return Array.from(
- dom(this.root).querySelectorAll('gr-account-chip'));
- }
-
- get focusStart() {
- return this.$.entry.focusStart;
- }
-
- _getSuggestions(input) {
- if (this.skipSuggestOnEmpty && !input) {
- return Promise.resolve([]);
- }
- const provider = this.suggestionsProvider;
- if (!provider) {
- return Promise.resolve([]);
- }
- return provider.getSuggestions(input).then(suggestions => {
- if (!suggestions) { return []; }
- if (this.filter) {
- suggestions = suggestions.filter(this.filter);
- }
- return suggestions.map(suggestion =>
- provider.makeSuggestionItem(suggestion));
- });
- }
-
- _handleAdd(e) {
- this.addAccountItem(e.detail.value);
- }
-
- addAccountItem(item) {
- // Append new account or group to the accounts property. We add our own
- // internal properties to the account/group here, so we clone the object
- // to avoid cluttering up the shared change object.
- let itemTypeAdded = 'unknown';
- if (item.account) {
- const account =
- Object.assign({}, item.account, {_pendingAdd: true});
- this.push('accounts', account);
- itemTypeAdded = 'account';
- } else if (item.group) {
- if (item.confirm) {
- this.pendingConfirmation = item;
- return;
- }
- const group = Object.assign({}, item.group,
- {_pendingAdd: true, _group: true});
- this.push('accounts', group);
- itemTypeAdded = 'group';
- } else if (this.allowAnyInput) {
- if (!item.includes('@')) {
- // Repopulate the input with what the user tried to enter and have
- // a toast tell them why they can't enter it.
- this.$.entry.setText(item);
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: VALID_EMAIL_ALERT},
- bubbles: true,
- composed: true,
- }));
- return false;
- } else {
- const account = {email: item, _pendingAdd: true};
- this.push('accounts', account);
- itemTypeAdded = 'email';
- }
- }
-
- this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
- this.pendingConfirmation = null;
- return true;
- }
-
- confirmGroup(group) {
- group = Object.assign(
- {}, group, {confirmed: true, _pendingAdd: true, _group: true});
- this.push('accounts', group);
- this.pendingConfirmation = null;
- }
-
- _computeChipClass(account) {
- const classes = [];
- if (account._group) {
- classes.push('group');
- }
- if (account._pendingAdd) {
- classes.push('pendingAdd');
- }
- return classes.join(' ');
- }
-
- _accountMatches(a, b) {
- if (a && b) {
- if (a._account_id) {
- return a._account_id === b._account_id;
- }
- if (a.email) {
- return a.email === b.email;
- }
- }
- return a === b;
- }
-
- _computeRemovable(account, readonly) {
- if (readonly) { return false; }
- if (this.removableValues) {
- for (let i = 0; i < this.removableValues.length; i++) {
- if (this._accountMatches(this.removableValues[i], account)) {
- return true;
- }
- }
- return !!account._pendingAdd;
- }
- return true;
- }
-
- _handleRemove(e) {
- const toRemove = e.detail.account;
- this.removeAccount(toRemove);
- this.$.entry.focus();
- }
-
- removeAccount(toRemove) {
- if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
- return;
- }
- for (let i = 0; i < this.accounts.length; i++) {
- let matches;
- const account = this.accounts[i];
- if (toRemove._group) {
- matches = toRemove.id === account.id;
- } else {
- matches = this._accountMatches(toRemove, account);
- }
- if (matches) {
- this.splice('accounts', i, 1);
- this.reporting.reportInteraction(`Remove from ${this.id}`);
- return;
- }
- }
- console.warn('received remove event for missing account', toRemove);
- }
-
- _getNativeInput(paperInput) {
- // In Polymer 2 inputElement isn't nativeInput anymore
- return paperInput.$.nativeInput || paperInput.inputElement;
- }
-
- _handleInputKeydown(e) {
- const input = this._getNativeInput(e.detail.input);
- if (input.selectionStart !== input.selectionEnd ||
- input.selectionStart !== 0) {
- return;
- }
- switch (e.detail.keyCode) {
- case 8: // Backspace
- this.removeAccount(this.accounts[this.accounts.length - 1]);
- break;
- case 37: // Left arrow
- if (this.accountChips[this.accountChips.length - 1]) {
- this.accountChips[this.accountChips.length - 1].focus();
- }
- break;
- }
- }
-
- _handleChipKeydown(e) {
- const chip = e.target;
- const chips = this.accountChips;
- const index = chips.indexOf(chip);
- switch (e.keyCode) {
- case 8: // Backspace
- case 13: // Enter
- case 32: // Spacebar
- case 46: // Delete
- this.removeAccount(chip.account);
- // Splice from this array to avoid inconsistent ordering of
- // event handling.
- chips.splice(index, 1);
- if (index < chips.length) {
- chips[index].focus();
- } else if (index > 0) {
- chips[index - 1].focus();
- } else {
- this.$.entry.focus();
- }
- break;
- case 37: // Left arrow
- if (index > 0) {
- chip.blur();
- chips[index - 1].focus();
- }
- break;
- case 39: // Right arrow
- chip.blur();
- if (index < chips.length - 1) {
- chips[index + 1].focus();
- } else {
- this.$.entry.focus();
- }
- break;
- }
- }
-
- /**
- * Submit the text of the entry as a reviewer value, if it exists. If it is
- * a successful submit of the text, clear the entry value.
- *
- * @return {boolean} If there is text in the entry, return true if the
- * submission was successful and false if not. If there is no text,
- * return true.
- */
- submitEntryText() {
- const text = this.$.entry.getText();
- if (!text.length) { return true; }
- const wasSubmitted = this.addAccountItem(text);
- if (wasSubmitted) { this.$.entry.clear(); }
- return wasSubmitted;
- }
-
- additions() {
- return this.accounts
- .filter(account => account._pendingAdd)
- .map(account => {
- if (account._group) {
- return {group: account};
- } else {
- return {account};
- }
- });
- }
-
- _computeEntryHidden(maxCount, accountsRecord, readonly) {
- return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
- }
-}
-
-customElements.define(GrAccountList.is, GrAccountList);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
new file mode 100644
index 0000000..c5e71fc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list.ts
@@ -0,0 +1,469 @@
+/**
+ * @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 '../gr-account-chip/gr-account-chip';
+import '../gr-account-entry/gr-account-entry';
+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-account-list_html';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {
+ ChangeInfo,
+ Suggestion,
+ AccountInfo,
+ GroupInfo,
+} from '../../../types/common';
+import {
+ GrReviewerSuggestionsProvider,
+ SuggestionItem,
+} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {GrAccountEntry} from '../gr-account-entry/gr-account-entry';
+import {GrAccountChip} from '../gr-account-chip/gr-account-chip';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {PaperInputElementExt} from '../../../types/types';
+
+const VALID_EMAIL_ALERT = 'Please input a valid email.';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-account-list': GrAccountList;
+ }
+}
+
+export interface GrAccountList {
+ $: {
+ entry: GrAccountEntry;
+ };
+}
+
+/**
+ * For item added with account info
+ */
+export interface AccountObjectInput {
+ account: AccountInfo;
+}
+
+/**
+ * For item added with group info
+ */
+export interface GroupObjectInput {
+ group: GroupInfo;
+ confirm: boolean;
+}
+
+/** Supported input to be added */
+export type RawAccountInput = string | AccountObjectInput | GroupObjectInput;
+
+// type guards for AccountObjectInput and GroupObjectInput
+function isAccountObject(x: RawAccountInput): x is AccountObjectInput {
+ return !!(x as AccountObjectInput).account;
+}
+
+function isGroupObjectInput(x: RawAccountInput): x is GroupObjectInput {
+ return !!(x as GroupObjectInput).group;
+}
+
+// Internal input type with account info
+export interface AccountInfoInput extends AccountInfo {
+ _group?: boolean;
+ _account?: boolean;
+ _pendingAdd?: boolean;
+ confirmed?: boolean;
+}
+
+// Internal input type with group info
+export interface GroupInfoInput extends GroupInfo {
+ _group?: boolean;
+ _account?: boolean;
+ _pendingAdd?: boolean;
+ confirmed?: boolean;
+}
+
+function isAccountInfoInput(x: AccountInput): x is AccountInfoInput {
+ const input = x as AccountInfoInput;
+ return !!input._account || !!input._account_id || !!input.email;
+}
+
+function isGroupInfoInput(x: AccountInput): x is GroupInfoInput {
+ const input = x as GroupInfoInput;
+ return !!input._group || !!input.id;
+}
+
+type AccountInput = AccountInfoInput | GroupInfoInput;
+
+export interface AccountAddition {
+ account?: AccountInfoInput;
+ group?: GroupInfoInput;
+}
+
+@customElement('gr-account-list')
+export class GrAccountList extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when user inputs an invalid email address.
+ *
+ * @event show-alert
+ */
+
+ @property({type: Array, notify: true})
+ accounts: AccountInput[] = [];
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Object})
+ filter?: (input: Suggestion) => boolean;
+
+ @property({type: String})
+ placeholder = '';
+
+ @property({type: Boolean})
+ disabled = false;
+
+ /**
+ * Returns suggestions and convert them to list item
+ */
+ @property({type: Object})
+ suggestionsProvider?: GrReviewerSuggestionsProvider;
+
+ /**
+ * Needed for template checking since value is initially set to null.
+ */
+ @property({type: Object, notify: true})
+ pendingConfirmation: GroupObjectInput | null = null;
+
+ @property({type: Boolean})
+ readonly = false;
+
+ /**
+ * When true, allows for non-suggested inputs to be added.
+ */
+ @property({type: Boolean})
+ allowAnyInput = false;
+
+ /**
+ * Array of values (groups/accounts) that are removable. When this prop is
+ * undefined, all values are removable.
+ */
+ @property({type: Array})
+ removableValues?: AccountInput[];
+
+ @property({type: Number})
+ maxCount = 0;
+
+ /**
+ * Returns suggestion items
+ */
+ @property({type: Object})
+ _querySuggestions: (input: string) => Promise<SuggestionItem[]>;
+
+ /**
+ * Set to true to disable suggestions on empty input.
+ */
+ @property({type: Boolean})
+ skipSuggestOnEmpty = false;
+
+ reporting: ReportingService;
+
+ constructor() {
+ super();
+ this.reporting = appContext.reportingService;
+ this._querySuggestions = input => this._getSuggestions(input);
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('remove', e =>
+ this._handleRemove(e as CustomEvent<{account: AccountInput}>)
+ );
+ }
+
+ get accountChips() {
+ return Array.from(this.root?.querySelectorAll('gr-account-chip') || []);
+ }
+
+ get focusStart() {
+ return this.$.entry.focusStart;
+ }
+
+ _getSuggestions(input: string) {
+ if (this.skipSuggestOnEmpty && !input) {
+ return Promise.resolve([]);
+ }
+ const provider = this.suggestionsProvider;
+ if (!provider) {
+ return Promise.resolve([]);
+ }
+ return provider.getSuggestions(input).then(suggestions => {
+ if (!suggestions) {
+ return [];
+ }
+ if (this.filter) {
+ suggestions = suggestions.filter(this.filter);
+ }
+ return suggestions.map(suggestion =>
+ provider.makeSuggestionItem(suggestion)
+ );
+ });
+ }
+
+ _handleAdd(e: CustomEvent<{value: RawAccountInput}>) {
+ this.addAccountItem(e.detail.value);
+ }
+
+ addAccountItem(item: RawAccountInput) {
+ // Append new account or group to the accounts property. We add our own
+ // internal properties to the account/group here, so we clone the object
+ // to avoid cluttering up the shared change object.
+ let itemTypeAdded = 'unknown';
+ if (isAccountObject(item)) {
+ const account = {...item.account, _pendingAdd: true};
+ this.push('accounts', account);
+ itemTypeAdded = 'account';
+ } else if (isGroupObjectInput(item)) {
+ if (item.confirm) {
+ this.pendingConfirmation = item;
+ return;
+ }
+ const group = {...item.group, _pendingAdd: true, _group: true};
+ this.push('accounts', group);
+ itemTypeAdded = 'group';
+ } else if (this.allowAnyInput) {
+ if (!item.includes('@')) {
+ // Repopulate the input with what the user tried to enter and have
+ // a toast tell them why they can't enter it.
+ this.$.entry.setText(item);
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: VALID_EMAIL_ALERT},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ return false;
+ } else {
+ const account = {email: item, _pendingAdd: true};
+ this.push('accounts', account);
+ itemTypeAdded = 'email';
+ }
+ }
+
+ this.reporting.reportInteraction(`Add to ${this.id}`, {itemTypeAdded});
+ this.pendingConfirmation = null;
+ return true;
+ }
+
+ confirmGroup(group: GroupInfo) {
+ this.push('accounts', {
+ ...group,
+ confirmed: true,
+ _pendingAdd: true,
+ _group: true,
+ });
+ this.pendingConfirmation = null;
+ }
+
+ _computeChipClass(account: AccountInput) {
+ const classes = [];
+ if (account._group) {
+ classes.push('group');
+ }
+ if (account._pendingAdd) {
+ classes.push('pendingAdd');
+ }
+ return classes.join(' ');
+ }
+
+ _accountMatches(a: AccountInput, b: AccountInput) {
+ // TODO(TS): seems a & b always exists ?
+ if (a && b) {
+ // both conditions are checking against AccountInfo
+ // and only check a not b.. typeguard won't work very good without
+ // changing logic, so keep it as inline casting
+ if ((a as AccountInfoInput)._account_id) {
+ return (
+ (a as AccountInfoInput)._account_id ===
+ (b as AccountInfoInput)._account_id
+ );
+ }
+ if ((a as AccountInfoInput).email) {
+ return (a as AccountInfoInput).email === (b as AccountInfoInput).email;
+ }
+ }
+ return a === b;
+ }
+
+ _computeRemovable(account: AccountInput, readonly: boolean) {
+ if (readonly) {
+ return false;
+ }
+ if (this.removableValues) {
+ for (let i = 0; i < this.removableValues.length; i++) {
+ if (this._accountMatches(this.removableValues[i], account)) {
+ return true;
+ }
+ }
+ return !!account._pendingAdd;
+ }
+ return true;
+ }
+
+ _handleRemove(e: CustomEvent<{account: AccountInput}>) {
+ const toRemove = e.detail.account;
+ this.removeAccount(toRemove);
+ this.$.entry.focus();
+ }
+
+ removeAccount(toRemove?: AccountInput) {
+ if (!toRemove || !this._computeRemovable(toRemove, this.readonly)) {
+ return;
+ }
+ for (let i = 0; i < this.accounts.length; i++) {
+ let matches;
+ const account = this.accounts[i];
+ if (toRemove._group) {
+ matches =
+ (toRemove as GroupInfoInput).id === (account as GroupInfoInput).id;
+ } else {
+ matches = this._accountMatches(toRemove, account);
+ }
+ if (matches) {
+ this.splice('accounts', i, 1);
+ this.reporting.reportInteraction(`Remove from ${this.id}`);
+ return;
+ }
+ }
+ console.warn('received remove event for missing account', toRemove);
+ }
+
+ _getNativeInput(paperInput: PaperInputElementExt) {
+ // In Polymer 2 inputElement isn't nativeInput anymore
+ return (paperInput.$.nativeInput ||
+ paperInput.inputElement) as HTMLTextAreaElement;
+ }
+
+ _handleInputKeydown(
+ e: CustomEvent<{input: PaperInputElementExt; keyCode: number}>
+ ) {
+ const input = this._getNativeInput(e.detail.input);
+ if (
+ input.selectionStart !== input.selectionEnd ||
+ input.selectionStart !== 0
+ ) {
+ return;
+ }
+ switch (e.detail.keyCode) {
+ case 8: // Backspace
+ this.removeAccount(this.accounts[this.accounts.length - 1]);
+ break;
+ case 37: // Left arrow
+ if (this.accountChips[this.accountChips.length - 1]) {
+ this.accountChips[this.accountChips.length - 1].focus();
+ }
+ break;
+ }
+ }
+
+ _handleChipKeydown(e: KeyboardEvent) {
+ const chip = e.target as GrAccountChip;
+ const chips = this.accountChips;
+ const index = chips.indexOf(chip);
+ switch (e.keyCode) {
+ case 8: // Backspace
+ case 13: // Enter
+ case 32: // Spacebar
+ case 46: // Delete
+ this.removeAccount(chip.account);
+ // Splice from this array to avoid inconsistent ordering of
+ // event handling.
+ chips.splice(index, 1);
+ if (index < chips.length) {
+ chips[index].focus();
+ } else if (index > 0) {
+ chips[index - 1].focus();
+ } else {
+ this.$.entry.focus();
+ }
+ break;
+ case 37: // Left arrow
+ if (index > 0) {
+ chip.blur();
+ chips[index - 1].focus();
+ }
+ break;
+ case 39: // Right arrow
+ chip.blur();
+ if (index < chips.length - 1) {
+ chips[index + 1].focus();
+ } else {
+ this.$.entry.focus();
+ }
+ break;
+ }
+ }
+
+ /**
+ * Submit the text of the entry as a reviewer value, if it exists. If it is
+ * a successful submit of the text, clear the entry value.
+ *
+ * @return If there is text in the entry, return true if the
+ * submission was successful and false if not. If there is no text,
+ * return true.
+ */
+ submitEntryText() {
+ const text = this.$.entry.getText();
+ if (!text.length) {
+ return true;
+ }
+ const wasSubmitted = this.addAccountItem(text);
+ if (wasSubmitted) {
+ this.$.entry.clear();
+ }
+ return wasSubmitted;
+ }
+
+ additions(): AccountAddition[] {
+ return this.accounts
+ .filter(account => account._pendingAdd)
+ .map(account => {
+ if (isGroupInfoInput(account)) {
+ return {group: account};
+ } else if (isAccountInfoInput(account)) {
+ return {account};
+ } else {
+ throw new Error('AccountInput must be either Account or Group.');
+ }
+ });
+ }
+
+ _computeEntryHidden(
+ maxCount: number,
+ accountsRecord: PolymerDeepPropertyChange<AccountInput[], AccountInput[]>,
+ readonly: boolean
+ ) {
+ return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
index b26f468..3acba5b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-account-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-account-list');
@@ -54,7 +53,7 @@
let suggestionsProvider;
function getChips() {
- return dom(element.root).querySelectorAll('gr-account-chip');
+ return element.root.querySelectorAll('gr-account-chip');
}
setup(() => {
@@ -78,7 +77,7 @@
});
test('addition and removal of account/group chips', () => {
- flushAsynchronousOperations();
+ flush();
sinon.stub(element, '_computeRemovable').returns(true);
// Existing accounts are listed.
let chips = getChips();
@@ -95,7 +94,7 @@
},
},
});
- flushAsynchronousOperations();
+ flush();
chips = getChips();
assert.equal(chips.length, 3);
assert.isFalse(chips[0].classList.contains('pendingAdd'));
@@ -108,7 +107,7 @@
detail: {account: existingAccount1},
composed: true, bubbles: true,
}));
- flushAsynchronousOperations();
+ flush();
chips = getChips();
assert.equal(chips.length, 2);
assert.isFalse(chips[0].classList.contains('pendingAdd'));
@@ -125,7 +124,7 @@
detail: {account: newAccount},
composed: true, bubbles: true,
}));
- flushAsynchronousOperations();
+ flush();
chips = getChips();
assert.equal(chips.length, 1);
assert.isFalse(chips[0].classList.contains('pendingAdd'));
@@ -139,7 +138,7 @@
},
},
});
- flushAsynchronousOperations();
+ flush();
chips = getChips();
assert.equal(chips.length, 2);
assert.isTrue(chips[1].classList.contains('group'));
@@ -151,13 +150,13 @@
detail: {account: newGroup},
composed: true, bubbles: true,
}));
- flushAsynchronousOperations();
+ flush();
chips = getChips();
assert.equal(chips.length, 1);
assert.isFalse(chips[0].classList.contains('pendingAdd'));
});
- test('_getSuggestions uses filter correctly', done => {
+ test('_getSuggestions uses filter correctly', () => {
const originalSuggestions = [
{
email: 'abc@example.com',
@@ -185,7 +184,7 @@
};
});
- element._getSuggestions().then(suggestions => {
+ return element._getSuggestions().then(suggestions => {
// Default is no filtering.
assert.equal(suggestions.length, 3);
@@ -195,14 +194,13 @@
return suggestion._account_id === accountId;
};
- element._getSuggestions()
- .then(suggestions => {
- assert.deepEqual(suggestions,
- [{name: originalSuggestions[0].email,
- value: originalSuggestions[0]._account_id}]);
- })
- .then(done);
- });
+ return element._getSuggestions();
+ })
+ .then(suggestions => {
+ assert.deepEqual(suggestions,
+ [{name: originalSuggestions[0].email,
+ value: originalSuggestions[0]._account_id}]);
+ });
});
test('_computeChipClass', () => {
@@ -236,7 +234,7 @@
test('submitEntryText', () => {
element.allowAnyInput = true;
- flushAsynchronousOperations();
+ flush();
const getTextStub = sinon.stub(element.$.entry, 'getText');
getTextStub.onFirstCall().returns('');
@@ -346,11 +344,11 @@
},
},
});
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.entry.hasAttribute('hidden'));
});
- test('enter text calls suggestions provider', done => {
+ test('enter text calls suggestions provider', async () => {
const suggestions = [
{
email: 'abc@example.com',
@@ -374,16 +372,13 @@
input.text = 'newTest';
MockInteractions.focus(input.$.input);
input.noDebounce = true;
- flushAsynchronousOperations();
- flush(() => {
- assert.isTrue(getSuggestionsStub.calledOnce);
- assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
- assert.equal(makeSuggestionItemStub.getCalls().length, 2);
- done();
- });
+ await flush();
+ assert.isTrue(getSuggestionsStub.calledOnce);
+ assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
+ assert.equal(makeSuggestionItemStub.getCalls().length, 2);
});
- test('suggestion on empty', done => {
+ test('suggestion on empty', async () => {
element.skipSuggestOnEmpty = false;
const suggestions = [
{
@@ -408,16 +403,13 @@
input.text = '';
MockInteractions.focus(input.$.input);
input.noDebounce = true;
- flushAsynchronousOperations();
- flush(() => {
- assert.isTrue(getSuggestionsStub.calledOnce);
- assert.equal(getSuggestionsStub.lastCall.args[0], '');
- assert.equal(makeSuggestionItemStub.getCalls().length, 2);
- done();
- });
+ await flush();
+ assert.isTrue(getSuggestionsStub.calledOnce);
+ assert.equal(getSuggestionsStub.lastCall.args[0], '');
+ assert.equal(makeSuggestionItemStub.getCalls().length, 2);
});
- test('skip suggestion on empty', done => {
+ test('skip suggestion on empty', async () => {
element.skipSuggestOnEmpty = true;
const getSuggestionsStub =
sinon.stub(suggestionsProvider, 'getSuggestions')
@@ -428,11 +420,8 @@
input.text = '';
MockInteractions.focus(input.$.input);
input.noDebounce = true;
- flushAsynchronousOperations();
- flush(() => {
- assert.isTrue(getSuggestionsStub.notCalled);
- done();
- });
+ await flush();
+ assert.isTrue(getSuggestionsStub.notCalled);
});
suite('allowAnyInput', () => {
@@ -469,68 +458,62 @@
});
suite('keyboard interactions', () => {
- test('backspace at text input start removes last account', done => {
+ test('backspace at text input start removes last account', async () => {
const input = element.$.entry.$.input;
sinon.stub(input, '_updateSuggestions');
sinon.stub(element, '_computeRemovable').returns(true);
- flush(() => {
- // Next line is a workaround for Firefox not moving cursor
- // on input field update
- assert.equal(
- element._getNativeInput(input.$.input).selectionStart, 0);
- input.text = 'test';
- MockInteractions.focus(input.$.input);
- flushAsynchronousOperations();
- assert.equal(element.accounts.length, 2);
- MockInteractions.pressAndReleaseKeyOn(
- element._getNativeInput(input.$.input), 8); // Backspace
- assert.equal(element.accounts.length, 2);
- input.text = '';
- MockInteractions.pressAndReleaseKeyOn(
- element._getNativeInput(input.$.input), 8); // Backspace
- flushAsynchronousOperations();
- assert.equal(element.accounts.length, 1);
- done();
- });
+ await flush();
+ // Next line is a workaround for Firefox not moving cursor
+ // on input field update
+ assert.equal(
+ element._getNativeInput(input.$.input).selectionStart, 0);
+ input.text = 'test';
+ MockInteractions.focus(input.$.input);
+ flush();
+ assert.equal(element.accounts.length, 2);
+ MockInteractions.pressAndReleaseKeyOn(
+ element._getNativeInput(input.$.input), 8); // Backspace
+ assert.equal(element.accounts.length, 2);
+ input.text = '';
+ MockInteractions.pressAndReleaseKeyOn(
+ element._getNativeInput(input.$.input), 8); // Backspace
+ flush();
+ assert.equal(element.accounts.length, 1);
});
- test('arrow key navigation', done => {
+ test('arrow key navigation', async () => {
const input = element.$.entry.$.input;
input.text = '';
element.accounts = [makeAccount(), makeAccount()];
- flush(() => {
- MockInteractions.focus(input.$.input);
- flushAsynchronousOperations();
- const chips = element.accountChips;
- const chipsOneSpy = sinon.spy(chips[1], 'focus');
- MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
- assert.isTrue(chipsOneSpy.called);
- const chipsZeroSpy = sinon.spy(chips[0], 'focus');
- MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
- assert.isTrue(chipsZeroSpy.called);
- MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
- assert.isTrue(chipsZeroSpy.calledOnce);
- MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
- assert.isTrue(chipsOneSpy.calledTwice);
- done();
- });
+ flush();
+ MockInteractions.focus(input.$.input);
+ await flush();
+ const chips = element.accountChips;
+ const chipsOneSpy = sinon.spy(chips[1], 'focus');
+ MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
+ assert.isTrue(chipsOneSpy.called);
+ const chipsZeroSpy = sinon.spy(chips[0], 'focus');
+ MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
+ assert.isTrue(chipsZeroSpy.called);
+ MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
+ assert.isTrue(chipsZeroSpy.calledOnce);
+ MockInteractions.pressAndReleaseKeyOn(chips[0], 39); // Right
+ assert.isTrue(chipsOneSpy.calledTwice);
});
- test('delete', done => {
+ test('delete', () => {
element.accounts = [makeAccount(), makeAccount()];
- flush(() => {
- const focusSpy = sinon.spy(element.accountChips[1], 'focus');
- const removeSpy = sinon.spy(element, 'removeAccount');
- MockInteractions.pressAndReleaseKeyOn(
- element.accountChips[0], 8); // Backspace
- assert.isTrue(focusSpy.called);
- assert.isTrue(removeSpy.calledOnce);
+ flush();
+ const focusSpy = sinon.spy(element.accountChips[1], 'focus');
+ const removeSpy = sinon.spy(element, 'removeAccount');
+ MockInteractions.pressAndReleaseKeyOn(
+ element.accountChips[0], 8); // Backspace
+ assert.isTrue(focusSpy.called);
+ assert.isTrue(removeSpy.calledOnce);
- MockInteractions.pressAndReleaseKeyOn(
- element.accountChips[1], 46); // Delete
- assert.isTrue(removeSpy.calledTwice);
- done();
- });
+ MockInteractions.pressAndReleaseKeyOn(
+ element.accountChips[1], 46); // Delete
+ assert.isTrue(removeSpy.calledTwice);
});
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
deleted file mode 100644
index d3a6fad..0000000
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ /dev/null
@@ -1,113 +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 '../gr-button/gr-button.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-alert_html.js';
-import {getRootElement} from '../../../scripts/rootElement.js';
-
-/** @extends PolymerElement */
-class GrAlert extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-alert'; }
- /**
- * Fired when the action button is pressed.
- *
- * @event action
- */
-
- static get properties() {
- return {
- text: String,
- actionText: String,
- /** @type {?string} */
- type: String,
- shown: {
- type: Boolean,
- value: true,
- readOnly: true,
- reflectToAttribute: true,
- },
- toast: {
- type: Boolean,
- value: true,
- reflectToAttribute: true,
- },
-
- _hideActionButton: Boolean,
- _boundTransitionEndHandler: {
- type: Function,
- value() { return this._handleTransitionEnd.bind(this); },
- },
- _actionCallback: Function,
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.addEventListener('transitionend', this._boundTransitionEndHandler);
- }
-
- /** @override */
- detached() {
- super.detached();
- this.removeEventListener('transitionend',
- this._boundTransitionEndHandler);
- }
-
- show(text, opt_actionText, opt_actionCallback) {
- this.text = text;
- this.actionText = opt_actionText;
- this._hideActionButton = !opt_actionText;
- this._actionCallback = opt_actionCallback;
- getRootElement().appendChild(this);
- this._setShown(true);
- }
-
- hide() {
- this._setShown(false);
- if (this._hasZeroTransitionDuration()) {
- getRootElement().removeChild(this);
- }
- }
-
- _hasZeroTransitionDuration() {
- const style = window.getComputedStyle(this);
- // transitionDuration is always given in seconds.
- const duration = Math.round(parseFloat(style.transitionDuration) * 100);
- return duration === 0;
- }
-
- _handleTransitionEnd(e) {
- if (this.shown) { return; }
-
- getRootElement().removeChild(this);
- }
-
- _handleActionTap(e) {
- e.preventDefault();
- if (this._actionCallback) { this._actionCallback(); }
- }
-}
-
-customElements.define(GrAlert.is, GrAlert);
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
new file mode 100644
index 0000000..e5806f0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -0,0 +1,129 @@
+/**
+ * @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 '../gr-button/gr-button';
+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-alert_html';
+import {getRootElement} from '../../../scripts/rootElement';
+import {customElement, property} from '@polymer/decorators';
+import {ErrorType} from '../../../types/types';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-alert': GrAlert;
+ }
+}
+
+@customElement('gr-alert')
+export class GrAlert extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the action button is pressed.
+ *
+ * @event action
+ */
+
+ @property({type: String})
+ text?: string;
+
+ @property({type: String})
+ actionText?: string;
+
+ @property({type: String})
+ type?: ErrorType;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ shown = true;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ toast = true;
+
+ @property({type: Boolean})
+ _hideActionButton?: boolean;
+
+ @property()
+ _boundTransitionEndHandler?: (
+ this: HTMLElement,
+ ev: TransitionEvent
+ ) => unknown;
+
+ @property()
+ _actionCallback?: () => void;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._boundTransitionEndHandler = () => this._handleTransitionEnd();
+ this.addEventListener('transitionend', this._boundTransitionEndHandler);
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ if (this._boundTransitionEndHandler) {
+ this.removeEventListener(
+ 'transitionend',
+ this._boundTransitionEndHandler
+ );
+ }
+ }
+
+ show(text: string, actionText?: string, actionCallback?: () => void) {
+ this.text = text;
+ this.actionText = actionText;
+ this._hideActionButton = !actionText;
+ this._actionCallback = actionCallback;
+ getRootElement().appendChild(this);
+ this.shown = true;
+ }
+
+ hide() {
+ this.shown = false;
+ if (this._hasZeroTransitionDuration()) {
+ getRootElement().removeChild(this);
+ }
+ }
+
+ _hasZeroTransitionDuration() {
+ const style = window.getComputedStyle(this);
+ // transitionDuration is always given in seconds.
+ const duration = Math.round(parseFloat(style.transitionDuration) * 100);
+ return duration === 0;
+ }
+
+ _handleTransitionEnd() {
+ if (this.shown) {
+ return;
+ }
+
+ getRootElement().removeChild(this);
+ }
+
+ _handleActionTap(e: MouseEvent) {
+ e.preventDefault();
+ if (this._actionCallback) {
+ this._actionCallback();
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
index 8105584..11ec496 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
@@ -39,11 +39,13 @@
assert.isNull(element.parentNode);
});
- test('action event', done => {
+ test('action event', () => {
+ const spy = sinon.spy();
element.show();
- element._actionCallback = done;
- MockInteractions.tap(element.shadowRoot
- .querySelector('.action'));
+ element._actionCallback = spy;
+ assert.isFalse(spy.called);
+ MockInteractions.tap(element.shadowRoot.querySelector('.action'));
+ assert.isTrue(spy.called);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
deleted file mode 100644
index f4c4886..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
+++ /dev/null
@@ -1,204 +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-dropdown/iron-dropdown.js';
-import '../gr-cursor-manager/gr-cursor-manager.js';
-import '../../../styles/shared-styles.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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-autocomplete-dropdown_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin.js';
-
-/**
- * @extends PolymerElement
- */
-class GrAutocompleteDropdown extends IronFitMixin(KeyboardShortcutMixin(
- GestureEventListeners(LegacyElementMixin(PolymerElement)))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-autocomplete-dropdown'; }
- /**
- * Fired when the dropdown is closed.
- *
- * @event dropdown-closed
- */
-
- /**
- * Fired when item is selected.
- *
- * @event item-selected
- */
-
- static get properties() {
- return {
- index: Number,
- isHidden: {
- type: Boolean,
- value: true,
- reflectToAttribute: true,
- },
- verticalOffset: {
- type: Number,
- value: null,
- },
- horizontalOffset: {
- type: Number,
- value: null,
- },
- suggestions: {
- type: Array,
- value: () => [],
- observer: '_resetCursorStops',
- },
- _suggestionEls: Array,
- };
- }
-
- get keyBindings() {
- return {
- up: '_handleUp',
- down: '_handleDown',
- enter: '_handleEnter',
- esc: '_handleEscape',
- tab: '_handleTab',
- };
- }
-
- close() {
- this.isHidden = true;
- }
-
- open() {
- this.isHidden = false;
- this._resetCursorStops();
- // Refit should run after we call Polymer.flush inside _resetCursorStops
- this.refit();
- }
-
- getCurrentText() {
- return this.getCursorTarget().dataset.value;
- }
-
- _handleUp(e) {
- if (!this.isHidden) {
- e.preventDefault();
- e.stopPropagation();
- this.cursorUp();
- }
- }
-
- _handleDown(e) {
- if (!this.isHidden) {
- e.preventDefault();
- e.stopPropagation();
- this.cursorDown();
- }
- }
-
- cursorDown() {
- if (!this.isHidden) {
- this.$.cursor.next();
- }
- }
-
- cursorUp() {
- if (!this.isHidden) {
- this.$.cursor.previous();
- }
- }
-
- _handleTab(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('item-selected', {
- detail: {
- trigger: 'tab',
- selected: this.$.cursor.target,
- },
- composed: true, bubbles: true,
- }));
- }
-
- _handleEnter(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('item-selected', {
- detail: {
- trigger: 'enter',
- selected: this.$.cursor.target,
- },
- composed: true, bubbles: true,
- }));
- }
-
- _handleEscape() {
- this._fireClose();
- this.close();
- }
-
- _handleClickItem(e) {
- e.preventDefault();
- e.stopPropagation();
- let selected = e.target;
- while (!selected.classList.contains('autocompleteOption')) {
- if (!selected || selected === this) { return; }
- selected = selected.parentElement;
- }
- this.dispatchEvent(new CustomEvent('item-selected', {
- detail: {
- trigger: 'click',
- selected,
- },
- composed: true, bubbles: true,
- }));
- }
-
- _fireClose() {
- this.dispatchEvent(new CustomEvent('dropdown-closed', {
- composed: true, bubbles: true,
- }));
- }
-
- getCursorTarget() {
- return this.$.cursor.target;
- }
-
- _resetCursorStops() {
- if (this.suggestions.length > 0) {
- if (!this.isHidden) {
- flush();
- this._suggestionEls = Array.from(
- this.$.suggestions.querySelectorAll('li'));
- this._resetCursorIndex();
- }
- } else {
- this._suggestionEls = [];
- }
- }
-
- _resetCursorIndex() {
- this.$.cursor.setCursorAtIndex(0);
- }
-
- _computeLabelClass(item) {
- return item.label ? '' : 'hide';
- }
-}
-
-customElements.define(GrAutocompleteDropdown.is, GrAutocompleteDropdown);
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
new file mode 100644
index 0000000..451bdfa
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.ts
@@ -0,0 +1,241 @@
+/**
+ * @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-dropdown/iron-dropdown';
+import '../gr-cursor-manager/gr-cursor-manager';
+import '../../../styles/shared-styles';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-autocomplete-dropdown_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin';
+import {customElement, property, observe} from '@polymer/decorators';
+import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
+
+// TODO(TS): Update once GrCursorManager is upated
+export interface GrAutocompleteDropdown {
+ $: {
+ cursor: any;
+ suggestions: Element;
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-autocomplete-dropdown': GrAutocompleteDropdown;
+ }
+}
+
+interface Item {
+ dataValue?: string;
+ name?: string;
+ text?: string;
+ label?: string;
+ value?: string;
+}
+
+/**
+ * @extends PolymerElement
+ */
+@customElement('gr-autocomplete-dropdown')
+export class GrAutocompleteDropdown extends IronFitMixin(
+ KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+ ),
+ IronFitBehavior as IronFitBehavior
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the dropdown is closed.
+ *
+ * @event dropdown-closed
+ */
+
+ /**
+ * Fired when item is selected.
+ *
+ * @event item-selected
+ */
+
+ @property({type: Number})
+ index: number | null = null;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ isHidden = true;
+
+ @property({type: Number})
+ verticalOffset: number | null = null;
+
+ @property({type: Number})
+ horizontalOffset: number | null = null;
+
+ @property({type: Array})
+ suggestions: Item[] = [];
+
+ @property({type: Array})
+ _suggestionEls: Element[] = [];
+
+ get keyBindings() {
+ return {
+ up: '_handleUp',
+ down: '_handleDown',
+ enter: '_handleEnter',
+ esc: '_handleEscape',
+ tab: '_handleTab',
+ };
+ }
+
+ close() {
+ this.isHidden = true;
+ }
+
+ open() {
+ this.isHidden = false;
+ this._resetCursorStops();
+ // Refit should run after we call Polymer.flush inside _resetCursorStops
+ this.refit();
+ }
+
+ getCurrentText() {
+ return this.getCursorTarget().dataset['value'];
+ }
+
+ _handleUp(e: Event) {
+ if (!this.isHidden) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.cursorUp();
+ }
+ }
+
+ _handleDown(e: Event) {
+ if (!this.isHidden) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.cursorDown();
+ }
+ }
+
+ cursorDown() {
+ if (!this.isHidden) {
+ this.$.cursor.next();
+ }
+ }
+
+ cursorUp() {
+ if (!this.isHidden) {
+ this.$.cursor.previous();
+ }
+ }
+
+ _handleTab(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('item-selected', {
+ detail: {
+ trigger: 'tab',
+ selected: this.$.cursor.target,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _handleEnter(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('item-selected', {
+ detail: {
+ trigger: 'enter',
+ selected: this.$.cursor.target,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _handleEscape() {
+ this._fireClose();
+ this.close();
+ }
+
+ _handleClickItem(e: Event) {
+ e.preventDefault();
+ e.stopPropagation();
+ let selected = e.target! as Element;
+ while (!selected.classList.contains('autocompleteOption')) {
+ if (!selected || selected === this) {
+ return;
+ }
+ selected = selected.parentElement!;
+ }
+ this.dispatchEvent(
+ new CustomEvent('item-selected', {
+ detail: {
+ trigger: 'click',
+ selected,
+ },
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _fireClose() {
+ this.dispatchEvent(
+ new CustomEvent('dropdown-closed', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ getCursorTarget() {
+ return this.$.cursor.target;
+ }
+
+ @observe('suggestions')
+ _resetCursorStops() {
+ if (this.suggestions.length > 0) {
+ if (!this.isHidden) {
+ flush();
+ this._suggestionEls = Array.from(
+ this.$.suggestions.querySelectorAll('li')
+ );
+ this._resetCursorIndex();
+ }
+ } else {
+ this._suggestionEls = [];
+ }
+ }
+
+ _resetCursorIndex() {
+ this.$.cursor.setCursorAtIndex(0);
+ }
+
+ _computeLabelClass(item: Item) {
+ return item.label ? '' : 'hide';
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
index f76d070..ad06649 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
@@ -29,7 +29,7 @@
element.suggestions = [
{dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
{dataValue: 'test value 2', name: 'test name 2', text: 2}];
- flushAsynchronousOperations();
+ flush();
});
teardown(() => {
@@ -42,12 +42,11 @@
assert.equal(els[1].innerText.trim(), '2');
});
- test('escape key', done => {
+ test('escape key', () => {
const closeSpy = sinon.spy(element, 'close');
MockInteractions.pressAndReleaseKeyOn(element, 27);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(closeSpy.called);
- done();
});
test('tab key', () => {
@@ -108,7 +107,7 @@
element.addEventListener('item-selected', itemSelectedStub);
MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
trigger: 'click',
selected: element.$.suggestions.querySelectorAll('li')[1],
@@ -121,7 +120,7 @@
MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
.lastElementChild);
- flushAsynchronousOperations();
+ flush();
assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
trigger: 'click',
selected: element.$.suggestions.querySelectorAll('li')[0],
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
deleted file mode 100644
index 90b966f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ /dev/null
@@ -1,483 +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 '@polymer/paper-input/paper-input.js';
-import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown.js';
-import '../gr-cursor-manager/gr-cursor-manager.js';
-import '../gr-icons/gr-icons.js';
-import '../../../styles/shared-styles.js';
-import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-autocomplete_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
-const DEBOUNCE_WAIT_MS = 200;
-
-/**
- * @extends PolymerElement
- */
-class GrAutocomplete extends KeyboardShortcutMixin(
- GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-autocomplete'; }
- /**
- * Fired when a value is chosen.
- *
- * @event commit
- */
-
- /**
- * Fired when the user cancels.
- *
- * @event cancel
- */
-
- /**
- * Fired on keydown to allow for custom hooks into autocomplete textbox
- * behavior.
- *
- * @event input-keydown
- */
-
- static get properties() {
- return {
-
- /**
- * Query for requesting autocomplete suggestions. The function should
- * accept the input as a string parameter and return a promise. The
- * promise yields an array of suggestion objects with "name", "label",
- * "value" properties. The "name" property will be displayed in the
- * suggestion entry. The "label" property will, when specified, appear
- * next to the "name" as label text. The "value" property will be emitted
- * if that suggestion is selected.
- *
- * @type {function(string): Promise<?>}
- */
- query: {
- type: Function,
- value() {
- return function() {
- return Promise.resolve([]);
- };
- },
- },
-
- /**
- * The number of characters that must be typed before suggestions are
- * made. If threshold is zero, default suggestions are enabled.
- */
- threshold: {
- type: Number,
- value: 1,
- },
-
- allowNonSuggestedValues: Boolean,
- borderless: Boolean,
- disabled: Boolean,
- showSearchIcon: {
- type: Boolean,
- value: false,
- },
- /**
- * Vertical offset needed for an element with 20px line-height, 4px
- * padding and 1px border (30px height total). Plus 1px spacing between
- * input and dropdown. Inputs with different line-height or padding will
- * need to tweak vertical offset.
- */
- verticalOffset: {
- type: Number,
- value: 31,
- },
-
- text: {
- type: String,
- value: '',
- notify: true,
- },
-
- placeholder: String,
-
- clearOnCommit: {
- type: Boolean,
- value: false,
- },
-
- /**
- * When true, tab key autocompletes but does not fire the commit event.
- * When false, tab key not caught, and focus is removed from the element.
- * See Issue 4556, Issue 6645.
- */
- tabComplete: {
- type: Boolean,
- value: false,
- },
-
- value: {
- type: String,
- notify: true,
- },
-
- /**
- * Multi mode appends autocompleted entries to the value.
- * If false, autocompleted entries replace value.
- */
- multi: {
- type: Boolean,
- value: false,
- },
-
- /**
- * When true and uncommitted text is left in the autocomplete input after
- * blurring, the text will appear red.
- */
- warnUncommitted: {
- type: Boolean,
- value: false,
- },
-
- /**
- * When true, querying for suggestions is not debounced w/r/t keypresses
- */
- noDebounce: {
- type: Boolean,
- value: false,
- },
-
- /** @type {?} */
- _suggestions: {
- type: Array,
- value() { return []; },
- },
-
- _suggestionEls: {
- type: Array,
- value() { return []; },
- },
-
- _index: Number,
- _disableSuggestions: {
- type: Boolean,
- value: false,
- },
- _focused: {
- type: Boolean,
- value: false,
- },
- /**
- * Invisible label for input element. This label is exposed to
- * screen readers by paper-input
- */
- label: {
- type: String,
- value: '',
- },
-
- /** The DOM element of the selected suggestion. */
- _selected: Object,
- };
- }
-
- static get observers() {
- return [
- '_maybeOpenDropdown(_suggestions, _focused)',
- '_updateSuggestions(text, threshold, noDebounce)',
- ];
- }
-
- get _nativeInput() {
- // In Polymer 2 inputElement isn't nativeInput anymore
- return this.$.input.$.nativeInput || this.$.input.inputElement;
- }
-
- /** @override */
- attached() {
- super.attached();
- this.listen(document.body, 'click', '_handleBodyClick');
- }
-
- /** @override */
- detached() {
- super.detached();
- this.unlisten(document.body, 'click', '_handleBodyClick');
- this.cancelDebouncer('update-suggestions');
- }
-
- get focusStart() {
- return this.$.input;
- }
-
- focus() {
- this._nativeInput.focus();
- }
-
- selectAll() {
- const nativeInputElement = this._nativeInput;
- if (!this.$.input.value) { return; }
- nativeInputElement.setSelectionRange(0, this.$.input.value.length);
- }
-
- clear() {
- this.text = '';
- }
-
- _handleItemSelect(e) {
- // Let _handleKeydown deal with keyboard interaction.
- if (e.detail.trigger !== 'click') { return; }
- this._selected = e.detail.selected;
- this._commit();
- }
-
- get _inputElement() {
- // Polymer2: this.$ can be undefined when this is first evaluated.
- return this.$ && this.$.input;
- }
-
- /**
- * Set the text of the input without triggering the suggestion dropdown.
- *
- * @param {string} text The new text for the input.
- */
- setText(text) {
- this._disableSuggestions = true;
- this.text = text;
- this._disableSuggestions = false;
- }
-
- _onInputFocus() {
- this._focused = true;
- this._updateSuggestions(this.text, this.threshold, this.noDebounce);
- this.$.input.classList.remove('warnUncommitted');
- // Needed so that --paper-input-container-input updated style is applied.
- this.updateStyles();
- }
-
- _onInputBlur() {
- this.$.input.classList.toggle('warnUncommitted',
- this.warnUncommitted && this.text.length && !this._focused);
- // Needed so that --paper-input-container-input updated style is applied.
- this.updateStyles();
- }
-
- _updateSuggestions(text, threshold, noDebounce) {
- // Polymer 2: check for undefined
- if ([text, threshold, noDebounce].includes(undefined)) {
- return;
- }
-
- // Reset _suggestions for every update
- // This will also prevent from carrying over suggestions:
- // @see Issue 12039
- this._suggestions = [];
-
- // TODO(taoalpha): Also skip if text has not changed
-
- if (this._disableSuggestions) { return; }
- if (text.length < threshold) {
- this.value = '';
- return;
- }
-
- if (!this._focused) {
- return;
- }
-
- const update = () => {
- this.query(text).then(suggestions => {
- if (text !== this.text) {
- // Late response.
- return;
- }
- for (const suggestion of suggestions) {
- suggestion.text = suggestion.name;
- }
- this._suggestions = suggestions;
- flush();
- if (this._index === -1) {
- this.value = '';
- }
- });
- };
-
- if (noDebounce) {
- update();
- } else {
- this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
- }
- }
-
- _maybeOpenDropdown(suggestions, focused) {
- if (suggestions.length > 0 && focused) {
- return this.$.suggestions.open();
- }
- return this.$.suggestions.close();
- }
-
- _computeClass(borderless) {
- return borderless ? 'borderless' : '';
- }
-
- /**
- * _handleKeydown used for key handling in the this.$.input AND all child
- * autocomplete options.
- */
- _handleKeydown(e) {
- this._focused = true;
- switch (e.keyCode) {
- case 38: // Up
- e.preventDefault();
- this.$.suggestions.cursorUp();
- break;
- case 40: // Down
- e.preventDefault();
- this.$.suggestions.cursorDown();
- break;
- case 27: // Escape
- e.preventDefault();
- this._cancel();
- break;
- case 9: // Tab
- if (this._suggestions.length > 0 && this.tabComplete) {
- e.preventDefault();
- this._handleInputCommit(true);
- this.focus();
- } else {
- this._focused = false;
- }
- break;
- case 13: // Enter
- if (this.modifierPressed(e)) { break; }
- e.preventDefault();
- this._handleInputCommit();
- break;
- default:
- // For any normal keypress, return focus to the input to allow for
- // unbroken user input.
- this.focus();
-
- // Since this has been a normal keypress, the suggestions will have
- // been based on a previous input. Clear them. This prevents an
- // outdated suggestion from being used if the input keystroke is
- // immediately followed by a commit keystroke. @see Issue 8655
- this._suggestions = [];
- }
- this.dispatchEvent(new CustomEvent('input-keydown', {
- detail: {keyCode: e.keyCode, input: this.$.input},
- composed: true, bubbles: true,
- }));
- }
-
- _cancel() {
- if (this._suggestions.length) {
- this.set('_suggestions', []);
- } else {
- this.dispatchEvent(new CustomEvent('cancel', {
- composed: true, bubbles: true,
- }));
- }
- }
-
- /**
- * @param {boolean=} opt_tabComplete
- */
- _handleInputCommit(opt_tabComplete) {
- // Nothing to do if the dropdown is not open.
- if (!this.allowNonSuggestedValues &&
- this.$.suggestions.isHidden) { return; }
-
- this._selected = this.$.suggestions.getCursorTarget();
- this._commit(opt_tabComplete);
- }
-
- _updateValue(suggestion, suggestions) {
- if (!suggestion) { return; }
- const completed = suggestions[suggestion.dataset.index].value;
- if (this.multi) {
- // Append the completed text to the end of the string.
- // Allow spaces within quoted terms.
- const tokens = this.text.match(TOKENIZE_REGEX);
- tokens[tokens.length - 1] = completed;
- this.value = tokens.join(' ');
- } else {
- this.value = completed;
- }
- }
-
- _handleBodyClick(e) {
- const eventPath = dom(e).path;
- for (let i = 0; i < eventPath.length; i++) {
- if (eventPath[i] === this) {
- return;
- }
- }
- this._focused = false;
- }
-
- _handleSuggestionTap(e) {
- e.stopPropagation();
- this.$.cursor.setCursor(e.target);
- this._commit();
- }
-
- /**
- * Commits the suggestion, optionally firing the commit event.
- *
- * @param {boolean=} opt_silent Allows for silent committing of an
- * autocomplete suggestion in order to handle cases like tab-to-complete
- * without firing the commit event.
- */
- _commit(opt_silent) {
- // Allow values that are not in suggestion list iff suggestions are empty.
- if (this._suggestions.length > 0) {
- this._updateValue(this._selected, this._suggestions);
- } else {
- this.value = this.text || '';
- }
-
- const value = this.value;
-
- // Value and text are mirrors of each other in multi mode.
- if (this.multi) {
- this.setText(this.value);
- } else {
- if (!this.clearOnCommit && this._selected) {
- this.setText(this._suggestions[this._selected.dataset.index].name);
- } else {
- this.clear();
- }
- }
-
- this._suggestions = [];
- if (!opt_silent) {
- this.dispatchEvent(new CustomEvent('commit', {
- detail: {value},
- composed: true, bubbles: true,
- }));
- }
-
- this._textChangedSinceCommit = false;
- }
-
- _computeShowSearchIconClass(showSearchIcon) {
- return showSearchIcon ? 'showSearchIcon' : '';
- }
-}
-
-customElements.define(GrAutocomplete.is, GrAutocomplete);
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
new file mode 100644
index 0000000..668ea1b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -0,0 +1,512 @@
+/**
+ * @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 '@polymer/paper-input/paper-input';
+import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import '../gr-cursor-manager/gr-cursor-manager';
+import '../gr-icons/gr-icons';
+import '../../../styles/shared-styles';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-autocomplete_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {property, customElement, observe} from '@polymer/decorators';
+import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
+import {PaperInputElementExt} from '../../../types/types';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
+const DEBOUNCE_WAIT_MS = 200;
+
+export interface GrAutocomplete {
+ $: {
+ input: PaperInputElementExt;
+ suggestions: GrAutocompleteDropdown;
+ cursor: GrCursorManager;
+ };
+}
+
+export type AutocompleteQuery = (
+ text: string
+) => Promise<AutocompleteSuggestion[]>;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-autocomplete': GrAutocomplete;
+ }
+}
+
+export interface AutocompleteSuggestion {
+ name?: string;
+ label?: string;
+ // TODO(TS): this value can be string or arbitrary object (in gr-create-repo-dialog)
+ // probably should limit it to string only as it seems not used
+ value?: any;
+ text?: string;
+}
+
+export interface AutocompleteCommitEventDetail {
+ value: string;
+}
+
+export type AutocompleteCommitEvent = CustomEvent<
+ AutocompleteCommitEventDetail
+>;
+
+@customElement('gr-autocomplete')
+export class GrAutocomplete extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+ /**
+ * Fired when a value is chosen.
+ *
+ * @event commit
+ */
+
+ /**
+ * Fired when the user cancels.
+ *
+ * @event cancel
+ */
+
+ /**
+ * Fired on keydown to allow for custom hooks into autocomplete textbox
+ * behavior.
+ *
+ * @event input-keydown
+ */
+
+ /**
+ * Query for requesting autocomplete suggestions. The function should
+ * accept the input as a string parameter and return a promise. The
+ * promise yields an array of suggestion objects with "name", "label",
+ * "value" properties. The "name" property will be displayed in the
+ * suggestion entry. The "label" property will, when specified, appear
+ * next to the "name" as label text. The "value" property will be emitted
+ * if that suggestion is selected.
+ *
+ */
+ @property({type: Object})
+ query: AutocompleteQuery = () => Promise.resolve([]);
+
+ /**
+ * The number of characters that must be typed before suggestions are
+ * made. If threshold is zero, default suggestions are enabled.
+ */
+ @property({type: Number})
+ threshold = 1;
+
+ @property({type: Boolean})
+ allowNonSuggestedValues = false;
+
+ @property({type: Boolean})
+ borderless = false;
+
+ @property({type: Boolean})
+ disabled = false;
+
+ @property({type: Boolean})
+ showSearchIcon = false;
+
+ /**
+ * Vertical offset needed for an element with 20px line-height, 4px
+ * padding and 1px border (30px height total). Plus 1px spacing between
+ * input and dropdown. Inputs with different line-height or padding will
+ * need to tweak vertical offset.
+ */
+ @property({type: Number})
+ verticalOffset = 31;
+
+ @property({type: String, notify: true})
+ text = '';
+
+ @property({type: String})
+ placeholder = '';
+
+ @property({type: Boolean})
+ clearOnCommit = false;
+
+ /**
+ * When true, tab key autocompletes but does not fire the commit event.
+ * When false, tab key not caught, and focus is removed from the element.
+ * See Issue 4556, Issue 6645.
+ */
+ @property({type: Boolean})
+ tabComplete = false;
+
+ @property({type: String, notify: true})
+ value = '';
+
+ /**
+ * Multi mode appends autocompleted entries to the value.
+ * If false, autocompleted entries replace value.
+ */
+ @property({type: Boolean})
+ multi = false;
+
+ /**
+ * When true and uncommitted text is left in the autocomplete input after
+ * blurring, the text will appear red.
+ */
+ @property({type: Boolean})
+ warnUncommitted = false;
+
+ /**
+ * When true, querying for suggestions is not debounced w/r/t keypresses
+ */
+ @property({type: Boolean})
+ noDebounce = false;
+
+ @property({type: Array})
+ _suggestions: AutocompleteSuggestion[] = [];
+
+ @property({type: Array})
+ _suggestionEls = [];
+
+ @property({type: Number})
+ _index?: number;
+
+ @property({type: Boolean})
+ _disableSuggestions = false;
+
+ @property({type: Boolean})
+ _focused = false;
+
+ /**
+ * Invisible label for input element. This label is exposed to
+ * screen readers by paper-input
+ */
+ @property({type: String})
+ label = '';
+
+ /** The DOM element of the selected suggestion. */
+ @property({type: Object})
+ _selected: HTMLElement | null = null;
+
+ get _nativeInput() {
+ // In Polymer 2 inputElement isn't nativeInput anymore
+ return (this.$.input.$.nativeInput ||
+ this.$.input.inputElement) as HTMLInputElement;
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.listen(document.body, 'click', '_handleBodyClick');
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.unlisten(document.body, 'click', '_handleBodyClick');
+ this.cancelDebouncer('update-suggestions');
+ }
+
+ get focusStart() {
+ return this.$.input;
+ }
+
+ focus() {
+ this._nativeInput.focus();
+ }
+
+ selectAll() {
+ const nativeInputElement = this._nativeInput;
+ if (!this.$.input.value) {
+ return;
+ }
+ nativeInputElement.setSelectionRange(0, this.$.input.value.length);
+ }
+
+ clear() {
+ this.text = '';
+ }
+
+ _handleItemSelect(e: CustomEvent) {
+ // Let _handleKeydown deal with keyboard interaction.
+ if (e.detail.trigger !== 'click') {
+ return;
+ }
+ this._selected = e.detail.selected;
+ this._commit();
+ }
+
+ get _inputElement() {
+ // Polymer2: this.$ can be undefined when this is first evaluated.
+ return this.$ && this.$.input;
+ }
+
+ /**
+ * Set the text of the input without triggering the suggestion dropdown.
+ *
+ * @param text The new text for the input.
+ */
+ setText(text: string) {
+ this._disableSuggestions = true;
+ this.text = text;
+ this._disableSuggestions = false;
+ }
+
+ _onInputFocus() {
+ this._focused = true;
+ this._updateSuggestions(this.text, this.threshold, this.noDebounce);
+ this.$.input.classList.remove('warnUncommitted');
+ // Needed so that --paper-input-container-input updated style is applied.
+ this.updateStyles();
+ }
+
+ _onInputBlur() {
+ this.$.input.classList.toggle(
+ 'warnUncommitted',
+ this.warnUncommitted && !!this.text.length && !this._focused
+ );
+ // Needed so that --paper-input-container-input updated style is applied.
+ this.updateStyles();
+ }
+
+ @observe('text', 'threshold', 'noDebounce')
+ _updateSuggestions(text?: string, threshold?: number, noDebounce?: boolean) {
+ if (
+ text === undefined ||
+ threshold === undefined ||
+ noDebounce === undefined
+ )
+ return;
+
+ // Reset _suggestions for every update
+ // This will also prevent from carrying over suggestions:
+ // @see Issue 12039
+ this._suggestions = [];
+
+ // TODO(taoalpha): Also skip if text has not changed
+
+ if (this._disableSuggestions) {
+ return;
+ }
+ if (text.length < threshold) {
+ this.value = '';
+ return;
+ }
+
+ if (!this._focused) {
+ return;
+ }
+
+ const update = () => {
+ this.query(text).then(suggestions => {
+ if (text !== this.text) {
+ // Late response.
+ return;
+ }
+ for (const suggestion of suggestions) {
+ suggestion.text = suggestion.name;
+ }
+ this._suggestions = suggestions;
+ flush();
+ if (this._index === -1) {
+ this.value = '';
+ }
+ });
+ };
+
+ if (noDebounce) {
+ update();
+ } else {
+ this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
+ }
+ }
+
+ @observe('_suggestions', '_focused')
+ _maybeOpenDropdown(suggestions: AutocompleteSuggestion[], focused: boolean) {
+ if (suggestions.length > 0 && focused) {
+ return this.$.suggestions.open();
+ }
+ return this.$.suggestions.close();
+ }
+
+ _computeClass(borderless: boolean) {
+ return borderless ? 'borderless' : '';
+ }
+
+ /**
+ * _handleKeydown used for key handling in the this.$.input AND all child
+ * autocomplete options.
+ */
+ _handleKeydown(e: CustomKeyboardEvent) {
+ this._focused = true;
+ switch (e.keyCode) {
+ case 38: // Up
+ e.preventDefault();
+ this.$.suggestions.cursorUp();
+ break;
+ case 40: // Down
+ e.preventDefault();
+ this.$.suggestions.cursorDown();
+ break;
+ case 27: // Escape
+ e.preventDefault();
+ this._cancel();
+ break;
+ case 9: // Tab
+ if (this._suggestions.length > 0 && this.tabComplete) {
+ e.preventDefault();
+ this._handleInputCommit(true);
+ this.focus();
+ } else {
+ this._focused = false;
+ }
+ break;
+ case 13: // Enter
+ if (this.modifierPressed(e)) {
+ break;
+ }
+ e.preventDefault();
+ this._handleInputCommit();
+ break;
+ default:
+ // For any normal keypress, return focus to the input to allow for
+ // unbroken user input.
+ this.focus();
+
+ // Since this has been a normal keypress, the suggestions will have
+ // been based on a previous input. Clear them. This prevents an
+ // outdated suggestion from being used if the input keystroke is
+ // immediately followed by a commit keystroke. @see Issue 8655
+ this._suggestions = [];
+ }
+ this.dispatchEvent(
+ new CustomEvent('input-keydown', {
+ detail: {keyCode: e.keyCode, input: this.$.input},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _cancel() {
+ if (this._suggestions.length) {
+ this.set('_suggestions', []);
+ } else {
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ }
+
+ _handleInputCommit(_tabComplete?: boolean) {
+ // Nothing to do if the dropdown is not open.
+ if (!this.allowNonSuggestedValues && this.$.suggestions.isHidden) {
+ return;
+ }
+
+ this._selected = this.$.suggestions.getCursorTarget();
+ this._commit(_tabComplete);
+ }
+
+ _updateValue(
+ suggestion: HTMLElement | null,
+ suggestions: AutocompleteSuggestion[]
+ ) {
+ if (!suggestion) {
+ return;
+ }
+ const index = Number(suggestion.dataset['index']!);
+ if (isNaN(index)) return;
+ const completed = suggestions[index].value;
+ if (completed === undefined || completed === null) return;
+ if (this.multi) {
+ // Append the completed text to the end of the string.
+ // Allow spaces within quoted terms.
+ const tokens = this.text.match(TOKENIZE_REGEX);
+ if (tokens?.length) {
+ tokens[tokens.length - 1] = completed;
+ this.value = tokens.join(' ');
+ }
+ } else {
+ this.value = completed;
+ }
+ }
+
+ _handleBodyClick(e: Event) {
+ const eventPath = e.path;
+ if (!eventPath) return;
+ for (let i = 0; i < eventPath.length; i++) {
+ if (eventPath[i] === this) {
+ return;
+ }
+ }
+ this._focused = false;
+ }
+
+ /**
+ * Commits the suggestion, optionally firing the commit event.
+ *
+ * @param silent Allows for silent committing of an
+ * autocomplete suggestion in order to handle cases like tab-to-complete
+ * without firing the commit event.
+ */
+ _commit(silent?: boolean) {
+ // Allow values that are not in suggestion list iff suggestions are empty.
+ if (this._suggestions.length > 0) {
+ this._updateValue(this._selected, this._suggestions);
+ } else {
+ this.value = this.text || '';
+ }
+
+ const value = this.value;
+
+ // Value and text are mirrors of each other in multi mode.
+ if (this.multi) {
+ this.setText(this.value);
+ } else {
+ if (!this.clearOnCommit && this._selected) {
+ const dataSet = this._selected.dataset;
+ // index property cannot be null for the data-set
+ if (dataSet) {
+ const index = Number(dataSet['index']!);
+ if (isNaN(index)) return;
+ this.setText(this._suggestions[index].name || '');
+ }
+ } else {
+ this.clear();
+ }
+ }
+
+ this._suggestions = [];
+ if (!silent) {
+ this.dispatchEvent(
+ new CustomEvent('commit', {
+ detail: {value} as AutocompleteCommitEventDetail,
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ }
+
+ _computeShowSearchIconClass(showSearchIcon: boolean) {
+ return showSearchIcon ? 'showSearchIcon' : '';
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
index 8ab4828..62775aa 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_html.ts
@@ -101,6 +101,11 @@
>
</iron-icon>
</div>
+
+ <!-- suffix as attribute is required to for polymer 1 -->
+ <div slot="suffix" suffix="">
+ <slot name="suffix"></slot>
+ </div>
</paper-input>
<gr-autocomplete-dropdown
vertical-align="top"
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
index e9753c9..329265e 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
@@ -18,7 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-autocomplete.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {dom, flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromTemplate(
html`<gr-autocomplete no-debounce></gr-autocomplete>`);
@@ -57,7 +57,7 @@
return promise.then(() => {
assert.isFalse(element.$.suggestions.isHidden);
const suggestions =
- dom(element.$.suggestions.root).querySelectorAll('li');
+ element.$.suggestions.root.querySelectorAll('li');
assert.equal(suggestions.length, 5);
for (let i = 0; i < 5; i++) {
@@ -68,22 +68,20 @@
});
});
- test('selectAll', done => {
- flush(() => {
- const nativeInput = element._nativeInput;
- const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
+ test('selectAll', async () => {
+ await flush();
+ const nativeInput = element._nativeInput;
+ const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
- element.selectAll();
- assert.isFalse(selectionStub.called);
+ element.selectAll();
+ assert.isFalse(selectionStub.called);
- element.$.input.value = 'test';
- element.selectAll();
- assert.isTrue(selectionStub.called);
- done();
- });
+ element.$.input.value = 'test';
+ element.selectAll();
+ assert.isTrue(selectionStub.called);
});
- test('esc key behavior', done => {
+ test('esc key behavior', () => {
let promise;
const queryStub = sinon.spy(() => promise = Promise.resolve([
{name: 'blah', value: 123},
@@ -95,7 +93,7 @@
element._focused = true;
element.text = 'blah';
- promise.then(() => {
+ return promise.then(() => {
assert.isFalse(element.$.suggestions.isHidden);
const cancelHandler = sinon.spy();
@@ -108,11 +106,10 @@
MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
assert.isTrue(cancelHandler.called);
- done();
});
});
- test('emits commit and handles cursor movement', done => {
+ test('emits commit and handles cursor movement', () => {
let promise;
const queryStub = sinon.spy(input => promise = Promise.resolve([
{name: input + ' 0', value: 0},
@@ -128,7 +125,7 @@
element._focused = true;
element.text = 'blah';
- promise.then(() => {
+ return promise.then(() => {
assert.isFalse(element.$.suggestions.isHidden);
const commitHandler = sinon.spy();
@@ -158,11 +155,10 @@
assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
assert.isTrue(element.$.suggestions.isHidden);
assert.isTrue(element._focused);
- done();
});
});
- test('clear-on-commit behavior (off)', done => {
+ test('clear-on-commit behavior (off)', () => {
let promise;
const queryStub = sinon.spy(() => {
promise = Promise.resolve([{name: 'suggestion', value: 0}]);
@@ -172,7 +168,7 @@
focusOnInput(element);
element.text = 'blah';
- promise.then(() => {
+ return promise.then(() => {
const commitHandler = sinon.spy();
element.addEventListener('commit', commitHandler);
@@ -181,11 +177,10 @@
assert.isTrue(commitHandler.called);
assert.equal(element.text, 'suggestion');
- done();
});
});
- test('clear-on-commit behavior (on)', done => {
+ test('clear-on-commit behavior (on)', () => {
let promise;
const queryStub = sinon.spy(() => {
promise = Promise.resolve([{name: 'suggestion', value: 0}]);
@@ -196,7 +191,7 @@
element.text = 'blah';
element.clearOnCommit = true;
- promise.then(() => {
+ return promise.then(() => {
const commitHandler = sinon.spy();
element.addEventListener('commit', commitHandler);
@@ -205,7 +200,6 @@
assert.isTrue(commitHandler.called);
assert.equal(element.text, '');
- done();
});
});
@@ -248,7 +242,7 @@
assert.equal(element._suggestions.length, 0);
});
- test('when focused', done => {
+ test('when focused', () => {
let promise;
const queryStub = sinon.stub()
.returns(promise = Promise.resolve([
@@ -259,15 +253,14 @@
focusOnInput(element);
element.text = 'bla';
assert.equal(element._focused, true);
- flushAsynchronousOperations();
- promise.then(() => {
+ flush();
+ return promise.then(() => {
assert.equal(element._suggestions.length, 1);
assert.equal(queryStub.notCalled, false);
- done();
});
});
- test('when not focused', done => {
+ test('when not focused', () => {
let promise;
const queryStub = sinon.stub()
.returns(promise = Promise.resolve([
@@ -277,14 +270,13 @@
element.suggestOnlyWhenFocus = true;
element.text = 'bla';
assert.equal(element._focused, false);
- flushAsynchronousOperations();
- promise.then(() => {
+ flush();
+ return promise.then(() => {
assert.equal(element._suggestions.length, 0);
- done();
});
});
- test('suggestions should not carry over', done => {
+ test('suggestions should not carry over', () => {
let promise;
const queryStub = sinon.stub()
.returns(promise = Promise.resolve([
@@ -293,16 +285,15 @@
element.query = queryStub;
focusOnInput(element);
element.text = 'bla';
- flushAsynchronousOperations();
- promise.then(() => {
+ flush();
+ return promise.then(() => {
assert.equal(element._suggestions.length, 1);
element._updateSuggestions('', 0, false);
assert.equal(element._suggestions.length, 0);
- done();
});
});
- test('multi completes only the last part of the query', done => {
+ test('multi completes only the last part of the query', () => {
let promise;
const queryStub = sinon.stub()
.returns(promise = Promise.resolve([
@@ -313,7 +304,7 @@
element.text = 'blah blah';
element.multi = true;
- promise.then(() => {
+ return promise.then(() => {
const commitHandler = sinon.spy();
element.addEventListener('commit', commitHandler);
@@ -322,7 +313,6 @@
assert.isTrue(commitHandler.called);
assert.equal(element.text, 'blah 0');
- done();
});
});
@@ -349,28 +339,24 @@
assert.isTrue(element._focused);
});
- test('_focused flag properly triggered', done => {
- flush(() => {
- assert.isFalse(element._focused);
- const input = element.shadowRoot
- .querySelector('paper-input').inputElement;
- MockInteractions.focus(input);
- assert.isTrue(element._focused);
- done();
- });
+ test('_focused flag properly triggered', () => {
+ flush();
+ assert.isFalse(element._focused);
+ const input = element.shadowRoot
+ .querySelector('paper-input').inputElement;
+ MockInteractions.focus(input);
+ assert.isTrue(element._focused);
});
- test('search icon shows with showSearchIcon property', done => {
- flush(() => {
- assert.equal(getComputedStyle(element.shadowRoot
- .querySelector('iron-icon')).display,
- 'none');
- element.showSearchIcon = true;
- assert.notEqual(getComputedStyle(element.shadowRoot
- .querySelector('iron-icon')).display,
- 'none');
- done();
- });
+ test('search icon shows with showSearchIcon property', () => {
+ flush();
+ assert.equal(getComputedStyle(element.shadowRoot
+ .querySelector('iron-icon')).display,
+ 'none');
+ element.showSearchIcon = true;
+ assert.notEqual(getComputedStyle(element.shadowRoot
+ .querySelector('iron-icon')).display,
+ 'none');
});
test('vertical offset overridden by param if it exists', () => {
@@ -458,7 +444,7 @@
focusSpy = sinon.spy(element, 'focus');
MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
'enter');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(commitSpy.called);
assert.isFalse(focusSpy.called);
@@ -472,7 +458,7 @@
element.tabComplete = true;
element._suggestions = ['tunnel snakes drool'];
MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(commitSpy.called);
assert.isTrue(focusSpy.called);
@@ -484,7 +470,7 @@
element._suggestions = ['sugar bombs'];
focusSpy = sinon.spy(element, 'focus');
MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
- flushAsynchronousOperations();
+ flush();
assert.isFalse(commitSpy.called);
assert.isFalse(focusSpy.called);
@@ -503,7 +489,7 @@
MockInteractions.pressAndReleaseKeyOn(
element.$.suggestions.shadowRoot
.querySelector('li:first-child'), 9, null, 'tab');
- flushAsynchronousOperations();
+ flush();
assert.isFalse(commitSpy.called);
assert.isFalse(element._focused);
});
@@ -520,7 +506,7 @@
MockInteractions.pressAndReleaseKeyOn(
element.$.suggestions.shadowRoot
.querySelector('li:first-child'), 9, null, 'tab');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(commitSpy.called);
assert.isTrue(element._focused);
@@ -534,7 +520,7 @@
assert.isFalse(element.$.suggestions.isHidden);
MockInteractions.tap(element.$.suggestions.shadowRoot
.querySelector('li:first-child'));
- flushAsynchronousOperations();
+ flush();
assert.isFalse(focusSpy.called);
assert.isTrue(commitSpy.called);
@@ -546,7 +532,7 @@
const listener = sinon.spy();
element.addEventListener('input-keydown', listener);
MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
- flushAsynchronousOperations();
+ flush();
assert.isTrue(listener.called);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
deleted file mode 100644
index 0d30179..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ /dev/null
@@ -1,107 +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/shared-styles.js';
-import '../gr-js-api-interface/gr-js-api-interface.js';
-import '../gr-rest-api-interface/gr-rest-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-avatar_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-import {pluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
-
-/**
- * @extends PolymerElement
- */
-class GrAvatar extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-avatar'; }
-
- static get properties() {
- return {
- account: {
- type: Object,
- observer: '_accountChanged',
- },
- imageSize: {
- type: Number,
- value: 16,
- },
- _hasAvatars: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- Promise.all([
- this._getConfig(),
- pluginLoader.awaitPluginsLoaded(),
- ]).then(([cfg]) => {
- this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-
- this._updateAvatarURL();
- });
- }
-
- _getConfig() {
- return this.$.restAPI.getConfig();
- }
-
- _accountChanged(account) {
- this._updateAvatarURL();
- }
-
- _updateAvatarURL() {
- if (!this._hasAvatars || !this.account) {
- this.hidden = true;
- return;
- }
- this.hidden = false;
-
- const url = this._buildAvatarURL(this.account);
- if (url) {
- this.style.backgroundImage = 'url("' + url + '")';
- }
- }
-
- _getAccounts(account) {
- return account._account_id || account.email || account.username ||
- account.name;
- }
-
- _buildAvatarURL(account) {
- if (!account) { return ''; }
- const avatars = account.avatars || [];
- for (let i = 0; i < avatars.length; i++) {
- if (avatars[i].height === this.imageSize) {
- return avatars[i].url;
- }
- }
- return getBaseUrl() + '/accounts/' +
- encodeURIComponent(this._getAccounts(account)) +
- '/avatar?s=' + this.imageSize;
- }
-}
-
-customElements.define(GrAvatar.is, GrAvatar);
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
new file mode 100644
index 0000000..45bac9f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -0,0 +1,118 @@
+/**
+ * @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/shared-styles';
+import '../gr-js-api-interface/gr-js-api-interface';
+import '../gr-rest-api-interface/gr-rest-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-avatar_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
+import {customElement, property} from '@polymer/decorators';
+import {AccountInfo} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+export interface GrAvatar {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-avatar')
+export class GrAvatar extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object, observer: '_accountChanged'})
+ account?: AccountInfo;
+
+ @property({type: Number})
+ imageSize = 16;
+
+ @property({type: Boolean})
+ _hasAvatars = false;
+
+ /** @override */
+ attached() {
+ super.attached();
+ Promise.all([
+ this._getConfig(),
+ getPluginLoader().awaitPluginsLoaded(),
+ ]).then(([cfg]) => {
+ this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+
+ this._updateAvatarURL();
+ });
+ }
+
+ _getConfig() {
+ return this.$.restAPI.getConfig();
+ }
+
+ _accountChanged() {
+ this._updateAvatarURL();
+ }
+
+ _updateAvatarURL() {
+ if (!this._hasAvatars || !this.account) {
+ this.hidden = true;
+ return;
+ }
+ this.hidden = false;
+
+ const url = this._buildAvatarURL(this.account);
+ if (url) {
+ this.style.backgroundImage = 'url("' + url + '")';
+ }
+ }
+
+ _getAccounts(account: AccountInfo) {
+ return (
+ account._account_id || account.email || account.username || account.name
+ );
+ }
+
+ _buildAvatarURL(account: AccountInfo) {
+ if (!account) {
+ return '';
+ }
+ const avatars = account.avatars || [];
+ for (let i = 0; i < avatars.length; i++) {
+ if (avatars[i].height === this.imageSize) {
+ return avatars[i].url;
+ }
+ }
+ const accountID = this._getAccounts(account);
+ if (!accountID) {
+ return '';
+ }
+ return (
+ `${getBaseUrl()}/accounts/` +
+ encodeURIComponent(`${this._getAccounts(account)}`) +
+ `/avatar?s=${this.imageSize}`
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-avatar': GrAvatar;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
index 5e43f90..261d59c 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-avatar.js';
-import {pluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
+import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader.js';
const basicFixture = fixtureFromElement('gr-avatar');
@@ -101,11 +101,11 @@
assert.strictEqual(element.style.backgroundImage, '');
// Emulate plugins loaded.
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
return Promise.all([
element.$.restAPI.getConfig(),
- pluginLoader.awaitPluginsLoaded(),
+ getPluginLoader().awaitPluginsLoaded(),
]).then(() => {
assert.isFalse(element.hasAttribute('hidden'));
@@ -131,11 +131,11 @@
assert.isFalse(element.hasAttribute('hidden'));
// Emulate plugins loaded.
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
return Promise.all([
element.$.restAPI.getConfig(),
- pluginLoader.awaitPluginsLoaded(),
+ getPluginLoader().awaitPluginsLoaded(),
]).then(() => {
assert.isTrue(element.hasAttribute('hidden'));
@@ -164,11 +164,11 @@
_account_id: 123,
};
// Emulate plugins loaded.
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
return Promise.all([
element.$.restAPI.getConfig(),
- pluginLoader.awaitPluginsLoaded(),
+ getPluginLoader().awaitPluginsLoaded(),
]).then(() => {
assert.isTrue(element.hasAttribute('hidden'));
});
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
deleted file mode 100644
index 3039255..0000000
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ /dev/null
@@ -1,136 +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 '@polymer/paper-button/paper-button.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-button_html.js';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {getEventPath} from '../../../utils/dom-util.js';
-import {appContext} from '../../../services/app-context.js';
-
-/**
- * @extends PolymerElement
- */
-class GrButton extends KeyboardShortcutMixin(TooltipMixin(GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-button'; }
-
- static get properties() {
- return {
- tooltip: String,
- downArrow: {
- type: Boolean,
- reflectToAttribute: true,
- },
- link: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- disabled: {
- type: Boolean,
- observer: '_disabledChanged',
- reflectToAttribute: true,
- },
- noUppercase: {
- type: Boolean,
- value: false,
- },
- loading: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- ariaDisabled: {
- type: Boolean,
- computed: '_computeDisabled(disabled, loading)',
- reflectToAttribute: true,
- },
-
- _disabled: {
- type: Boolean,
- computed: '_computeDisabled(disabled, loading)',
- },
-
- _initialTabindex: {
- type: String,
- value: '0',
- },
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- created() {
- super.created();
- this._initialTabindex = this.getAttribute('tabindex') || '0';
- this.addEventListener('click', e => this._handleAction(e));
- this.addEventListener('keydown',
- e => this._handleKeydown(e));
- }
-
- /** @override */
- ready() {
- super.ready();
- this._ensureAttribute('role', 'button');
- this._ensureAttribute('tabindex', '0');
- }
-
- _handleAction(e) {
- if (this._disabled) {
- e.preventDefault();
- e.stopPropagation();
- e.stopImmediatePropagation();
- return;
- }
-
- this.reporting.reportInteraction('button-click',
- {path: getEventPath(e)});
- }
-
- _disabledChanged(disabled) {
- this.setAttribute('tabindex', disabled ? '-1' : this._initialTabindex);
- this.updateStyles();
- }
-
- _computeDisabled(disabled, loading) {
- return disabled || loading;
- }
-
- _handleKeydown(e) {
- if (this.modifierPressed(e)) { return; }
- e = this.getKeyboardEvent(e);
- // Handle `enter`, `space`.
- if (e.keyCode === 13 || e.keyCode === 32) {
- e.preventDefault();
- e.stopPropagation();
- this.click();
- }
- }
-}
-
-customElements.define(GrButton.is, GrButton);
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
new file mode 100644
index 0000000..7a6ce2c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -0,0 +1,138 @@
+/**
+ * @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 '@polymer/paper-button/paper-button';
+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 {customElement, property, computed, observe} from '@polymer/decorators';
+import {htmlTemplate} from './gr-button_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {PolymerEvent, getEventPath} from '../../../utils/dom-util';
+import {appContext} from '../../../services/app-context';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-button': GrButton;
+ }
+}
+
+@customElement('gr-button')
+export class GrButton extends LegacyElementMixin(
+ KeyboardShortcutMixin(TooltipMixin(GestureEventListeners(PolymerElement)))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean, reflectToAttribute: true})
+ downArrow = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ link = false;
+
+ @property({type: Boolean})
+ noUppercase = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ loading = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ disabled: boolean | null = null;
+
+ @property({type: String})
+ tooltip = '';
+
+ // Note: don't assign a value to this, since constructor is called
+ // after created, the initial value maybe overriden by this
+ @property({type: String})
+ _initialTabindex?: string;
+
+ @computed('disabled', 'loading')
+ get _disabled() {
+ return this.disabled || this.loading;
+ }
+
+ @property({
+ computed: 'computeAriaDisabled(disabled, loading)',
+ reflectToAttribute: true,
+ type: Boolean,
+ })
+ ariaDisabled!: boolean;
+
+ computeAriaDisabled() {
+ return this._disabled;
+ }
+
+ private readonly reporting: ReportingService = appContext.reportingService;
+
+ /** @override */
+ created() {
+ super.created();
+ this._initialTabindex = this.getAttribute('tabindex') || '0';
+ // TODO(TS): try avoid using unknown
+ this.addEventListener('click', e =>
+ this._handleAction((e as unknown) as PolymerEvent)
+ );
+ this.addEventListener('keydown', e =>
+ this._handleKeydown((e as unknown) as CustomKeyboardEvent)
+ );
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._ensureAttribute('role', 'button');
+ this._ensureAttribute('tabindex', '0');
+ }
+
+ _handleAction(e: PolymerEvent) {
+ if (this._disabled) {
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ return;
+ }
+
+ this.reporting.reportInteraction('button-click', {path: getEventPath(e)});
+ }
+
+ @observe('disabled')
+ _disabledChanged(disabled: boolean) {
+ this.setAttribute(
+ 'tabindex',
+ disabled ? '-1' : this._initialTabindex || '0'
+ );
+ this.updateStyles();
+ }
+
+ _handleKeydown(e: CustomKeyboardEvent) {
+ if (this.modifierPressed(e)) {
+ return;
+ }
+ e = this.getKeyboardEvent(e);
+ // Handle `enter`, `space`.
+ if (e.keyCode === 13 || e.keyCode === 32) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.click();
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
index 4bc3bea..242cb28 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.js
@@ -59,7 +59,7 @@
assert.isFalse(paperBtn.disabled);
});
- test('loading set from listener', done => {
+ test('loading set from listener', () => {
let resolve;
element.addEventListener('click', e => {
e.target.loading = true;
@@ -71,11 +71,9 @@
assert.isTrue(paperBtn.disabled);
assert.isTrue(element.hasAttribute('loading'));
resolve();
- flush(() => {
- assert.isFalse(paperBtn.disabled);
- assert.isFalse(element.hasAttribute('loading'));
- done();
- });
+ flush();
+ assert.isFalse(paperBtn.disabled);
+ assert.isFalse(element.hasAttribute('loading'));
});
test('tabindex should be -1 if disabled', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
deleted file mode 100644
index dac1755..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ /dev/null
@@ -1,71 +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 '../gr-icons/gr-icons.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-change-star_html.js';
-
-/** @extends PolymerElement */
-class GrChangeStar extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-star'; }
- /**
- * Fired when star state is toggled.
- *
- * @event toggle-star
- */
-
- static get properties() {
- return {
- /** @type {?} */
- change: {
- type: Object,
- notify: true,
- },
- };
- }
-
- _computeStarClass(starred) {
- return starred ? 'active' : '';
- }
-
- _computeStarIcon(starred) {
- // Hollow star is used to indicate inactive state.
- return `gr-icons:star${starred ? '' : '-border'}`;
- }
-
- _computeAriaLabel(starred) {
- return starred ? 'Unstar this change' : 'Star this change';
- }
-
- toggleStar() {
- const newVal = !this.change.starred;
- this.set('change.starred', newVal);
- this.dispatchEvent(new CustomEvent('toggle-star', {
- bubbles: true,
- composed: true,
- detail: {change: this.change, starred: newVal},
- }));
- }
-}
-
-customElements.define(GrChangeStar.is, GrChangeStar);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
new file mode 100644
index 0000000..1ecaf7f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -0,0 +1,89 @@
+/**
+ * @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 '../gr-icons/gr-icons';
+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-change-star_html';
+import {customElement, property} from '@polymer/decorators';
+import {ChangeInfo} from '../../../types/common';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-star': GrChangeStar;
+ }
+}
+
+export interface ChangeStarToggleStarDetail {
+ change: ChangeInfo;
+ starred: boolean;
+}
+
+@customElement('gr-change-star')
+export class GrChangeStar extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when star state is toggled.
+ *
+ * @event toggle-star
+ */
+
+ @property({type: Object, notify: true})
+ change?: ChangeInfo;
+
+ _computeStarClass(starred: boolean) {
+ return starred ? 'active' : '';
+ }
+
+ _computeStarIcon(starred: boolean) {
+ // Hollow star is used to indicate inactive state.
+ return `gr-icons:star${starred ? '' : '-border'}`;
+ }
+
+ _computeAriaLabel(starred: boolean) {
+ return starred ? 'Unstar this change' : 'Star this change';
+ }
+
+ toggleStar() {
+ // Note: change should always be defined when use gr-change-star
+ // but since we don't have a good way to enforce usage to always
+ // set the change, we still check it here.
+ if (!this.change) {
+ return;
+ }
+ const newVal = !this.change.starred;
+ this.set('change.starred', newVal);
+ const detail: ChangeStarToggleStarDetail = {
+ change: this.change,
+ starred: newVal,
+ };
+ this.dispatchEvent(
+ new CustomEvent('toggle-star', {
+ bubbles: true,
+ composed: true,
+ detail,
+ })
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
index c7930c0..6c0f6f7 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
@@ -39,7 +39,9 @@
</style>
<button
role="checkbox"
- aria-label="[[_computeAriaLabel(change.starred)]]]"
+ title="[[createTitle(Shortcut.TOGGLE_CHANGE_STAR,
+ ShortcutSection.ACTIONS)]]"
+ aria-label="[[_computeAriaLabel(change.starred)]]"
on-click="toggleStar"
>
<iron-icon
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
index d479ece..f05ed21 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.js
@@ -45,24 +45,28 @@
assert.equal(icon.icon, 'gr-icons:star-border');
});
- test('starring', done => {
- element.addEventListener('toggle-star', () => {
- assert.equal(element.change.starred, true);
- done();
- });
+ test('starring', async () => {
+ let resolve;
+ const promise = new Promise(r => resolve = r);
+ element.addEventListener('toggle-star', resolve);
element.set('change.starred', false);
MockInteractions.tap(element.shadowRoot
.querySelector('button'));
+
+ await promise;
+ assert.equal(element.change.starred, true);
});
- test('unstarring', done => {
- element.addEventListener('toggle-star', () => {
- assert.equal(element.change.starred, false);
- done();
- });
+ test('unstarring', async () => {
+ let resolve;
+ const promise = new Promise(r => resolve = r);
+ element.addEventListener('toggle-star', resolve);
element.set('change.starred', true);
MockInteractions.tap(element.shadowRoot
.querySelector('button'));
+
+ await promise;
+ assert.equal(element.change.starred, false);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
deleted file mode 100644
index 915d171..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.js
+++ /dev/null
@@ -1,104 +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 '../gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-tooltip-content/gr-tooltip-content.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-change-status_html.js';
-
-const ChangeStates = {
- MERGED: 'Merged',
- ABANDONED: 'Abandoned',
- MERGE_CONFLICT: 'Merge Conflict',
- WIP: 'WIP',
- PRIVATE: 'Private',
-};
-
-const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
- 'It will not appear on dashboards unless you are CC\'ed or assigned, ' +
- 'and email notifications will be silenced until the review is started.';
-
-const MERGE_CONFLICT_TOOLTIP = 'This change has merge conflicts. ' +
- 'Download the patch and run "git rebase master". ' +
- 'Upload a new patchset after resolving all merge conflicts.';
-
-const PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
- 'current reviewers (or anyone with "View Private Changes" permission).';
-
-/** @extends PolymerElement */
-class GrChangeStatus extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-change-status'; }
-
- static get properties() {
- return {
- flat: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- status: {
- type: String,
- observer: '_updateChipDetails',
- },
- tooltipText: {
- type: String,
- value: '',
- },
- };
- }
-
- _computeStatusString(status) {
- if (status === ChangeStates.WIP && !this.flat) {
- return 'Work in Progress';
- }
- return status;
- }
-
- _toClassName(str) {
- return str.toLowerCase().replace(/\s/g, '-');
- }
-
- _updateChipDetails(status, previousStatus) {
- if (previousStatus) {
- this.classList.remove(this._toClassName(previousStatus));
- }
- this.classList.add(this._toClassName(status));
-
- switch (status) {
- case ChangeStates.WIP:
- this.tooltipText = WIP_TOOLTIP;
- break;
- case ChangeStates.PRIVATE:
- this.tooltipText = PRIVATE_TOOLTIP;
- break;
- case ChangeStates.MERGE_CONFLICT:
- this.tooltipText = MERGE_CONFLICT_TOOLTIP;
- break;
- default:
- this.tooltipText = '';
- break;
- }
- }
-}
-
-customElements.define(GrChangeStatus.is, GrChangeStatus);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
new file mode 100644
index 0000000..44ec4f5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -0,0 +1,104 @@
+/**
+ * @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 '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-tooltip-content/gr-tooltip-content';
+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-change-status_html';
+import {customElement, property} from '@polymer/decorators';
+
+enum ChangeStates {
+ MERGED = 'Merged',
+ ABANDONED = 'Abandoned',
+ MERGE_CONFLICT = 'Merge Conflict',
+ WIP = 'WIP',
+ PRIVATE = 'Private',
+}
+
+const WIP_TOOLTIP =
+ "This change isn't ready to be reviewed or submitted. " +
+ "It will not appear on dashboards unless you are CC'ed or assigned, " +
+ 'and email notifications will be silenced until the review is started.';
+
+const MERGE_CONFLICT_TOOLTIP =
+ 'This change has merge conflicts. ' +
+ 'Download the patch and run "git rebase master". ' +
+ 'Upload a new patchset after resolving all merge conflicts.';
+
+const PRIVATE_TOOLTIP =
+ 'This change is only visible to its owner and ' +
+ 'current reviewers (or anyone with "View Private Changes" permission).';
+
+/** @extends PolymerElement */
+@customElement('gr-change-status')
+class GrChangeStatus extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean, reflectToAttribute: true})
+ flat = false;
+
+ @property({type: String, observer: '_updateChipDetails'})
+ status?: ChangeStates;
+
+ @property({type: String})
+ tooltipText = '';
+
+ _computeStatusString(status: ChangeStates) {
+ if (status === ChangeStates.WIP && !this.flat) {
+ return 'Work in Progress';
+ }
+ return status;
+ }
+
+ _toClassName(str?: ChangeStates) {
+ return str ? str.toLowerCase().replace(/\s/g, '-') : '';
+ }
+
+ _updateChipDetails(status?: ChangeStates, previousStatus?: ChangeStates) {
+ if (previousStatus) {
+ this.classList.remove(this._toClassName(previousStatus));
+ }
+ this.classList.add(this._toClassName(status));
+
+ switch (status) {
+ case ChangeStates.WIP:
+ this.tooltipText = WIP_TOOLTIP;
+ break;
+ case ChangeStates.PRIVATE:
+ this.tooltipText = PRIVATE_TOOLTIP;
+ break;
+ case ChangeStates.MERGE_CONFLICT:
+ this.tooltipText = MERGE_CONFLICT_TOOLTIP;
+ break;
+ default:
+ this.tooltipText = '';
+ break;
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-change-status': GrChangeStatus;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
deleted file mode 100644
index e6f4a01..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.js
+++ /dev/null
@@ -1,590 +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/shared-styles.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-storage/gr-storage.js';
-import '../gr-comment/gr-comment.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-comment-thread_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {parseDate} from '../../../utils/date-util.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {appContext} from '../../../services/app-context.js';
-import {SpecialFilePath} from '../../../constants/constants.js';
-import {computeDisplayPath} from '../../../utils/path-list-util.js';
-
-const UNRESOLVED_EXPAND_COUNT = 5;
-const NEWLINE_PATTERN = /\n/g;
-
-/**
- * @extends PolymerElement
- */
-class GrCommentThread extends KeyboardShortcutMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- // KeyboardShortcutMixin Not used in this element rather other elements tests
-
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-comment-thread'; }
- /**
- * Fired when the thread should be discarded.
- *
- * @event thread-discard
- */
-
- /**
- * Fired when a comment in the thread is permanently modified.
- *
- * @event thread-changed
- */
-
- /**
- * gr-comment-thread exposes the following attributes that allow a
- * diff widget like gr-diff to show the thread in the right location:
- *
- * line-num:
- * 1-based line number or undefined if it refers to the entire file.
- *
- * comment-side:
- * "left" or "right". These indicate which of the two diffed versions
- * the comment relates to. In the case of unified diff, the left
- * version is the one whose line number column is further to the left.
- *
- * range:
- * The range of text that the comment refers to (start_line,
- * start_character, end_line, end_character), serialized as JSON. If
- * set, range's end_line will have the same value as line-num. Line
- * numbers are 1-based, char numbers are 0-based. The start position
- * (start_line, start_character) is inclusive, and the end position
- * (end_line, end_character) is exclusive.
- */
- static get properties() {
- return {
- changeNum: String,
- comments: {
- type: Array,
- value() { return []; },
- },
- /**
- * @type {?{start_line: number, start_character: number, end_line: number,
- * end_character: number}}
- */
- range: {
- type: Object,
- reflectToAttribute: true,
- },
- keyEventTarget: {
- type: Object,
- value() { return document.body; },
- },
- commentSide: {
- type: String,
- reflectToAttribute: true,
- },
- patchNum: String,
- path: String,
- projectName: {
- type: String,
- observer: '_projectNameChanged',
- },
- hasDraft: {
- type: Boolean,
- notify: true,
- reflectToAttribute: true,
- },
- isOnParent: {
- type: Boolean,
- value: false,
- },
- parentIndex: {
- type: Number,
- value: null,
- },
- rootId: {
- type: String,
- notify: true,
- computed: '_computeRootId(comments.*)',
- },
- /**
- * If this is true, the comment thread also needs to have the change and
- * line properties property set
- */
- showFilePath: {
- type: Boolean,
- value: false,
- },
- /** Necessary only if showFilePath is true or when used with gr-diff */
- lineNum: {
- type: Number,
- reflectToAttribute: true,
- },
- unresolved: {
- type: Boolean,
- notify: true,
- reflectToAttribute: true,
- },
- _showActions: Boolean,
- _lastComment: Object,
- _orderedComments: Array,
- _projectConfig: Object,
- isRobotComment: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- showFileName: {
- type: Boolean,
- value: true,
- },
- showPatchset: {
- type: Boolean,
- value: true,
- },
- };
- }
-
- static get observers() {
- return [
- '_commentsChanged(comments.*)',
- ];
- }
-
- get keyBindings() {
- return {
- 'e shift+e': '_handleEKey',
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('comment-update',
- e => this._handleCommentUpdate(e));
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getLoggedIn().then(loggedIn => {
- this._showActions = loggedIn;
- });
- this._setInitialExpandedState();
- }
-
- addOrEditDraft(opt_lineNum, opt_range) {
- const lastComment = this.comments[this.comments.length - 1] || {};
- if (lastComment.__draft) {
- const commentEl = this._commentElWithDraftID(
- lastComment.id || lastComment.__draftID);
- commentEl.editing = true;
-
- // If the comment was collapsed, re-open it to make it clear which
- // actions are available.
- commentEl.collapsed = false;
- } else {
- const range = opt_range ? opt_range :
- lastComment ? lastComment.range : undefined;
- const unresolved = lastComment ? lastComment.unresolved : undefined;
- this.addDraft(opt_lineNum, range, unresolved);
- }
- }
-
- addDraft(opt_lineNum, opt_range, opt_unresolved) {
- const draft = this._newDraft(opt_lineNum, opt_range);
- draft.__editing = true;
- draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
- this.push('comments', draft);
- }
-
- fireRemoveSelf() {
- this.dispatchEvent(new CustomEvent('thread-discard',
- {detail: {rootId: this.rootId}, bubbles: false}));
- }
-
- _getDiffUrlForPath(path) {
- return GerritNav.getUrlForDiffById(this.changeNum, this.projectName, path,
- this.patchNum);
- }
-
- _getDiffUrlForComment(projectName, changeNum, path, patchNum) {
- return GerritNav.getUrlForDiffById(changeNum,
- projectName, path, patchNum,
- null, this.lineNum);
- }
-
- _isPatchsetLevelComment(path) {
- return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
- }
-
- _computeDisplayPath(path) {
- const displayPath = computeDisplayPath(path);
- if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return `Patchset`;
- }
- return displayPath;
- }
-
- _computeDisplayLine() {
- if (this.lineNum) return `#${this.lineNum}`;
- // If range is set, then lineNum equals the end line of the range.
- if (!this.lineNum && !this.range) {
- if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
- return '';
- }
- return 'FILE';
- }
- if (this.range) return `#${this.range.end_line}`;
- return '';
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _commentsChanged() {
- this._orderedComments = this._sortedComments(this.comments);
- this.updateThreadProperties();
- }
-
- updateThreadProperties() {
- if (this._orderedComments.length) {
- this._lastComment = this._getLastComment();
- this.unresolved = this._lastComment.unresolved;
- this.hasDraft = this._lastComment.__draft;
- this.isRobotComment = !!(this._lastComment.robot_id);
- }
- }
-
- _shouldDisableAction(_showActions, _lastComment) {
- return !_showActions || !_lastComment || !!_lastComment.__draft;
- }
-
- _hideActions(_showActions, _lastComment) {
- return this._shouldDisableAction(_showActions, _lastComment) ||
- !!_lastComment.robot_id;
- }
-
- _getLastComment() {
- return this._orderedComments[this._orderedComments.length - 1] || {};
- }
-
- _handleEKey(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-
- // Don’t preventDefault in this case because it will render the event
- // useless for other handlers (other gr-comment-thread elements).
- if (e.detail.keyboardEvent.shiftKey) {
- this._expandCollapseComments(true);
- } else {
- if (this.modifierPressed(e)) { return; }
- this._expandCollapseComments(false);
- }
- }
-
- _expandCollapseComments(actionIsCollapse) {
- const comments =
- dom(this.root).querySelectorAll('gr-comment');
- for (const comment of comments) {
- comment.collapsed = actionIsCollapse;
- }
- }
-
- /**
- * Sets the initial state of the comment thread.
- * Expands the thread if one of the following is true:
- * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
- * thread is unresolved,
- * - it's a robot comment.
- */
- _setInitialExpandedState() {
- if (this._orderedComments) {
- for (let i = 0; i < this._orderedComments.length; i++) {
- const comment = this._orderedComments[i];
- const isRobotComment = !!comment.robot_id;
- // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
- const resolvedThread = !this.unresolved ||
- this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
- if (comment.collapsed === undefined) {
- comment.collapsed = !isRobotComment && resolvedThread;
- }
- }
- }
- }
-
- _sortedComments(comments) {
- return comments.slice().sort((c1, c2) => {
- const c1Date = c1.__date || parseDate(c1.updated);
- const c2Date = c2.__date || parseDate(c2.updated);
- const dateCompare = c1Date - c2Date;
- // Ensure drafts are at the end. There should only be one but in edge
- // cases could be more. In the unlikely event two drafts are being
- // compared, use the typical date compare.
- if (c2.__draft && !c1.__draft ) { return -1; }
- if (c1.__draft && !c2.__draft ) { return 1; }
- if (dateCompare === 0 && (!c1.id || !c1.id.localeCompare)) { return 0; }
- // If same date, fall back to sorting by id.
- return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
- });
- }
-
- _createReplyComment(content, opt_isEditing,
- opt_unresolved) {
- this.reporting.recordDraftInteraction();
- const reply = this._newReply(
- this._orderedComments[this._orderedComments.length - 1].id,
- content,
- opt_unresolved);
-
- // If there is currently a comment in an editing state, add an attribute
- // so that the gr-comment knows not to populate the draft text.
- for (let i = 0; i < this.comments.length; i++) {
- if (this.comments[i].__editing) {
- reply.__otherEditing = true;
- break;
- }
- }
-
- if (opt_isEditing) {
- reply.__editing = true;
- }
-
- this.push('comments', reply);
-
- if (!opt_isEditing) {
- // Allow the reply to render in the dom-repeat.
- this.async(() => {
- const commentEl = this._commentElWithDraftID(reply.__draftID);
- commentEl.save();
- }, 1);
- }
- }
-
- _isDraft(comment) {
- return !!comment.__draft;
- }
-
- /**
- * @param {boolean=} opt_quote
- */
- _processCommentReply(opt_quote) {
- const comment = this._lastComment;
- let quoteStr;
- if (opt_quote) {
- const msg = comment.message;
- quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
- }
- this._createReplyComment(quoteStr, true, comment.unresolved);
- }
-
- _handleCommentReply() {
- this._processCommentReply();
- }
-
- _handleCommentQuote() {
- this._processCommentReply(true);
- }
-
- _handleCommentAck() {
- this._createReplyComment('Ack', false, false);
- }
-
- _handleCommentDone() {
- this._createReplyComment('Done', false, false);
- }
-
- _handleCommentFix(e) {
- const comment = e.detail.comment;
- const msg = comment.message;
- const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
- const response = quoteStr + 'Please fix.';
- this._createReplyComment(response, false, true);
- }
-
- _commentElWithDraftID(id) {
- const els = dom(this.root).querySelectorAll('gr-comment');
- for (const el of els) {
- if (el.comment.id === id || el.comment.__draftID === id) {
- return el;
- }
- }
- return null;
- }
-
- _newReply(inReplyTo, opt_message, opt_unresolved) {
- const d = this._newDraft();
- d.in_reply_to = inReplyTo;
- if (opt_message != null) {
- d.message = opt_message;
- }
- if (opt_unresolved !== undefined) {
- d.unresolved = opt_unresolved;
- }
- return d;
- }
-
- /**
- * @param {number=} opt_lineNum
- * @param {!Object=} opt_range
- */
- _newDraft(opt_lineNum, opt_range) {
- const d = {
- __draft: true,
- __draftID: Math.random().toString(36),
- __date: new Date(),
- };
-
- // For replies, always use same meta info as root.
- if (this.comments && this.comments.length >= 1) {
- const rootComment = this.comments[0];
- [
- 'path',
- 'patchNum',
- 'side',
- '__commentSide',
- 'line',
- 'range',
- 'parent',
- ].forEach(key => {
- if (rootComment.hasOwnProperty(key)) {
- d[key] = rootComment[key];
- }
- });
- } else {
- // Set meta info for root comment.
- d.path = this.path;
- d.patchNum = this.patchNum;
- d.side = this._getSide(this.isOnParent);
- d.__commentSide = this.commentSide;
-
- if (opt_lineNum) {
- d.line = opt_lineNum;
- }
- if (opt_range) {
- d.range = opt_range;
- }
- if (this.parentIndex) {
- d.parent = this.parentIndex;
- }
- }
- return d;
- }
-
- _getSide(isOnParent) {
- if (isOnParent) { return 'PARENT'; }
- return 'REVISION';
- }
-
- _computeRootId(comments) {
- // Keep the root ID even if the comment was removed, so that notification
- // to sync will know which thread to remove.
- if (!comments.base.length) { return this.rootId; }
- const rootComment = comments.base[0];
- return rootComment.id || rootComment.__draftID;
- }
-
- _handleCommentDiscard(e) {
- const diffCommentEl = dom(e).rootTarget;
- const comment = diffCommentEl.comment;
- const idx = this._indexOf(comment, this.comments);
- if (idx == -1) {
- throw Error('Cannot find comment ' +
- JSON.stringify(diffCommentEl.comment));
- }
- this.splice('comments', idx, 1);
- if (this.comments.length === 0) {
- this.fireRemoveSelf();
- }
- this._handleCommentSavedOrDiscarded(e);
-
- // Check to see if there are any other open comments getting edited and
- // set the local storage value to its message value.
- for (const changeComment of this.comments) {
- if (changeComment.__editing) {
- const commentLocation = {
- changeNum: this.changeNum,
- patchNum: this.patchNum,
- path: changeComment.path,
- line: changeComment.line,
- };
- return this.$.storage.setDraftComment(commentLocation,
- changeComment.message);
- }
- }
- }
-
- _handleCommentSavedOrDiscarded(e) {
- this.dispatchEvent(new CustomEvent('thread-changed',
- {detail: {rootId: this.rootId, path: this.path},
- bubbles: false}));
- }
-
- _handleCommentUpdate(e) {
- const comment = e.detail.comment;
- const index = this._indexOf(comment, this.comments);
- if (index === -1) {
- // This should never happen: comment belongs to another thread.
- console.warn('Comment update for another comment thread.');
- return;
- }
- this.set(['comments', index], comment);
- // Because of the way we pass these comment objects around by-ref, in
- // combination with the fact that Polymer does dirty checking in
- // observers, the this.set() call above will not cause a thread update in
- // some situations.
- this.updateThreadProperties();
- }
-
- _indexOf(comment, arr) {
- for (let i = 0; i < arr.length; i++) {
- const c = arr[i];
- if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
- (c.id != null && c.id == comment.id)) {
- return i;
- }
- }
- return -1;
- }
-
- _computeHostClass(unresolved) {
- if (this.isRobotComment) {
- return 'robotComment';
- }
- return unresolved ? 'unresolved' : '';
- }
-
- /**
- * Load the project config when a project name has been provided.
- *
- * @param {string} name The project name.
- */
- _projectNameChanged(name) {
- if (!name) { return; }
- this.$.restAPI.getProjectConfig(name).then(config => {
- this._projectConfig = config;
- });
- }
-}
-
-customElements.define(GrCommentThread.is, GrCommentThread);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
new file mode 100644
index 0000000..878c01c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread.ts
@@ -0,0 +1,648 @@
+/**
+ * @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/shared-styles';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-storage/gr-storage';
+import '../gr-comment/gr-comment';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-comment-thread_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {
+ isDraft,
+ isRobot,
+ sortComments,
+ UIComment,
+ UIDraft,
+ UIRobot,
+} from '../../../utils/comment-util';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {appContext} from '../../../services/app-context';
+import {CommentSide, Side, SpecialFilePath} from '../../../constants/constants';
+import {computeDisplayPath} from '../../../utils/path-list-util';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ CommentRange,
+ ConfigInfo,
+ NumericChangeId,
+ PatchSetNum,
+ RepoName,
+ UrlEncodedCommentId,
+} from '../../../types/common';
+import {GrComment} from '../gr-comment/gr-comment';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+const UNRESOLVED_EXPAND_COUNT = 5;
+const NEWLINE_PATTERN = /\n/g;
+
+export interface GrCommentThread {
+ $: {
+ restAPI: RestApiService & Element;
+ storage: GrStorage;
+ };
+}
+
+@customElement('gr-comment-thread')
+export class GrCommentThread extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ // KeyboardShortcutMixin Not used in this element rather other elements tests
+
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the thread should be discarded.
+ *
+ * @event thread-discard
+ */
+
+ /**
+ * Fired when a comment in the thread is permanently modified.
+ *
+ * @event thread-changed
+ */
+
+ /**
+ * gr-comment-thread exposes the following attributes that allow a
+ * diff widget like gr-diff to show the thread in the right location:
+ *
+ * line-num:
+ * 1-based line number or undefined if it refers to the entire file.
+ *
+ * comment-side:
+ * "left" or "right". These indicate which of the two diffed versions
+ * the comment relates to. In the case of unified diff, the left
+ * version is the one whose line number column is further to the left.
+ *
+ * range:
+ * The range of text that the comment refers to (start_line,
+ * start_character, end_line, end_character), serialized as JSON. If
+ * set, range's end_line will have the same value as line-num. Line
+ * numbers are 1-based, char numbers are 0-based. The start position
+ * (start_line, start_character) is inclusive, and the end position
+ * (end_line, end_character) is exclusive.
+ */
+ @property({type: Number})
+ changeNum?: NumericChangeId;
+
+ @property({type: Array})
+ comments: UIComment[] = [];
+
+ @property({type: Object, reflectToAttribute: true})
+ range?: CommentRange;
+
+ @property({type: Object})
+ keyEventTarget: HTMLElement = document.body;
+
+ @property({type: String, reflectToAttribute: true})
+ commentSide?: Side;
+
+ @property({type: String})
+ patchNum?: PatchSetNum;
+
+ @property({type: String})
+ path?: string;
+
+ @property({type: String, observer: '_projectNameChanged'})
+ projectName?: RepoName;
+
+ @property({type: Boolean, notify: true, reflectToAttribute: true})
+ hasDraft?: boolean;
+
+ @property({type: Boolean})
+ isOnParent = false;
+
+ @property({type: Number})
+ parentIndex: number | null = null;
+
+ @property({
+ type: String,
+ notify: true,
+ computed: '_computeRootId(comments.*)',
+ })
+ rootId?: UrlEncodedCommentId;
+
+ @property({type: Boolean})
+ showFilePath = false;
+
+ @property({type: Number, reflectToAttribute: true})
+ lineNum?: number;
+
+ @property({type: Boolean, notify: true, reflectToAttribute: true})
+ unresolved?: boolean;
+
+ @property({type: Boolean})
+ _showActions?: boolean;
+
+ @property({type: Object})
+ _lastComment?: UIComment;
+
+ @property({type: Array})
+ _orderedComments: UIComment[] = [];
+
+ @property({type: Object})
+ _projectConfig?: ConfigInfo;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ isRobotComment = false;
+
+ @property({type: Boolean})
+ showFileName = true;
+
+ @property({type: Boolean})
+ showPatchset = true;
+
+ get keyBindings() {
+ return {
+ 'e shift+e': '_handleEKey',
+ };
+ }
+
+ reporting = appContext.reportingService;
+
+ flagsService = appContext.flagsService;
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('comment-update', e =>
+ this._handleCommentUpdate(e as CustomEvent)
+ );
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getLoggedIn().then(loggedIn => {
+ this._showActions = loggedIn;
+ });
+ this._setInitialExpandedState();
+ }
+
+ addOrEditDraft(lineNum?: number, rangeParam?: CommentRange) {
+ const lastComment = this.comments[this.comments.length - 1] || {};
+ if (isDraft(lastComment)) {
+ const commentEl = this._commentElWithDraftID(
+ lastComment.id || lastComment.__draftID
+ );
+ if (!commentEl) throw new Error('Failed to find draft.');
+ commentEl.editing = true;
+
+ // If the comment was collapsed, re-open it to make it clear which
+ // actions are available.
+ commentEl.collapsed = false;
+ } else {
+ const range = rangeParam
+ ? rangeParam
+ : lastComment
+ ? lastComment.range
+ : undefined;
+ const unresolved = lastComment ? lastComment.unresolved : undefined;
+ this.addDraft(lineNum, range, unresolved);
+ }
+ }
+
+ addDraft(lineNum?: number, range?: CommentRange, unresolved?: boolean) {
+ const draft = this._newDraft(lineNum, range);
+ draft.__editing = true;
+ draft.unresolved = unresolved === false ? unresolved : true;
+ this.push('comments', draft);
+ }
+
+ fireRemoveSelf() {
+ this.dispatchEvent(
+ new CustomEvent('thread-discard', {
+ detail: {rootId: this.rootId},
+ bubbles: false,
+ })
+ );
+ }
+
+ _getDiffUrlForPath(path: string) {
+ if (!this.changeNum) throw new Error('changeNum is missing');
+ if (!this.projectName) throw new Error('projectName is missing');
+ if (isDraft(this.comments[0])) {
+ return GerritNav.getUrlForDiffById(
+ this.changeNum,
+ this.projectName,
+ path,
+ this.patchNum
+ );
+ }
+ const id = this.comments[0].id;
+ if (!id) throw new Error('A published comment is missing the id.');
+ return GerritNav.getUrlForComment(this.changeNum, this.projectName, id);
+ }
+
+ _getDiffUrlForComment(
+ projectName?: RepoName,
+ changeNum?: NumericChangeId,
+ path?: string,
+ patchNum?: PatchSetNum
+ ) {
+ if (!projectName || !changeNum || !path) return undefined;
+ if (
+ (this.comments.length && this.comments[0].side === 'PARENT') ||
+ isDraft(this.comments[0])
+ ) {
+ return GerritNav.getUrlForDiffById(
+ changeNum,
+ projectName,
+ path,
+ patchNum,
+ undefined,
+ this.lineNum
+ );
+ }
+ const id = this.comments[0].id;
+ if (!id) throw new Error('A published comment is missing the id.');
+ return GerritNav.getUrlForComment(changeNum, projectName, id);
+ }
+
+ _isPatchsetLevelComment(path: string) {
+ return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+ }
+
+ _computeDisplayPath(path: string) {
+ const displayPath = computeDisplayPath(path);
+ if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+ return 'Patchset';
+ }
+ return displayPath;
+ }
+
+ _computeDisplayLine() {
+ if (this.lineNum) return `#${this.lineNum}`;
+ // If range is set, then lineNum equals the end line of the range.
+ if (!this.lineNum && !this.range) {
+ if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
+ return '';
+ }
+ return 'FILE';
+ }
+ if (this.range) return `#${this.range.end_line}`;
+ return '';
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ @observe('comments.*')
+ _commentsChanged() {
+ this._orderedComments = sortComments(this.comments);
+ this.updateThreadProperties();
+ }
+
+ updateThreadProperties() {
+ if (this._orderedComments.length) {
+ this._lastComment = this._getLastComment();
+ this.unresolved = this._lastComment.unresolved;
+ this.hasDraft = isDraft(this._lastComment);
+ this.isRobotComment = isRobot(this._lastComment);
+ }
+ }
+
+ _shouldDisableAction(_showActions?: boolean, _lastComment?: UIComment) {
+ return !_showActions || !_lastComment || isDraft(_lastComment);
+ }
+
+ _hideActions(_showActions?: boolean, _lastComment?: UIComment) {
+ return (
+ this._shouldDisableAction(_showActions, _lastComment) ||
+ isRobot(_lastComment)
+ );
+ }
+
+ _getLastComment() {
+ return this._orderedComments[this._orderedComments.length - 1] || {};
+ }
+
+ _handleEKey(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) {
+ return;
+ }
+
+ // Don’t preventDefault in this case because it will render the event
+ // useless for other handlers (other gr-comment-thread elements).
+ if (e.detail.keyboardEvent?.shiftKey) {
+ this._expandCollapseComments(true);
+ } else {
+ if (this.modifierPressed(e)) {
+ return;
+ }
+ this._expandCollapseComments(false);
+ }
+ }
+
+ _expandCollapseComments(actionIsCollapse: boolean) {
+ const comments = this.root?.querySelectorAll('gr-comment');
+ if (!comments) return;
+ for (const comment of comments) {
+ comment.collapsed = actionIsCollapse;
+ }
+ }
+
+ /**
+ * Sets the initial state of the comment thread.
+ * Expands the thread if one of the following is true:
+ * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
+ * thread is unresolved,
+ * - it's a robot comment.
+ */
+ _setInitialExpandedState() {
+ if (this._orderedComments) {
+ for (let i = 0; i < this._orderedComments.length; i++) {
+ const comment = this._orderedComments[i];
+ const isRobotComment = !!(comment as UIRobot).robot_id;
+ // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
+ const resolvedThread =
+ !this.unresolved ||
+ this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT;
+ if (comment.collapsed === undefined) {
+ comment.collapsed = !isRobotComment && resolvedThread;
+ }
+ }
+ }
+ }
+
+ _createReplyComment(
+ content?: string,
+ isEditing?: boolean,
+ unresolved?: boolean
+ ) {
+ this.reporting.recordDraftInteraction();
+ const id = this._orderedComments[this._orderedComments.length - 1].id;
+ if (!id) throw new Error('Cannot reply to comment without id.');
+ const reply = this._newReply(id, content, unresolved);
+
+ // If there is currently a comment in an editing state, add an attribute
+ // so that the gr-comment knows not to populate the draft text.
+ for (let i = 0; i < this.comments.length; i++) {
+ if (this.comments[i].__editing) {
+ reply.__otherEditing = true;
+ break;
+ }
+ }
+
+ if (isEditing) {
+ reply.__editing = true;
+ }
+
+ this.push('comments', reply);
+
+ if (!isEditing) {
+ // Allow the reply to render in the dom-repeat.
+ this.async(() => {
+ const commentEl = this._commentElWithDraftID(reply.__draftID);
+ if (commentEl) commentEl.save();
+ }, 1);
+ }
+ }
+
+ _isDraft(comment: UIComment) {
+ return isDraft(comment);
+ }
+
+ _processCommentReply(quote?: boolean) {
+ const comment = this._lastComment;
+ if (!comment) throw new Error('Failed to find last comment.');
+ let content = undefined;
+ if (quote) {
+ const msg = comment.message;
+ if (!msg) throw new Error('Quoting empty comment.');
+ content = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+ }
+ this._createReplyComment(content, true, comment.unresolved);
+ }
+
+ _handleCommentReply() {
+ this._processCommentReply();
+ }
+
+ _handleCommentQuote() {
+ this._processCommentReply(true);
+ }
+
+ _handleCommentAck() {
+ this._createReplyComment('Ack', false, false);
+ }
+
+ _handleCommentDone() {
+ this._createReplyComment('Done', false, false);
+ }
+
+ _handleCommentFix(e: CustomEvent) {
+ const comment = e.detail.comment;
+ const msg = comment.message;
+ const quoted = msg.replace(NEWLINE_PATTERN, '\n> ') as string;
+ const quoteStr = '> ' + quoted + '\n\n';
+ const response = quoteStr + 'Please fix.';
+ this._createReplyComment(response, false, true);
+ }
+
+ _commentElWithDraftID(id?: string): GrComment | null {
+ if (!id) return null;
+ const els = this.root?.querySelectorAll('gr-comment');
+ if (!els) return null;
+ for (const el of els) {
+ const c = el.comment;
+ if (isRobot(c)) continue;
+ if (c?.id === id || (isDraft(c) && c?.__draftID === id)) return el;
+ }
+ return null;
+ }
+
+ _newReply(
+ inReplyTo: UrlEncodedCommentId,
+ message?: string,
+ unresolved?: boolean
+ ) {
+ const d = this._newDraft();
+ d.in_reply_to = inReplyTo;
+ if (message !== undefined) {
+ d.message = message;
+ }
+ if (unresolved !== undefined) {
+ d.unresolved = unresolved;
+ }
+ return d;
+ }
+
+ _newDraft(lineNum?: number, range?: CommentRange) {
+ const d: UIDraft = {
+ __draft: true,
+ __draftID: Math.random().toString(36),
+ __date: new Date(),
+ };
+
+ // For replies, always use same meta info as root.
+ if (this.comments && this.comments.length >= 1) {
+ const rootComment = this.comments[0];
+ if (rootComment.path !== undefined) d.path = rootComment.path;
+ if (rootComment.patch_set !== undefined)
+ d.patch_set = rootComment.patch_set;
+ if (rootComment.side !== undefined) d.side = rootComment.side;
+ if (rootComment.__commentSide !== undefined)
+ d.__commentSide = rootComment.__commentSide;
+ if (rootComment.line !== undefined) d.line = rootComment.line;
+ if (rootComment.range !== undefined) d.range = rootComment.range;
+ if (rootComment.parent !== undefined) d.parent = rootComment.parent;
+ } else {
+ // Set meta info for root comment.
+ d.path = this.path;
+ d.patch_set = this.patchNum;
+ d.side = this._getSide(this.isOnParent);
+ d.__commentSide = this.commentSide;
+
+ if (lineNum) {
+ d.line = lineNum;
+ }
+ if (range) {
+ d.range = range;
+ }
+ if (this.parentIndex) {
+ d.parent = this.parentIndex;
+ }
+ }
+ return d;
+ }
+
+ _getSide(isOnParent: boolean): CommentSide {
+ return isOnParent ? CommentSide.PARENT : CommentSide.REVISION;
+ }
+
+ _computeRootId(comments: PolymerDeepPropertyChange<UIComment[], unknown>) {
+ // Keep the root ID even if the comment was removed, so that notification
+ // to sync will know which thread to remove.
+ if (!comments.base.length) {
+ return this.rootId;
+ }
+ const rootComment = comments.base[0];
+ if (rootComment.id) return rootComment.id;
+ if (isDraft(rootComment)) return rootComment.__draftID;
+ throw new Error('Missing id in root comment.');
+ }
+
+ _handleCommentDiscard(e: Event) {
+ if (!this.changeNum) throw new Error('changeNum is missing');
+ if (!this.patchNum) throw new Error('patchNum is missing');
+ const diffCommentEl = (dom(e) as EventApi).rootTarget as GrComment;
+ const comment = diffCommentEl.comment;
+ const idx = this._indexOf(comment, this.comments);
+ if (idx === -1) {
+ throw new Error(
+ 'Cannot find comment ' + JSON.stringify(diffCommentEl.comment)
+ );
+ }
+ this.splice('comments', idx, 1);
+ if (this.comments.length === 0) {
+ this.fireRemoveSelf();
+ }
+ this._handleCommentSavedOrDiscarded();
+
+ // Check to see if there are any other open comments getting edited and
+ // set the local storage value to its message value.
+ for (const changeComment of this.comments) {
+ if (changeComment.__editing) {
+ const commentLocation: StorageLocation = {
+ changeNum: this.changeNum,
+ patchNum: this.patchNum,
+ path: changeComment.path,
+ line: changeComment.line,
+ };
+ this.$.storage.setDraftComment(
+ commentLocation,
+ changeComment.message ?? ''
+ );
+ }
+ }
+ }
+
+ _handleCommentSavedOrDiscarded() {
+ this.dispatchEvent(
+ new CustomEvent('thread-changed', {
+ detail: {rootId: this.rootId, path: this.path},
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleCommentUpdate(e: CustomEvent) {
+ const comment = e.detail.comment;
+ const index = this._indexOf(comment, this.comments);
+ if (index === -1) {
+ // This should never happen: comment belongs to another thread.
+ console.warn('Comment update for another comment thread.');
+ return;
+ }
+ this.set(['comments', index], comment);
+ // Because of the way we pass these comment objects around by-ref, in
+ // combination with the fact that Polymer does dirty checking in
+ // observers, the this.set() call above will not cause a thread update in
+ // some situations.
+ this.updateThreadProperties();
+ }
+
+ _indexOf(comment: UIComment | undefined, arr: UIComment[]) {
+ if (!comment) return -1;
+ for (let i = 0; i < arr.length; i++) {
+ const c = arr[i];
+ if (
+ (isDraft(c) && isDraft(comment) && c.__draftID === comment.__draftID) ||
+ (c.id && c.id === comment.id)
+ ) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ _computeHostClass(unresolved?: boolean) {
+ if (this.isRobotComment) {
+ return 'robotComment';
+ }
+ return unresolved ? 'unresolved' : '';
+ }
+
+ /**
+ * Load the project config when a project name has been provided.
+ *
+ * @param name The project name.
+ */
+ _projectNameChanged(name?: RepoName) {
+ if (!name) {
+ return;
+ }
+ this.$.restAPI.getProjectConfig(name).then(config => {
+ this._projectConfig = config;
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-comment-thread': GrCommentThread;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
index db5ade1..1833b73 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment-thread/gr-comment-thread_test.js
@@ -17,9 +17,9 @@
import '../../../test/common-test-setup-karma.js';
import './gr-comment-thread.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {SpecialFilePath} from '../../../constants/constants.js';
+import {sortComments} from '../../../utils/comment-util.js';
const basicFixture = fixtureFromElement('gr-comment-thread');
@@ -35,6 +35,9 @@
});
element = basicFixture.instantiate();
+ element.patchNum = '3';
+ element.changeNum = '1';
+ flush();
});
test('comments are sorted correctly', () => {
@@ -67,7 +70,7 @@
updated: '2015-12-24 15:00:20.396000000',
},
];
- const results = element._sortedComments(comments);
+ const results = sortComments(comments);
assert.deepEqual(results, [
{
id: 'sally_to_dr_finklestein',
@@ -178,25 +181,23 @@
assert.isNotOk(element.shadowRoot
.querySelector('.pathInfo'));
- sinon.stub(GerritNav, 'getUrlForDiffById');
+ const commentStub = sinon.stub(GerritNav, 'getUrlForComment');
element.changeNum = 123;
element.projectName = 'test project';
element.path = 'path/to/file';
+ element.latestPatchNum = 10;
element.patchNum = 3;
element.lineNum = 5;
+ element.comments = [{id: 'comment_id'}];
element.showFilePath = true;
- flushAsynchronousOperations();
+ flush();
assert.isOk(element.shadowRoot
.querySelector('.pathInfo'));
assert.notEqual(getComputedStyle(element.shadowRoot
.querySelector('.pathInfo')).display,
'none');
- assert.isTrue(GerritNav.getUrlForDiffById.getCall(0).calledWithExactly(
- element.changeNum, element.projectName, element.path,
- element.patchNum, null, element.lineNum));
- assert.isTrue(GerritNav.getUrlForDiffById.getCall(1).calledWithExactly(
- element.changeNum, element.projectName, element.path,
- element.patchNum));
+ assert.isTrue(commentStub.calledWithExactly(
+ element.changeNum, element.projectName, 'comment_id'));
});
test('_computeDisplayPath', () => {
@@ -251,6 +252,8 @@
deleteDiffDraft() { return Promise.resolve({ok: true}); },
});
element = withCommentFixture.instantiate();
+ element.patchNum = '1';
+ element.changeNum = '1';
element.comments = [{
author: {
name: 'Mr. Peanutbutter',
@@ -262,10 +265,10 @@
updated: '2015-12-08 19:48:33.843000000',
path: '/path/to/file.txt',
unresolved: true,
- patchNum: 3,
+ patch_set: 3,
__commentSide: 'left',
}];
- flushAsynchronousOperations();
+ flush();
});
test('reply', () => {
@@ -277,7 +280,7 @@
const replyBtn = element.$.replyBtn;
MockInteractions.tap(replyBtn);
- flushAsynchronousOperations();
+ flush();
const drafts = element._orderedComments.filter(c => c.__draft == true);
assert.equal(drafts.length, 1);
@@ -295,7 +298,7 @@
const quoteBtn = element.$.quoteBtn;
MockInteractions.tap(quoteBtn);
- flushAsynchronousOperations();
+ flush();
const drafts = element._orderedComments.filter(c => c.__draft == true);
assert.equal(drafts.length, 1);
@@ -313,11 +316,12 @@
email: 'tenn1sballchaser@aol.com',
},
id: 'baf0414d_60047215',
+ path: 'test',
line: 5,
message: 'is this a crossover episode!?\nIt might be!',
updated: '2015-12-08 19:48:33.843000000',
}];
- flushAsynchronousOperations();
+ flush();
const commentEl = element.shadowRoot
.querySelector('gr-comment');
@@ -325,7 +329,7 @@
const quoteBtn = element.$.quoteBtn;
MockInteractions.tap(quoteBtn);
- flushAsynchronousOperations();
+ flush();
const drafts = element._orderedComments.filter(c => c.__draft == true);
assert.equal(drafts.length, 1);
@@ -434,12 +438,12 @@
element.comments[0].id,
element.comments[0].path,
'it’s pronouced jiff, not giff'));
- flushAsynchronousOperations();
+ flush();
const saveOrDiscardStub = sinon.stub();
element.addEventListener('thread-changed', saveOrDiscardStub);
const draftEl =
- dom(element.root).querySelectorAll('gr-comment')[1];
+ element.root.querySelectorAll('gr-comment')[1];
assert.ok(draftEl);
draftEl.addEventListener('comment-discard', () => {
const drafts = element.comments.filter(c => c.__draft == true);
@@ -465,14 +469,14 @@
element.path = '/path/to/file.txt';
element.comments = [];
element.addOrEditDraft('1');
- flushAsynchronousOperations();
+ flush();
const rootId = element.rootId;
assert.isOk(rootId);
const saveOrDiscardStub = sinon.stub();
element.addEventListener('thread-changed', saveOrDiscardStub);
const draftEl =
- dom(element.root).querySelectorAll('gr-comment')[0];
+ element.root.querySelectorAll('gr-comment')[0];
assert.ok(draftEl);
draftEl.addEventListener('comment-discard', () => {
assert.equal(element.comments.length, 0);
@@ -498,6 +502,7 @@
},
id: 'baf0414d_60047215',
line: 5,
+ path: 'test',
message: 'is this a crossover episode!?',
updated: '2015-12-08 19:48:33.843000000',
__draft: true,
@@ -505,7 +510,7 @@
const replyBtn = element.$.replyBtn;
MockInteractions.tap(replyBtn);
- flushAsynchronousOperations();
+ flush();
const editing = element._orderedComments.filter(c => c.__editing == true);
assert.equal(editing.length, 1);
@@ -522,6 +527,7 @@
email: 'tenn1sballchaser@aol.com',
},
id: 'baf0414d_60047215',
+ path: 'test',
line: 5,
message: 'is this a crossover episode!?',
updated: '2015-12-08 19:48:31.843000000',
@@ -533,6 +539,7 @@
},
__draftID: '1',
in_reply_to: 'baf0414d_60047215',
+ path: 'test',
line: 5,
message: 'yes',
updated: '2015-12-08 19:48:32.843000000',
@@ -546,16 +553,17 @@
},
__draftID: '2',
in_reply_to: 'baf0414d_60047215',
+ path: 'test',
line: 5,
message: 'no',
updated: '2015-12-08 19:48:33.843000000',
__draft: true,
}];
const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
- flushAsynchronousOperations();
+ flush();
const draftEl =
- dom(element.root).querySelectorAll('gr-comment')[1];
+ element.root.querySelectorAll('gr-comment')[1];
assert.ok(draftEl);
draftEl.addEventListener('comment-discard', () => {
assert.isFalse(storageStub.called);
@@ -626,7 +634,7 @@
test('comment in_reply_to is either null or most recent comment', () => {
element._createReplyComment('dummy', true);
- flushAsynchronousOperations();
+ flush();
assert.equal(element._orderedComments.length, 5);
assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
});
@@ -634,7 +642,7 @@
test('resolvable comments', () => {
assert.isFalse(element.unresolved);
element._createReplyComment('dummy', true, true);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.unresolved);
});
@@ -699,7 +707,7 @@
test('_newDraft with root', () => {
const draft = element._newDraft();
assert.equal(draft.__commentSide, 'left');
- assert.equal(draft.patchNum, 3);
+ assert.equal(draft.patch_set, 3);
});
test('_newDraft with no root', () => {
@@ -708,7 +716,7 @@
element.patchNum = 2;
const draft = element._newDraft();
assert.equal(draft.__commentSide, 'right');
- assert.equal(draft.patchNum, 2);
+ assert.equal(draft.patch_set, 2);
});
test('new comment gets created', () => {
@@ -826,6 +834,8 @@
deleteDiffDraft() { return Promise.resolve({ok: true}); },
});
element = withCommentFixture.instantiate();
+ element.patchNum = '1';
+ element.changeNum = '1';
element.comments = [{
author: {
name: 'Mr. Peanutbutter',
@@ -838,7 +848,7 @@
path: '/path/to/file.txt',
unresolved: false,
}];
- flushAsynchronousOperations();
+ flush();
});
test('ack and done should be hidden', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
deleted file mode 100644
index 4026fa3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.js
+++ /dev/null
@@ -1,875 +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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.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 '../gr-button/gr-button.js';
-import '../gr-dialog/gr-dialog.js';
-import '../gr-date-formatter/gr-date-formatter.js';
-import '../gr-formatted-text/gr-formatted-text.js';
-import '../gr-icons/gr-icons.js';
-import '../gr-overlay/gr-overlay.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-storage/gr-storage.js';
-import '../gr-textarea/gr-textarea.js';
-import '../gr-tooltip-content/gr-tooltip-content.js';
-import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js';
-import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-comment_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {getRootElement} from '../../../scripts/rootElement.js';
-import {getDisplayName} from '../../../utils/display-name-util.js';
-import {appContext} from '../../../services/app-context.js';
-
-const STORAGE_DEBOUNCE_INTERVAL = 400;
-const TOAST_DEBOUNCE_INTERVAL = 200;
-
-const SAVING_MESSAGE = 'Saving';
-const DRAFT_SINGULAR = 'draft...';
-const DRAFT_PLURAL = 'drafts...';
-const SAVED_MESSAGE = 'All changes saved';
-
-const REPORT_CREATE_DRAFT = 'CreateDraftComment';
-const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
-const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
-
-const FILE = 'FILE';
-
-/**
- * All candidates tips to show, will pick randomly.
- */
-const RESPECTFUL_REVIEW_TIPS= [
- 'Assume competence.',
- 'Provide rationale or context.',
- 'Consider how comments may be interpreted.',
- 'Avoid harsh language.',
- 'Make your comments specific and actionable.',
- 'When disagreeing, explain the advantage of your approach.',
-];
-
-/**
- * @extends PolymerElement
- */
-class GrComment extends KeyboardShortcutMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-comment'; }
- /**
- * Fired when the create fix comment action is triggered.
- *
- * @event create-fix-comment
- */
-
- /**
- * Fired when the show fix preview action is triggered.
- *
- * @event open-fix-preview
- */
-
- /**
- * Fired when this comment is discarded.
- *
- * @event comment-discard
- */
-
- /**
- * Fired when this comment is saved.
- *
- * @event comment-save
- */
-
- /**
- * Fired when this comment is updated.
- *
- * @event comment-update
- */
-
- /**
- * Fired when editing status changed.
- *
- * @event comment-editing-changed
- */
-
- /**
- * Fired when the comment's timestamp is tapped.
- *
- * @event comment-anchor-tap
- */
-
- static get properties() {
- return {
- changeNum: String,
- /** @type {!Gerrit.Comment} */
- comment: {
- type: Object,
- notify: true,
- observer: '_commentChanged',
- },
- comments: {
- type: Array,
- },
- isRobotComment: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- disabled: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- draft: {
- type: Boolean,
- value: false,
- observer: '_draftChanged',
- },
- editing: {
- type: Boolean,
- value: false,
- observer: '_editingChanged',
- },
- discarding: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- hasChildren: Boolean,
- patchNum: String,
- showActions: Boolean,
- _showHumanActions: Boolean,
- _showRobotActions: Boolean,
- collapsed: {
- type: Boolean,
- value: true,
- reflectToAttribute: true,
- observer: '_toggleCollapseClass',
- },
- /** @type {?} */
- projectConfig: Object,
- robotButtonDisabled: Boolean,
- _hasHumanReply: Boolean,
- _isAdmin: {
- type: Boolean,
- value: false,
- },
-
- _xhrPromise: Object, // Used for testing.
- _messageText: {
- type: String,
- value: '',
- observer: '_messageTextChanged',
- },
- commentSide: String,
- side: String,
-
- resolved: Boolean,
-
- _numPendingDraftRequests: {
- type: Object,
- value:
- {number: 0}, // Intentional to share the object across instances.
- },
-
- _enableOverlay: {
- type: Boolean,
- value: false,
- },
-
- /**
- * Property for storing references to overlay elements. When the overlays
- * are moved to getRootElement() to be shown they are no-longer
- * children, so they can't be queried along the tree, so they are stored
- * here.
- */
- _overlays: {
- type: Object,
- value: () => { return {}; },
- },
-
- _showRespectfulTip: {
- type: Boolean,
- value: false,
- },
- showPatchset: {
- type: Boolean,
- value: true,
- },
- _respectfulReviewTip: String,
- _respectfulTipDismissed: {
- type: Boolean,
- value: false,
- },
- _serverConfig: Object,
- };
- }
-
- static get observers() {
- return [
- '_commentMessageChanged(comment.message)',
- '_loadLocalDraft(changeNum, patchNum, comment)',
- '_isRobotComment(comment)',
- '_calculateActionstoShow(showActions, isRobotComment)',
- '_computeHasHumanReply(comment, comments.*)',
- '_onEditingChange(editing)',
- ];
- }
-
- get keyBindings() {
- return {
- 'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
- 'esc': '_handleEsc',
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- attached() {
- super.attached();
- if (this.editing) {
- this.collapsed = false;
- } else if (this.comment) {
- this.collapsed = this.comment.collapsed;
- }
- this._getIsAdmin().then(isAdmin => {
- this._isAdmin = isAdmin;
- });
- this.$.restAPI.getConfig().then(cfg => {
- this._serverConfig = cfg;
- });
- }
-
- /** @override */
- detached() {
- super.detached();
- this.cancelDebouncer('fire-update');
- if (this.textarea) {
- this.textarea.closeDropdown();
- }
- }
-
- _onEditingChange(editing) {
- this.dispatchEvent(new CustomEvent('comment-editing-changed', {
- detail: !!editing,
- bubbles: true,
- composed: true,
- }));
- if (!editing) return;
- // visibility based on cache this will make sure we only and always show
- // a tip once every Math.max(a day, period between creating comments)
- const cachedVisibilityOfRespectfulTip =
- this.$.storage.getRespectfulTipVisibility();
- if (!cachedVisibilityOfRespectfulTip) {
- // we still want to show the tip with a probability of 30%
- if (this.getRandomNum(0, 3) >= 1) return;
- this._showRespectfulTip = true;
- const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
- this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
- this.reporting.reportInteraction(
- 'respectful-tip-appeared',
- {tip: this._respectfulReviewTip}
- );
- // update cache
- this.$.storage.setRespectfulTipVisibility();
- }
- }
-
- /** Set as a separate method so easy to stub. */
- getRandomNum(min, max) {
- return Math.floor(Math.random() * (max - min) + min);
- }
-
- _computeVisibilityOfTip(showTip, tipDismissed) {
- return showTip && !tipDismissed;
- }
-
- _dismissRespectfulTip() {
- this._respectfulTipDismissed = true;
- this.reporting.reportInteraction(
- 'respectful-tip-dismissed',
- {tip: this._respectfulReviewTip}
- );
- // add a 14-day delay to the tip cache
- this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
- }
-
- _onRespectfulReadMoreClick() {
- this.reporting.reportInteraction('respectful-read-more-clicked');
- }
-
- get textarea() {
- return this.shadowRoot.querySelector('#editTextarea');
- }
-
- get confirmDeleteOverlay() {
- if (!this._overlays.confirmDelete) {
- this._enableOverlay = true;
- flush();
- this._overlays.confirmDelete = this.shadowRoot
- .querySelector('#confirmDeleteOverlay');
- }
- return this._overlays.confirmDelete;
- }
-
- get confirmDiscardOverlay() {
- if (!this._overlays.confirmDiscard) {
- this._enableOverlay = true;
- flush();
- this._overlays.confirmDiscard = this.shadowRoot
- .querySelector('#confirmDiscardOverlay');
- }
- return this._overlays.confirmDiscard;
- }
-
- _computeShowHideIcon(collapsed) {
- return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
- }
-
- _computeShowHideAriaLabel(collapsed) {
- return collapsed ? 'Expand' : 'Collapse';
- }
-
- _calculateActionstoShow(showActions, isRobotComment) {
- // Polymer 2: check for undefined
- if ([showActions, isRobotComment].includes(undefined)) {
- return;
- }
-
- this._showHumanActions = showActions && !isRobotComment;
- this._showRobotActions = showActions && isRobotComment;
- }
-
- _isRobotComment(comment) {
- this.isRobotComment = !!comment.robot_id;
- }
-
- isOnParent() {
- return this.side === 'PARENT';
- }
-
- _getIsAdmin() {
- return this.$.restAPI.getIsAdmin();
- }
-
- /**
- * @param {*=} opt_comment
- */
- save(opt_comment) {
- let comment = opt_comment;
- if (!comment) {
- comment = this.comment;
- }
-
- this.set('comment.message', this._messageText);
- this.editing = false;
- this.disabled = true;
-
- if (!this._messageText) {
- return this._discardDraft();
- }
-
- this._xhrPromise = this._saveDraft(comment).then(response => {
- this.disabled = false;
- if (!response.ok) { return response; }
-
- this._eraseDraftComment();
- return this.$.restAPI.getResponseObject(response).then(obj => {
- const resComment = obj;
- resComment.__draft = true;
- // Maintain the ephemeral draft ID for identification by other
- // elements.
- if (this.comment.__draftID) {
- resComment.__draftID = this.comment.__draftID;
- }
- resComment.__commentSide = this.commentSide;
- this.comment = resComment;
- this._fireSave();
- return obj;
- });
- })
- .catch(err => {
- this.disabled = false;
- throw err;
- });
-
- return this._xhrPromise;
- }
-
- _eraseDraftComment() {
- // Prevents a race condition in which removing the draft comment occurs
- // prior to it being saved.
- this.cancelDebouncer('store');
-
- this.$.storage.eraseDraftComment({
- changeNum: this.changeNum,
- patchNum: this._getPatchNum(),
- path: this.comment.path,
- line: this.comment.line,
- range: this.comment.range,
- });
- }
-
- _commentChanged(comment) {
- this.editing = !!comment.__editing;
- this.resolved = !comment.unresolved;
- if (this.editing) { // It's a new draft/reply, notify.
- this._fireUpdate();
- }
- }
-
- _computeHasHumanReply() {
- if (!this.comment || !this.comments) return;
- // hide please fix button for robot comment that has human reply
- this._hasHumanReply = this.comments
- .some(c => c.in_reply_to && c.in_reply_to === this.comment.id &&
- !c.robot_id);
- }
-
- /**
- * @param {!Object=} opt_mixin
- *
- * @return {!Object}
- */
- _getEventPayload(opt_mixin) {
- return Object.assign({}, opt_mixin, {
- comment: this.comment,
- patchNum: this.patchNum,
- });
- }
-
- _fireSave() {
- this.dispatchEvent(new CustomEvent('comment-save', {
- detail: this._getEventPayload(),
- composed: true, bubbles: true,
- }));
- }
-
- _fireUpdate() {
- this.debounce('fire-update', () => {
- this.dispatchEvent(new CustomEvent('comment-update', {
- detail: this._getEventPayload(),
- composed: true, bubbles: true,
- }));
- });
- }
-
- _draftChanged(draft) {
- this.$.container.classList.toggle('draft', draft);
- }
-
- _editingChanged(editing, previousValue) {
- // Polymer 2: observer fires when at least one property is defined.
- // Do nothing to prevent comment.__editing being overwritten
- // if previousValue is undefined
- if (previousValue === undefined) return;
-
- this.$.container.classList.toggle('editing', editing);
- if (this.comment && this.comment.id) {
- const cancelButton = this.shadowRoot.querySelector('.cancel');
- if (cancelButton) {
- cancelButton.hidden = !editing;
- }
- }
- if (this.comment) {
- this.comment.__editing = this.editing;
- }
- if (editing != !!previousValue) {
- // To prevent event firing on comment creation.
- this._fireUpdate();
- }
- if (editing) {
- this.async(() => {
- flush();
- this.textarea && this.textarea.putCursorAtEnd();
- }, 1);
- }
- }
-
- _computeDeleteButtonClass(isAdmin, draft) {
- return isAdmin && !draft ? 'showDeleteButtons' : '';
- }
-
- _computeSaveDisabled(draft, comment, resolved) {
- // If resolved state has changed and a msg exists, save should be enabled.
- if (!comment || comment.unresolved === resolved && draft) {
- return false;
- }
- return !draft || draft.trim() === '';
- }
-
- _handleSaveKey(e) {
- if (!this._computeSaveDisabled(this._messageText, this.comment,
- this.resolved)) {
- e.preventDefault();
- this._handleSave(e);
- }
- }
-
- _handleEsc(e) {
- if (!this._messageText.length) {
- e.preventDefault();
- this._handleCancel(e);
- }
- }
-
- _handleToggleCollapsed() {
- this.collapsed = !this.collapsed;
- }
-
- _toggleCollapseClass(collapsed) {
- if (collapsed) {
- this.$.container.classList.add('collapsed');
- } else {
- this.$.container.classList.remove('collapsed');
- }
- }
-
- _commentMessageChanged(message) {
- this._messageText = message || '';
- }
-
- _messageTextChanged(newValue, oldValue) {
- if (!this.comment || (this.comment && this.comment.id)) {
- return;
- }
-
- this.debounce('store', () => {
- const message = this._messageText;
- const commentLocation = {
- changeNum: this.changeNum,
- patchNum: this._getPatchNum(),
- path: this.comment.path,
- line: this.comment.line,
- range: this.comment.range,
- };
-
- if ((!this._messageText || !this._messageText.length) && oldValue) {
- // If the draft has been modified to be empty, then erase the storage
- // entry.
- this.$.storage.eraseDraftComment(commentLocation);
- } else {
- this.$.storage.setDraftComment(commentLocation, message);
- }
- }, STORAGE_DEBOUNCE_INTERVAL);
- }
-
- _handleAnchorClick(e) {
- e.preventDefault();
- if (!this.comment.line) {
- return;
- }
- this.dispatchEvent(new CustomEvent('comment-anchor-tap', {
- bubbles: true,
- composed: true,
- detail: {
- number: this.comment.line || FILE,
- side: this.side,
- },
- }));
- }
-
- _handleEdit(e) {
- e.preventDefault();
- this._messageText = this.comment.message;
- this.editing = true;
- this.reporting.recordDraftInteraction();
- }
-
- _handleSave(e) {
- e.preventDefault();
-
- // Ignore saves started while already saving.
- if (this.disabled) {
- return;
- }
- const timingLabel = this.comment.id ?
- REPORT_UPDATE_DRAFT : REPORT_CREATE_DRAFT;
- const timer = this.reporting.getTimer(timingLabel);
- this.set('comment.__editing', false);
- return this.save().then(() => { timer.end(); });
- }
-
- _handleCancel(e) {
- e.preventDefault();
-
- if (!this.comment.message ||
- this.comment.message.trim().length === 0 ||
- !this.comment.id) {
- this._fireDiscard();
- return;
- }
- this._messageText = this.comment.message;
- this.editing = false;
- }
-
- _fireDiscard() {
- this.cancelDebouncer('fire-update');
- this.dispatchEvent(new CustomEvent('comment-discard', {
- detail: this._getEventPayload(),
- composed: true, bubbles: true,
- }));
- }
-
- _handleFix() {
- this.dispatchEvent(new CustomEvent('create-fix-comment', {
- bubbles: true,
- composed: true,
- detail: this._getEventPayload(),
- }));
- }
-
- _handleShowFix() {
- this.dispatchEvent(new CustomEvent('open-fix-preview', {
- bubbles: true,
- composed: true,
- detail: this._getEventPayload(),
- }));
- }
-
- _hasNoFix(comment) {
- return !comment || !comment.fix_suggestions;
- }
-
- _handleDiscard(e) {
- e.preventDefault();
- this.reporting.recordDraftInteraction();
-
- if (!this._messageText) {
- this._discardDraft();
- return;
- }
-
- this._openOverlay(this.confirmDiscardOverlay).then(() => {
- this.confirmDiscardOverlay.querySelector('#confirmDiscardDialog')
- .resetFocus();
- });
- }
-
- _handleConfirmDiscard(e) {
- e.preventDefault();
- const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
- this._closeConfirmDiscardOverlay();
- return this._discardDraft().then(() => { timer.end(); });
- }
-
- _discardDraft() {
- if (!this.comment.__draft) {
- throw Error('Cannot discard a non-draft comment.');
- }
- this.discarding = true;
- this.editing = false;
- this.disabled = true;
- this._eraseDraftComment();
-
- if (!this.comment.id) {
- this.disabled = false;
- this._fireDiscard();
- return;
- }
-
- this._xhrPromise = this._deleteDraft(this.comment).then(response => {
- this.disabled = false;
- if (!response.ok) {
- this.discarding = false;
- return response;
- }
-
- this._fireDiscard();
- })
- .catch(err => {
- this.disabled = false;
- throw err;
- });
-
- return this._xhrPromise;
- }
-
- _closeConfirmDiscardOverlay() {
- this._closeOverlay(this.confirmDiscardOverlay);
- }
-
- _getSavingMessage(numPending) {
- if (numPending === 0) {
- return SAVED_MESSAGE;
- }
- return [
- SAVING_MESSAGE,
- numPending,
- numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
- ].join(' ');
- }
-
- _showStartRequest() {
- const numPending = ++this._numPendingDraftRequests.number;
- this._updateRequestToast(numPending);
- }
-
- _showEndRequest() {
- const numPending = --this._numPendingDraftRequests.number;
- this._updateRequestToast(numPending);
- }
-
- _handleFailedDraftRequest() {
- this._numPendingDraftRequests.number--;
-
- // Cancel the debouncer so that error toasts from the error-manager will
- // not be overridden.
- this.cancelDebouncer('draft-toast');
- }
-
- _updateRequestToast(numPending) {
- const message = this._getSavingMessage(numPending);
- this.debounce('draft-toast', () => {
- // Note: the event is fired on the body rather than this element because
- // this element may not be attached by the time this executes, in which
- // case the event would not bubble.
- document.body.dispatchEvent(new CustomEvent(
- 'show-alert', {detail: {message}, bubbles: true, composed: true}));
- }, TOAST_DEBOUNCE_INTERVAL);
- }
-
- _saveDraft(draft) {
- this._showStartRequest();
- return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
- .then(result => {
- if (result.ok) {
- this._showEndRequest();
- } else {
- this._handleFailedDraftRequest();
- }
- return result;
- });
- }
-
- _deleteDraft(draft) {
- this._showStartRequest();
- return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
- draft).then(result => {
- if (result.ok) {
- this._showEndRequest();
- } else {
- this._handleFailedDraftRequest();
- }
- return result;
- });
- }
-
- _getPatchNum() {
- return this.isOnParent() ? 'PARENT' : this.patchNum;
- }
-
- _loadLocalDraft(changeNum, patchNum, comment) {
- // Polymer 2: check for undefined
- if ([changeNum, patchNum, comment].includes(undefined)) {
- return;
- }
-
- // Only apply local drafts to comments that haven't been saved
- // remotely, and haven't been given a default message already.
- //
- // Don't get local draft if there is another comment that is currently
- // in an editing state.
- if (!comment || comment.id || comment.message || comment.__otherEditing) {
- delete comment.__otherEditing;
- return;
- }
-
- const draft = this.$.storage.getDraftComment({
- changeNum,
- patchNum: this._getPatchNum(),
- path: comment.path,
- line: comment.line,
- range: comment.range,
- });
-
- if (draft) {
- this.set('comment.message', draft.message);
- }
- }
-
- _handleToggleResolved() {
- this.reporting.recordDraftInteraction();
- this.resolved = !this.resolved;
- // Modify payload instead of this.comment, as this.comment is passed from
- // the parent by ref.
- const payload = this._getEventPayload();
- payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
- this.dispatchEvent(new CustomEvent('comment-update', {
- detail: payload,
- composed: true, bubbles: true,
- }));
- if (!this.editing) {
- // Save the resolved state immediately.
- this.save(payload.comment);
- }
- }
-
- _handleCommentDelete() {
- this._openOverlay(this.confirmDeleteOverlay);
- }
-
- _handleCancelDeleteComment() {
- this._closeOverlay(this.confirmDeleteOverlay);
- }
-
- _openOverlay(overlay) {
- dom(getRootElement()).appendChild(overlay);
- return overlay.open();
- }
-
- _computeAuthorName(comment, serverConfig) {
- if ([comment, serverConfig].includes(undefined)) return '';
- if (comment.robot_id) {
- return comment.robot_id;
- }
- if (comment.author) {
- return getDisplayName(serverConfig, comment.author);
- }
- return '';
- }
-
- _computeHideRunDetails(comment, collapsed) {
- if (!comment) return true;
- return !(comment.robot_id && comment.url && !collapsed);
- }
-
- _closeOverlay(overlay) {
- dom(getRootElement()).removeChild(overlay);
- overlay.close();
- }
-
- _handleConfirmDeleteComment() {
- const dialog =
- this.confirmDeleteOverlay.querySelector('#confirmDeleteComment');
- this.$.restAPI.deleteComment(
- this.changeNum, this.patchNum, this.comment.id, dialog.message)
- .then(newComment => {
- this._handleCancelDeleteComment();
- this.comment = newComment;
- });
- }
-}
-
-customElements.define(GrComment.is, GrComment);
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
new file mode 100644
index 0000000..e4d520f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -0,0 +1,1045 @@
+/**
+ * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/shared-styles';
+import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import '../../plugins/gr-endpoint-param/gr-endpoint-param';
+import '../gr-button/gr-button';
+import '../gr-dialog/gr-dialog';
+import '../gr-date-formatter/gr-date-formatter';
+import '../gr-formatted-text/gr-formatted-text';
+import '../gr-icons/gr-icons';
+import '../gr-overlay/gr-overlay';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-storage/gr-storage';
+import '../gr-textarea/gr-textarea';
+import '../gr-tooltip-content/gr-tooltip-content';
+import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
+import '../gr-account-label/gr-account-label';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-comment_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {getRootElement} from '../../../scripts/rootElement';
+import {appContext} from '../../../services/app-context';
+import {customElement, observe, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrTextarea} from '../gr-textarea/gr-textarea';
+import {GrStorage, StorageLocation} from '../gr-storage/gr-storage';
+import {GrOverlay} from '../gr-overlay/gr-overlay';
+import {
+ AccountDetailInfo,
+ NumericChangeId,
+ ConfigInfo,
+ PatchSetNum,
+} from '../../../types/common';
+import {GrButton} from '../gr-button/gr-button';
+import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
+import {GrDialog} from '../gr-dialog/gr-dialog';
+import {Side} from '../../../constants/constants';
+import {
+ isDraft,
+ UIComment,
+ UIDraft,
+ UIRobot,
+} from '../../../utils/comment-util';
+import {OpenFixPreviewEventDetail} from '../../../types/events';
+
+const STORAGE_DEBOUNCE_INTERVAL = 400;
+const TOAST_DEBOUNCE_INTERVAL = 200;
+
+const SAVING_MESSAGE = 'Saving';
+const DRAFT_SINGULAR = 'draft...';
+const DRAFT_PLURAL = 'drafts...';
+const SAVED_MESSAGE = 'All changes saved';
+const UNSAVED_MESSAGE = 'Unable to save draft';
+
+const REPORT_CREATE_DRAFT = 'CreateDraftComment';
+const REPORT_UPDATE_DRAFT = 'UpdateDraftComment';
+const REPORT_DISCARD_DRAFT = 'DiscardDraftComment';
+
+const FILE = 'FILE';
+
+export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
+
+/**
+ * All candidates tips to show, will pick randomly.
+ */
+const RESPECTFUL_REVIEW_TIPS = [
+ 'Assume competence.',
+ 'Provide rationale or context.',
+ 'Consider how comments may be interpreted.',
+ 'Avoid harsh language.',
+ 'Make your comments specific and actionable.',
+ 'When disagreeing, explain the advantage of your approach.',
+];
+
+interface CommentOverlays {
+ confirmDelete?: GrOverlay | null;
+ confirmDiscard?: GrOverlay | null;
+}
+
+export interface GrComment {
+ $: {
+ restAPI: RestApiService & Element;
+ storage: GrStorage;
+ container: HTMLDivElement;
+ resolvedCheckbox: HTMLInputElement;
+ };
+}
+
+@customElement('gr-comment')
+export class GrComment extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the create fix comment action is triggered.
+ *
+ * @event create-fix-comment
+ */
+
+ /**
+ * Fired when the show fix preview action is triggered.
+ *
+ * @event open-fix-preview
+ */
+
+ /**
+ * Fired when this comment is discarded.
+ *
+ * @event comment-discard
+ */
+
+ /**
+ * Fired when this comment is saved.
+ *
+ * @event comment-save
+ */
+
+ /**
+ * Fired when this comment is updated.
+ *
+ * @event comment-update
+ */
+
+ /**
+ * Fired when editing status changed.
+ *
+ * @event comment-editing-changed
+ */
+
+ /**
+ * Fired when the comment's timestamp is tapped.
+ *
+ * @event comment-anchor-tap
+ */
+
+ @property({type: Number})
+ changeNum?: NumericChangeId;
+
+ @property({type: Object, notify: true, observer: '_commentChanged'})
+ comment?: UIComment | UIRobot;
+
+ @property({type: Array})
+ comments?: (UIComment | UIRobot)[];
+
+ @property({type: Boolean, reflectToAttribute: true})
+ isRobotComment = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ disabled = false;
+
+ @property({type: Boolean, observer: '_draftChanged'})
+ draft = false;
+
+ @property({type: Boolean, observer: '_editingChanged'})
+ editing = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ discarding = false;
+
+ @property({type: Boolean})
+ hasChildren?: boolean;
+
+ @property({type: String})
+ patchNum?: PatchSetNum;
+
+ @property({type: Boolean})
+ showActions?: boolean;
+
+ @property({type: Boolean})
+ _showHumanActions?: boolean;
+
+ @property({type: Boolean})
+ _showRobotActions?: boolean;
+
+ @property({
+ type: Boolean,
+ reflectToAttribute: true,
+ observer: '_toggleCollapseClass',
+ })
+ collapsed = true;
+
+ @property({type: Object})
+ projectConfig?: ConfigInfo;
+
+ @property({type: Boolean})
+ robotButtonDisabled?: boolean;
+
+ @property({type: Boolean})
+ _hasHumanReply?: boolean;
+
+ @property({type: Boolean})
+ _isAdmin = false;
+
+ @property({type: Object})
+ _xhrPromise?: Promise<any>; // Used for testing.
+
+ @property({type: String, observer: '_messageTextChanged'})
+ _messageText = '';
+
+ @property({type: String})
+ commentSide?: Side;
+
+ @property({type: String})
+ side?: string;
+
+ @property({type: Boolean})
+ resolved?: boolean;
+
+ // Intentional to share the object across instances.
+ @property({type: Object})
+ _numPendingDraftRequests: {number: number} = {number: 0};
+
+ @property({type: Boolean})
+ _enableOverlay = false;
+
+ /**
+ * Property for storing references to overlay elements. When the overlays
+ * are moved to getRootElement() to be shown they are no-longer
+ * children, so they can't be queried along the tree, so they are stored
+ * here.
+ */
+ @property({type: Object})
+ _overlays: CommentOverlays = {};
+
+ @property({type: Boolean})
+ _showRespectfulTip = false;
+
+ @property({type: Boolean})
+ showPatchset = true;
+
+ @property({type: String})
+ _respectfulReviewTip?: string;
+
+ @property({type: Boolean})
+ _respectfulTipDismissed = false;
+
+ @property({type: Boolean})
+ _unableToSave = false;
+
+ @property({type: Object})
+ _selfAccount?: AccountDetailInfo;
+
+ get keyBindings() {
+ return {
+ 'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
+ esc: '_handleEsc',
+ };
+ }
+
+ reporting = appContext.reportingService;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this.$.restAPI.getAccount().then(account => {
+ this._selfAccount = account;
+ });
+ if (this.editing) {
+ this.collapsed = false;
+ } else if (this.comment) {
+ this.collapsed = !!this.comment.collapsed;
+ }
+ this._getIsAdmin().then(isAdmin => {
+ this._isAdmin = !!isAdmin;
+ });
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.cancelDebouncer('fire-update');
+ if (this.textarea) {
+ this.textarea.closeDropdown();
+ }
+ }
+
+ _getAuthor(comment: UIComment) {
+ return comment.author || this._selfAccount;
+ }
+
+ @observe('editing')
+ _onEditingChange(editing?: boolean) {
+ this.dispatchEvent(
+ new CustomEvent('comment-editing-changed', {
+ detail: !!editing,
+ bubbles: true,
+ composed: true,
+ })
+ );
+ if (!editing) return;
+ // visibility based on cache this will make sure we only and always show
+ // a tip once every Math.max(a day, period between creating comments)
+ const cachedVisibilityOfRespectfulTip = this.$.storage.getRespectfulTipVisibility();
+ if (!cachedVisibilityOfRespectfulTip) {
+ // we still want to show the tip with a probability of 30%
+ if (this.getRandomNum(0, 3) >= 1) return;
+ this._showRespectfulTip = true;
+ const randomIdx = this.getRandomNum(0, RESPECTFUL_REVIEW_TIPS.length);
+ this._respectfulReviewTip = RESPECTFUL_REVIEW_TIPS[randomIdx];
+ this.reporting.reportInteraction('respectful-tip-appeared', {
+ tip: this._respectfulReviewTip,
+ });
+ // update cache
+ this.$.storage.setRespectfulTipVisibility();
+ }
+ }
+
+ /** Set as a separate method so easy to stub. */
+ getRandomNum(min: number, max: number) {
+ return Math.floor(Math.random() * (max - min) + min);
+ }
+
+ _computeVisibilityOfTip(showTip: boolean, tipDismissed: boolean) {
+ return showTip && !tipDismissed;
+ }
+
+ _dismissRespectfulTip() {
+ this._respectfulTipDismissed = true;
+ this.reporting.reportInteraction('respectful-tip-dismissed', {
+ tip: this._respectfulReviewTip,
+ });
+ // add a 14-day delay to the tip cache
+ this.$.storage.setRespectfulTipVisibility(/* delayDays= */ 14);
+ }
+
+ _onRespectfulReadMoreClick() {
+ this.reporting.reportInteraction('respectful-read-more-clicked');
+ }
+
+ get textarea(): GrTextarea | null {
+ return this.shadowRoot?.querySelector('#editTextarea') as GrTextarea | null;
+ }
+
+ get confirmDeleteOverlay() {
+ if (!this._overlays.confirmDelete) {
+ this._enableOverlay = true;
+ flush();
+ this._overlays.confirmDelete = this.shadowRoot?.querySelector(
+ '#confirmDeleteOverlay'
+ ) as GrOverlay | null;
+ }
+ return this._overlays.confirmDelete;
+ }
+
+ get confirmDiscardOverlay() {
+ if (!this._overlays.confirmDiscard) {
+ this._enableOverlay = true;
+ flush();
+ this._overlays.confirmDiscard = this.shadowRoot?.querySelector(
+ '#confirmDiscardOverlay'
+ ) as GrOverlay | null;
+ }
+ return this._overlays.confirmDiscard;
+ }
+
+ _computeShowHideIcon(collapsed: boolean) {
+ return collapsed ? 'gr-icons:expand-more' : 'gr-icons:expand-less';
+ }
+
+ _computeShowHideAriaLabel(collapsed: boolean) {
+ return collapsed ? 'Expand' : 'Collapse';
+ }
+
+ @observe('showActions', 'isRobotComment')
+ _calculateActionstoShow(showActions?: boolean, isRobotComment?: boolean) {
+ // Polymer 2: check for undefined
+ if ([showActions, isRobotComment].includes(undefined)) {
+ return;
+ }
+
+ this._showHumanActions = showActions && !isRobotComment;
+ this._showRobotActions = showActions && isRobotComment;
+ }
+
+ @observe('comment')
+ _isRobotComment(comment: UIRobot) {
+ this.isRobotComment = !!comment.robot_id;
+ }
+
+ isOnParent() {
+ return this.side === 'PARENT';
+ }
+
+ _getIsAdmin() {
+ return this.$.restAPI.getIsAdmin();
+ }
+
+ _computeDraftTooltip(unableToSave: boolean) {
+ return unableToSave
+ ? 'Unable to save draft. Please try to save again.'
+ : "This draft is only visible to you. To publish drafts, click the 'Reply'" +
+ "or 'Start review' button at the top of the change or press the 'A' key.";
+ }
+
+ _computeDraftText(unableToSave: boolean) {
+ return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
+ }
+
+ save(opt_comment?: UIComment) {
+ let comment = opt_comment;
+ if (!comment) {
+ comment = this.comment;
+ }
+
+ this.set('comment.message', this._messageText);
+ this.editing = false;
+ this.disabled = true;
+
+ if (!this._messageText) {
+ return this._discardDraft();
+ }
+
+ this._xhrPromise = this._saveDraft(comment)
+ .then(response => {
+ this.disabled = false;
+ if (!response.ok) {
+ return;
+ }
+
+ this._eraseDraftComment();
+ return this.$.restAPI.getResponseObject(response).then(obj => {
+ const resComment = (obj as unknown) as UIDraft;
+ if (!isDraft(this.comment)) throw new Error('Can only save drafts.');
+ resComment.__draft = true;
+ // Maintain the ephemeral draft ID for identification by other
+ // elements.
+ if (this.comment?.__draftID) {
+ resComment.__draftID = this.comment.__draftID;
+ }
+ resComment.__commentSide = this.commentSide;
+ this.comment = resComment;
+ this._fireSave();
+ return obj;
+ });
+ })
+ .catch(err => {
+ this.disabled = false;
+ throw err;
+ });
+
+ return this._xhrPromise;
+ }
+
+ _eraseDraftComment() {
+ // Prevents a race condition in which removing the draft comment occurs
+ // prior to it being saved.
+ this.cancelDebouncer('store');
+
+ if (!this.comment?.path) throw new Error('Cannot erase Draft Comment');
+ if (this.changeNum === undefined) {
+ throw new Error('undefined changeNum');
+ }
+ this.$.storage.eraseDraftComment({
+ changeNum: this.changeNum,
+ patchNum: this._getPatchNum(),
+ path: this.comment.path,
+ line: this.comment.line,
+ range: this.comment.range,
+ });
+ }
+
+ _commentChanged(comment: UIComment) {
+ this.editing = !!comment.__editing;
+ this.resolved = !comment.unresolved;
+ if (this.editing) {
+ // It's a new draft/reply, notify.
+ this._fireUpdate();
+ }
+ }
+
+ @observe('comment', 'comments.*')
+ _computeHasHumanReply() {
+ const comment = this.comment;
+ if (!comment || !this.comments) return;
+ // hide please fix button for robot comment that has human reply
+ this._hasHumanReply = this.comments.some(
+ c =>
+ c.in_reply_to &&
+ c.in_reply_to === comment.id &&
+ !(c as UIRobot).robot_id
+ );
+ }
+
+ _getEventPayload(): OpenFixPreviewEventDetail {
+ return {comment: this.comment, patchNum: this.patchNum};
+ }
+
+ _fireSave() {
+ this.dispatchEvent(
+ new CustomEvent('comment-save', {
+ detail: this._getEventPayload(),
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _fireUpdate() {
+ this.debounce('fire-update', () => {
+ this.dispatchEvent(
+ new CustomEvent('comment-update', {
+ detail: this._getEventPayload(),
+ composed: true,
+ bubbles: true,
+ })
+ );
+ });
+ }
+
+ _computeAccountLabelClass(draft: boolean) {
+ return draft ? 'draft' : '';
+ }
+
+ _draftChanged(draft: boolean) {
+ this.$.container.classList.toggle('draft', draft);
+ }
+
+ _editingChanged(editing?: boolean, previousValue?: boolean) {
+ // Polymer 2: observer fires when at least one property is defined.
+ // Do nothing to prevent comment.__editing being overwritten
+ // if previousValue is undefined
+ if (previousValue === undefined) return;
+
+ this.$.container.classList.toggle('editing', editing);
+ if (this.comment && this.comment.id) {
+ const cancelButton = this.shadowRoot?.querySelector(
+ '.cancel'
+ ) as GrButton | null;
+ if (cancelButton) {
+ cancelButton.hidden = !editing;
+ }
+ }
+ if (this.comment) {
+ this.comment.__editing = this.editing;
+ }
+ if (!!editing !== !!previousValue) {
+ // To prevent event firing on comment creation.
+ this._fireUpdate();
+ }
+ if (editing) {
+ this.async(() => {
+ flush();
+ this.textarea && this.textarea.putCursorAtEnd();
+ }, 1);
+ }
+ }
+
+ _computeDeleteButtonClass(isAdmin: boolean, draft: boolean) {
+ return isAdmin && !draft ? 'showDeleteButtons' : '';
+ }
+
+ _computeSaveDisabled(
+ draft: string,
+ comment: UIComment | undefined,
+ resolved?: boolean
+ ) {
+ // If resolved state has changed and a msg exists, save should be enabled.
+ if (!comment || (comment.unresolved === resolved && draft)) {
+ return false;
+ }
+ return !draft || draft.trim() === '';
+ }
+
+ _handleSaveKey(e: Event) {
+ if (
+ !this._computeSaveDisabled(this._messageText, this.comment, this.resolved)
+ ) {
+ e.preventDefault();
+ this._handleSave(e);
+ }
+ }
+
+ _handleEsc(e: Event) {
+ if (!this._messageText.length) {
+ e.preventDefault();
+ this._handleCancel(e);
+ }
+ }
+
+ _handleToggleCollapsed() {
+ this.collapsed = !this.collapsed;
+ }
+
+ _toggleCollapseClass(collapsed: boolean) {
+ if (collapsed) {
+ this.$.container.classList.add('collapsed');
+ } else {
+ this.$.container.classList.remove('collapsed');
+ }
+ }
+
+ @observe('comment.message')
+ _commentMessageChanged(message: string) {
+ this._messageText = message || '';
+ }
+
+ _messageTextChanged(_: string, oldValue: string) {
+ if (!this.comment || (this.comment && this.comment.id)) {
+ return;
+ }
+
+ const patchNum = this.comment.patch_set
+ ? this.comment.patch_set
+ : this._getPatchNum();
+ const {path, line, range} = this.comment;
+ if (path) {
+ this.debounce(
+ 'store',
+ () => {
+ const message = this._messageText;
+ if (this.changeNum === undefined) {
+ throw new Error('undefined changeNum');
+ }
+ const commentLocation: StorageLocation = {
+ changeNum: this.changeNum,
+ patchNum,
+ path,
+ line,
+ range,
+ };
+
+ if ((!message || !message.length) && oldValue) {
+ // If the draft has been modified to be empty, then erase the storage
+ // entry.
+ this.$.storage.eraseDraftComment(commentLocation);
+ } else {
+ this.$.storage.setDraftComment(commentLocation, message);
+ }
+ },
+ STORAGE_DEBOUNCE_INTERVAL
+ );
+ }
+ }
+
+ _handleAnchorClick(e: Event) {
+ e.preventDefault();
+ if (!this.comment) return;
+ this.dispatchEvent(
+ new CustomEvent('comment-anchor-tap', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ number: this.comment.line || FILE,
+ side: this.side,
+ },
+ })
+ );
+ }
+
+ _handleEdit(e: Event) {
+ e.preventDefault();
+ if (this.comment?.message) this._messageText = this.comment.message;
+ this.editing = true;
+ this.reporting.recordDraftInteraction();
+ }
+
+ _handleSave(e: Event) {
+ e.preventDefault();
+
+ // Ignore saves started while already saving.
+ if (this.disabled) {
+ return;
+ }
+ const timingLabel = this.comment?.id
+ ? REPORT_UPDATE_DRAFT
+ : REPORT_CREATE_DRAFT;
+ const timer = this.reporting.getTimer(timingLabel);
+ this.set('comment.__editing', false);
+ return this.save().then(() => {
+ timer.end();
+ });
+ }
+
+ _handleCancel(e: Event) {
+ e.preventDefault();
+
+ if (
+ !this.comment?.message ||
+ this.comment.message.trim().length === 0 ||
+ !this.comment.id
+ ) {
+ this._fireDiscard();
+ return;
+ }
+ this._messageText = this.comment.message;
+ this.editing = false;
+ }
+
+ _fireDiscard() {
+ this.cancelDebouncer('fire-update');
+ this.dispatchEvent(
+ new CustomEvent('comment-discard', {
+ detail: this._getEventPayload(),
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _handleFix() {
+ this.dispatchEvent(
+ new CustomEvent('create-fix-comment', {
+ bubbles: true,
+ composed: true,
+ detail: this._getEventPayload(),
+ })
+ );
+ }
+
+ _handleShowFix() {
+ this.dispatchEvent(
+ new CustomEvent('open-fix-preview', {
+ bubbles: true,
+ composed: true,
+ detail: this._getEventPayload(),
+ })
+ );
+ }
+
+ _hasNoFix(comment: UIComment) {
+ return !comment || !(comment as UIRobot).fix_suggestions;
+ }
+
+ _handleDiscard(e: Event) {
+ e.preventDefault();
+ this.reporting.recordDraftInteraction();
+
+ if (!this._messageText) {
+ this._discardDraft();
+ return;
+ }
+
+ this._openOverlay(this.confirmDiscardOverlay).then(() => {
+ const dialog = this.confirmDiscardOverlay?.querySelector(
+ '#confirmDiscardDialog'
+ ) as GrDialog | null;
+ if (dialog) dialog.resetFocus();
+ });
+ }
+
+ _handleConfirmDiscard(e: Event) {
+ e.preventDefault();
+ const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
+ this._closeConfirmDiscardOverlay();
+ return this._discardDraft().then(() => {
+ timer.end();
+ });
+ }
+
+ _discardDraft() {
+ if (!this.comment) return Promise.reject(new Error('undefined comment'));
+ if (!isDraft(this.comment)) {
+ return Promise.reject(new Error('Cannot discard a non-draft comment.'));
+ }
+ this.discarding = true;
+ this.editing = false;
+ this.disabled = true;
+ this._eraseDraftComment();
+
+ if (!this.comment.id) {
+ this.disabled = false;
+ this._fireDiscard();
+ return Promise.resolve();
+ }
+
+ this._xhrPromise = this._deleteDraft(this.comment)
+ .then(response => {
+ this.disabled = false;
+ if (!response.ok) {
+ this.discarding = false;
+ }
+
+ this._fireDiscard();
+ return response;
+ })
+ .catch(err => {
+ this.disabled = false;
+ throw err;
+ });
+
+ return this._xhrPromise;
+ }
+
+ _closeConfirmDiscardOverlay() {
+ this._closeOverlay(this.confirmDiscardOverlay);
+ }
+
+ _getSavingMessage(numPending: number, requestFailed?: boolean) {
+ if (requestFailed) {
+ return UNSAVED_MESSAGE;
+ }
+ if (numPending === 0) {
+ return SAVED_MESSAGE;
+ }
+ return [
+ SAVING_MESSAGE,
+ numPending,
+ numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
+ ].join(' ');
+ }
+
+ _showStartRequest() {
+ const numPending = ++this._numPendingDraftRequests.number;
+ this._updateRequestToast(numPending);
+ }
+
+ _showEndRequest() {
+ const numPending = --this._numPendingDraftRequests.number;
+ this._updateRequestToast(numPending);
+ }
+
+ _handleFailedDraftRequest() {
+ this._numPendingDraftRequests.number--;
+
+ // Cancel the debouncer so that error toasts from the error-manager will
+ // not be overridden.
+ this.cancelDebouncer('draft-toast');
+ this._updateRequestToast(
+ this._numPendingDraftRequests.number,
+ /* requestFailed=*/ true
+ );
+ }
+
+ _updateRequestToast(numPending: number, requestFailed?: boolean) {
+ const message = this._getSavingMessage(numPending, requestFailed);
+ this.debounce(
+ 'draft-toast',
+ () => {
+ // Note: the event is fired on the body rather than this element because
+ // this element may not be attached by the time this executes, in which
+ // case the event would not bubble.
+ document.body.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ },
+ TOAST_DEBOUNCE_INTERVAL
+ );
+ }
+
+ _handleDraftFailure() {
+ this.$.container.classList.add('unableToSave');
+ this._unableToSave = true;
+ this._handleFailedDraftRequest();
+ }
+
+ _saveDraft(draft?: UIComment) {
+ if (!draft || this.changeNum === undefined || this.patchNum === undefined) {
+ throw new Error('undefined draft or changeNum or patchNum');
+ }
+ this._showStartRequest();
+ return this.$.restAPI
+ .saveDiffDraft(this.changeNum, this.patchNum, draft)
+ .then(result => {
+ if (result.ok) {
+ // remove
+ this._unableToSave = false;
+ this.$.container.classList.remove('unableToSave');
+ this._showEndRequest();
+ } else {
+ this._handleDraftFailure();
+ }
+ return result;
+ })
+ .catch(err => {
+ this._handleDraftFailure();
+ throw err;
+ });
+ }
+
+ _deleteDraft(draft: UIComment) {
+ if (this.changeNum === undefined || this.patchNum === undefined) {
+ throw new Error('undefined changeNum or patchNum');
+ }
+ this._showStartRequest();
+ if (!draft.id) throw new Error('Missing id in comment draft.');
+ return this.$.restAPI
+ .deleteDiffDraft(this.changeNum, this.patchNum, {id: draft.id})
+ .then(result => {
+ if (result.ok) {
+ this._showEndRequest();
+ } else {
+ this._handleFailedDraftRequest();
+ }
+ return result;
+ });
+ }
+
+ _getPatchNum(): PatchSetNum {
+ const patchNum = this.isOnParent()
+ ? ('PARENT' as PatchSetNum)
+ : this.patchNum;
+ if (patchNum === undefined) throw new Error('patchNum undefined');
+ return patchNum;
+ }
+
+ @observe('changeNum', 'patchNum', 'comment')
+ _loadLocalDraft(
+ changeNum: number,
+ patchNum?: PatchSetNum,
+ comment?: UIComment
+ ) {
+ // Polymer 2: check for undefined
+ if ([changeNum, patchNum, comment].includes(undefined)) {
+ return;
+ }
+
+ // Only apply local drafts to comments that haven't been saved
+ // remotely, and haven't been given a default message already.
+ //
+ // Don't get local draft if there is another comment that is currently
+ // in an editing state.
+ if (
+ !comment ||
+ comment.id ||
+ comment.message ||
+ comment.__otherEditing ||
+ !comment.path
+ ) {
+ if (comment) delete comment.__otherEditing;
+ return;
+ }
+
+ const draft = this.$.storage.getDraftComment({
+ changeNum,
+ patchNum: this._getPatchNum(),
+ path: comment.path,
+ line: comment.line,
+ range: comment.range,
+ });
+
+ if (draft) {
+ this.set('comment.message', draft.message);
+ }
+ }
+
+ _handleToggleResolved() {
+ this.reporting.recordDraftInteraction();
+ this.resolved = !this.resolved;
+ // Modify payload instead of this.comment, as this.comment is passed from
+ // the parent by ref.
+ const payload = this._getEventPayload();
+ if (!payload.comment) {
+ throw new Error('comment not defined in payload');
+ }
+ payload.comment.unresolved = !this.$.resolvedCheckbox.checked;
+ this.dispatchEvent(
+ new CustomEvent('comment-update', {
+ detail: payload,
+ composed: true,
+ bubbles: true,
+ })
+ );
+ if (!this.editing) {
+ // Save the resolved state immediately.
+ this.save(payload.comment);
+ }
+ }
+
+ _handleCommentDelete() {
+ this._openOverlay(this.confirmDeleteOverlay);
+ }
+
+ _handleCancelDeleteComment() {
+ this._closeOverlay(this.confirmDeleteOverlay);
+ }
+
+ _openOverlay(overlay?: GrOverlay | null) {
+ if (!overlay) {
+ return Promise.reject(new Error('undefined overlay'));
+ }
+ getRootElement().appendChild(overlay);
+ return overlay.open();
+ }
+
+ _computeHideRunDetails(comment: UIRobot, collapsed: boolean) {
+ if (!comment) return true;
+ return !(comment.robot_id && comment.url && !collapsed);
+ }
+
+ _closeOverlay(overlay?: GrOverlay | null) {
+ if (overlay) {
+ getRootElement().removeChild(overlay);
+ overlay.close();
+ }
+ }
+
+ _handleConfirmDeleteComment() {
+ const dialog = this.confirmDeleteOverlay?.querySelector(
+ '#confirmDeleteComment'
+ ) as GrConfirmDeleteCommentDialog | null;
+ if (!dialog || !dialog.message) {
+ throw new Error('missing confirm delete dialog');
+ }
+ if (
+ !this.comment ||
+ !this.comment.id ||
+ this.changeNum === undefined ||
+ this.patchNum === undefined
+ ) {
+ throw new Error('undefined comment or id or changeNum or patchNum');
+ }
+ this.$.restAPI
+ .deleteComment(
+ this.changeNum,
+ this.patchNum,
+ this.comment.id,
+ dialog.message
+ )
+ .then(newComment => {
+ this._handleCancelDeleteComment();
+ this.comment = newComment;
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-comment': GrComment;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
index a9d2491..c021461 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_html.ts
@@ -112,7 +112,7 @@
.draft .draftTooltip {
display: inline;
}
- .draft:not(.editing) .save,
+ .draft:not(.editing):not(.unableToSave) .save,
.draft:not(.editing) .cancel {
display: none;
}
@@ -235,21 +235,34 @@
color: var(--deemphasized-text-color);
margin-left: var(--spacing-s);
}
+ .headerLeft gr-account-label {
+ --gr-account-label-text-style: {
+ font-weight: var(--font-weight-bold);
+ }
+ --account-max-length: 120px;
+ width: 120px;
+ }
+ .draft gr-account-label {
+ width: unset;
+ }
</style>
<div id="container" class="container">
<div class="header" id="header" on-click="_handleToggleCollapsed">
<div class="headerLeft">
- <span class="authorName">
- [[_computeAuthorName(comment, _serverConfig)]]
- </span>
+ <gr-account-label
+ account="[[_getAuthor(comment, _selfAccount)]]"
+ class$="[[_computeAccountLabelClass(draft)]]"
+ hide-status=""
+ >
+ </gr-account-label>
<gr-tooltip-content
class="draftTooltip"
has-tooltip=""
- title="This draft is only visible to you. To publish drafts, click the 'Reply' or 'Start review' button at the top of the change or press the 'A' key."
+ title="[[_computeDraftTooltip(_unableToSave)]]"
max-width="20em"
show-icon=""
>
- <span class="draftLabel">DRAFT</span>
+ <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
</gr-tooltip-content>
</div>
<div class="headerMiddle">
@@ -267,6 +280,7 @@
</div>
<gr-button
id="deleteBtn"
+ title="Delete Comment"
link=""
class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
hidden$="[[isRobotComment]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
index eb82daa..10925af 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
@@ -18,6 +18,8 @@
import '../../../test/common-test-setup-karma.js';
import './gr-comment.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
+import {SpecialFilePath} from '../../../constants/constants.js';
const basicFixture = fixtureFromElement('gr-comment');
@@ -38,7 +40,14 @@
setup(() => {
stub('gr-rest-api-interface', {
- getAccount() { return Promise.resolve(null); },
+ getAccount() {
+ return Promise.resolve({
+ email: 'dhruvsri@google.com',
+ name: 'Dhruv Srivastava',
+ _account_id: 1083225,
+ avatars: [{url: 'abc', height: 32}],
+ });
+ },
});
element = basicFixture.instantiate();
element.comment = {
@@ -97,7 +106,7 @@
element.side = 'PARENT';
const stub = sinon.stub();
element.addEventListener('comment-anchor-tap', stub);
- flushAsynchronousOperations();
+ flush();
const dateEl = element.shadowRoot
.querySelector('.date');
assert.ok(dateEl);
@@ -141,6 +150,7 @@
email: 'tenn1sballchaser@aol.com',
},
line: 5,
+ path: 'test',
};
flush(() => {
assert.isTrue(loadSpy.called);
@@ -190,7 +200,7 @@
element._messageText = 'test';
sinon.stub(element, '_handleCancel');
sinon.stub(element, '_handleSave');
- flushAsynchronousOperations();
+ flush();
});
suite('when text is empty', () => {
@@ -307,8 +317,12 @@
});
test('create', () => {
+ element.patchNum = 1;
element.comment = {};
return element._handleSave(mockEvent).then(() => {
+ assert.equal(element.shadowRoot.querySelector('gr-account-label').
+ shadowRoot.querySelector('span.name').innerText.trim(),
+ 'Dhruv Srivastava');
assert.isTrue(endStub.calledOnce);
assert.isTrue(getTimerStub.calledOnce);
assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
@@ -339,7 +353,7 @@
const reportStub = sinon.stub(element.reporting,
'recordDraftInteraction');
element.draft = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('.edit'));
assert.isTrue(reportStub.calledOnce);
@@ -349,11 +363,83 @@
const reportStub = sinon.stub(element.reporting,
'recordDraftInteraction');
element.draft = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('.discard'));
assert.isTrue(reportStub.calledOnce);
});
+
+ test('failed save draft request', done => {
+ element.draft = true;
+ element.changeNum = 1;
+ element.patchNum = 1;
+ const updateRequestStub = sinon.stub(element, '_updateRequestToast');
+ const diffDraftStub =
+ sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
+ Promise.resolve({ok: false}));
+ element._saveDraft({id: 'abc_123'});
+ flush(() => {
+ let args = updateRequestStub.lastCall.args;
+ assert.deepEqual(args, [0, true]);
+ assert.equal(element._getSavingMessage(...args),
+ __testOnly_UNSAVED_MESSAGE);
+ assert.equal(element.shadowRoot.querySelector('.draftLabel').innerText,
+ 'DRAFT(Failed to save)');
+ assert.isTrue(isVisible(element.shadowRoot
+ .querySelector('.save')), 'save is visible');
+ diffDraftStub.returns(
+ Promise.resolve({ok: true}));
+ element._saveDraft({id: 'abc_123'});
+ flush(() => {
+ args = updateRequestStub.lastCall.args;
+ assert.deepEqual(args, [0]);
+ assert.equal(element._getSavingMessage(...args),
+ 'All changes saved');
+ assert.equal(element.shadowRoot.querySelector('.draftLabel')
+ .innerText, 'DRAFT');
+ assert.isFalse(isVisible(element.shadowRoot
+ .querySelector('.save')), 'save is not visible');
+ assert.isFalse(element._unableToSave);
+ done();
+ });
+ });
+ });
+
+ test('failed save draft request with promise failure', done => {
+ element.draft = true;
+ element.changeNum = 1;
+ element.patchNum = 1;
+ const updateRequestStub = sinon.stub(element, '_updateRequestToast');
+ const diffDraftStub =
+ sinon.stub(element.$.restAPI, 'saveDiffDraft').returns(
+ Promise.reject(new Error()));
+ element._saveDraft({id: 'abc_123'});
+ flush(() => {
+ let args = updateRequestStub.lastCall.args;
+ assert.deepEqual(args, [0, true]);
+ assert.equal(element._getSavingMessage(...args),
+ __testOnly_UNSAVED_MESSAGE);
+ assert.equal(element.shadowRoot.querySelector('.draftLabel').innerText,
+ 'DRAFT(Failed to save)');
+ assert.isTrue(isVisible(element.shadowRoot
+ .querySelector('.save')), 'save is visible');
+ diffDraftStub.returns(
+ Promise.resolve({ok: true}));
+ element._saveDraft({id: 'abc_123'});
+ flush(() => {
+ args = updateRequestStub.lastCall.args;
+ assert.deepEqual(args, [0]);
+ assert.equal(element._getSavingMessage(...args),
+ 'All changes saved');
+ assert.equal(element.shadowRoot.querySelector('.draftLabel')
+ .innerText, 'DRAFT');
+ assert.isFalse(isVisible(element.shadowRoot
+ .querySelector('.save')), 'save is not visible');
+ assert.isFalse(element._unableToSave);
+ done();
+ });
+ });
+ });
});
suite('gr-comment draft tests', () => {
@@ -414,7 +500,7 @@
.querySelector('.robotActions').hasAttribute('hidden'));
element.draft = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(isVisible(element.shadowRoot
.querySelector('.edit')), 'edit is visible');
assert.isTrue(isVisible(element.shadowRoot
@@ -431,7 +517,7 @@
.querySelector('.robotActions').hasAttribute('hidden'));
element.editing = true;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(isVisible(element.shadowRoot
.querySelector('.edit')), 'edit is not visible');
assert.isFalse(isVisible(element.shadowRoot
@@ -449,7 +535,7 @@
element.draft = false;
element.editing = false;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(isVisible(element.shadowRoot
.querySelector('.edit')), 'edit is not visible');
assert.isFalse(isVisible(element.shadowRoot
@@ -467,7 +553,7 @@
element.comment.id = 'foo';
element.draft = true;
element.editing = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(isVisible(element.shadowRoot
.querySelector('.cancel')), 'cancel is visible');
assert.isFalse(element.shadowRoot
@@ -497,13 +583,13 @@
element.set(['comment', 'robot_run_id'], 'text');
element.editing = false;
element.collapsed = false;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.shadowRoot
.querySelector('.robotRun.link').textContent === 'Run Details');
// A robot comment with run ID and url should display a link.
element.set(['comment', 'url'], '/path/to/run');
- flushAsynchronousOperations();
+ flush();
assert.notEqual(getComputedStyle(element.shadowRoot
.querySelector('.robotRun.link')).display,
'none');
@@ -541,10 +627,10 @@
// When the edit button is pressed, should still see the actions
// and also textarea
element.draft = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('.edit'));
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.collapsed);
assert.isFalse(isVisible(element.shadowRoot
.querySelector('gr-formatted-text')),
@@ -590,13 +676,12 @@
});
test('robot comment layout', done => {
- const comment = Object.assign({
- robot_id: 'happy_robot_id',
+ const comment = {robot_id: 'happy_robot_id',
url: '/robot/comment',
author: {
name: 'Happy Robot',
- },
- }, element.comment);
+ display_name: 'Display name Robot',
+ }, ...element.comment};
element.comment = comment;
element.collapsed = false;
flush(() => {
@@ -610,15 +695,16 @@
assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
const robotServiceName = element.shadowRoot
- .querySelector('.authorName');
- assert.equal(robotServiceName.textContent.trim(), 'happy_robot_id');
+ .querySelector('gr-account-label')
+ .shadowRoot.querySelector('span.name');
+ assert.equal(robotServiceName.textContent.trim(), 'Display name Robot');
const authorName = element.shadowRoot
.querySelector('.robotId');
assert.isTrue(authorName.innerText === 'Happy Robot');
element.collapsed = true;
- flushAsynchronousOperations();
+ flush();
runIdMessage = element.shadowRoot
.querySelector('.runIdMessage');
assert.isTrue(runIdMessage.hidden);
@@ -627,26 +713,46 @@
});
test('author name fallback to email', done => {
- const comment = Object.assign({
- url: '/robot/comment',
+ const comment = {url: '/robot/comment',
author: {
email: 'test@test.com',
- },
- }, element.comment);
+ }, ...element.comment};
element.comment = comment;
element.collapsed = false;
flush(() => {
const authorName = element.shadowRoot
- .querySelector('.authorName');
+ .querySelector('gr-account-label')
+ .shadowRoot.querySelector('span.name');
assert.equal(authorName.innerText.trim(), 'test@test.com');
done();
});
});
+ test('patchset level comment', done => {
+ const comment = {...element.comment,
+ path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS, line: undefined,
+ range: undefined};
+ element.comment = comment;
+ flushAsynchronousOperations();
+ MockInteractions.tap(element.shadowRoot
+ .querySelector('.edit'));
+ assert.isTrue(element.editing);
+
+ element._messageText = 'hello world';
+ const eraseMessageDraftSpy = sinon.spy(element.$.storage,
+ 'eraseDraftComment');
+ const mockEvent = {preventDefault: sinon.stub()};
+ element._handleSave(mockEvent);
+ flush(() => {
+ assert.isTrue(eraseMessageDraftSpy.called);
+ done();
+ });
+ });
+
test('draft creation/cancellation', done => {
assert.isFalse(element.editing);
element.draft = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('.edit'));
assert.isTrue(element.editing);
@@ -679,7 +785,7 @@
.querySelector('.cancel'));
element.flushDebouncer('fire-update');
element._messageText = '';
- flushAsynchronousOperations();
+ flush();
MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
});
@@ -768,7 +874,7 @@
});
element._messageText = 'is that the horse from horsing around??';
element.editing = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.pressAndReleaseKeyOn(
element.textarea.$.textarea.textarea,
83, 'ctrl'); // 'ctrl + s'
@@ -779,7 +885,7 @@
const cancelDebounce = sinon.stub(element, 'cancelDebouncer');
element.draft = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('.edit'));
element._messageText = 'good news, everyone!';
@@ -845,7 +951,7 @@
const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
element.showActions = true;
element.draft = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.$.header);
MockInteractions.tap(element.shadowRoot
.querySelector('.edit'));
@@ -936,7 +1042,7 @@
const storeStub = sinon.stub(element.$.storage, 'setDraftComment');
const eraseStub = sinon.stub(element.$.storage, 'eraseDraftComment');
element._messageText = 'test text';
- flushAsynchronousOperations();
+ flush();
element.flushDebouncer('store');
assert.isTrue(storeStub.called);
@@ -951,7 +1057,7 @@
const discardSpy = sinon.spy(element, '_fireDiscard');
const storeStub = sinon.stub(element.$.storage, 'setDraftComment');
element._messageText = 'test text';
- flushAsynchronousOperations();
+ flush();
element.flushDebouncer('store');
assert.isFalse(storeStub.called);
@@ -975,7 +1081,7 @@
});
element.isRobotComment = true;
element.comments = [element.comment];
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('.fix'));
@@ -1051,7 +1157,7 @@
},
];
element.comment = element.comments[0];
- flushAsynchronousOperations();
+ flush();
assert.isNull(element.shadowRoot
.querySelector('robotActions gr-button'));
});
@@ -1113,7 +1219,7 @@
},
];
element.comment = element.comments[0];
- flushAsynchronousOperations();
+ flush();
assert.isNotNull(element.shadowRoot
.querySelector('.robotActions gr-button'));
});
@@ -1125,7 +1231,7 @@
});
element.comment = {fix_suggestions: [{}]};
element.isRobotComment = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('.show-fix'));
@@ -1194,7 +1300,7 @@
MockInteractions.tap(element.shadowRoot
.querySelector('.respectfulReviewTip .close'));
- flushAsynchronousOperations();
+ flush();
assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
done();
});
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
deleted file mode 100644
index f9ce12e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
+++ /dev/null
@@ -1,73 +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 '../gr-dialog/gr-dialog.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-delete-comment-dialog_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrConfirmDeleteCommentDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-confirm-delete-comment-dialog'; }
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- static get properties() {
- return {
- message: String,
- };
- }
-
- resetFocus() {
- this.$.messageInput.textarea.focus();
- }
-
- _handleConfirmTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm', {
- detail: {reason: this.message},
- composed: true, bubbles: false,
- }));
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {
- composed: true, bubbles: false,
- }));
- }
-}
-
-customElements.define(GrConfirmDeleteCommentDialog.is,
- GrConfirmDeleteCommentDialog);
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
new file mode 100644
index 0000000..a636a07
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -0,0 +1,89 @@
+/**
+ * @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 '../gr-dialog/gr-dialog';
+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-delete-comment-dialog_html';
+import {property, customElement} from '@polymer/decorators';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-confirm-delete-comment-dialog': GrConfirmDeleteCommentDialog;
+ }
+}
+export interface GrConfirmDeleteCommentDialog {
+ $: {
+ messageInput: IronAutogrowTextareaElement;
+ };
+}
+
+@customElement('gr-confirm-delete-comment-dialog')
+export class GrConfirmDeleteCommentDialog extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ static get is() {
+ return 'gr-confirm-delete-comment-dialog';
+ }
+ /**
+ * Fired when the confirm button is pressed.
+ *
+ * @event confirm
+ */
+
+ /**
+ * Fired when the cancel button is pressed.
+ *
+ * @event cancel
+ */
+
+ @property({type: String})
+ message?: string;
+
+ resetFocus() {
+ this.$.messageInput.textarea.focus();
+ }
+
+ _handleConfirmTap(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('confirm', {
+ detail: {reason: this.message},
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleCancelTap(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
deleted file mode 100644
index 39c149f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
+++ /dev/null
@@ -1,85 +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-input/iron-input.js';
-import '../../../styles/shared-styles.js';
-import '../gr-button/gr-button.js';
-import '../gr-icons/gr-icons.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-copy-clipboard_html.js';
-
-const COPY_TIMEOUT_MS = 1000;
-
-/** @extends PolymerElement */
-class GrCopyClipboard extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-copy-clipboard'; }
-
- static get properties() {
- return {
- text: String,
- buttonTitle: String,
- hasTooltip: {
- type: Boolean,
- value: false,
- },
- hideInput: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- focusOnCopy() {
- this.$.button.focus();
- }
-
- _computeInputClass(hideInput) {
- return hideInput ? 'hideInput' : '';
- }
-
- _handleInputClick(e) {
- e.preventDefault();
- dom(e).rootTarget.select();
- }
-
- _copyToClipboard(e) {
- e.preventDefault();
- e.stopPropagation();
-
- if (this.hideInput) {
- this.$.input.style.display = 'block';
- }
- this.$.input.focus();
- this.$.input.select();
- document.execCommand('copy');
- if (this.hideInput) {
- this.$.input.style.display = 'none';
- }
- this.$.icon.icon = 'gr-icons:check';
- this.async(
- () => this.$.icon.icon = 'gr-icons:content-copy',
- COPY_TIMEOUT_MS);
- }
-}
-
-customElements.define(GrCopyClipboard.is, GrCopyClipboard);
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
new file mode 100644
index 0000000..47dacc3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -0,0 +1,95 @@
+/**
+ * @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-input/iron-input';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+import '../gr-icons/gr-icons';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-copy-clipboard_html';
+import {GrButton} from '../gr-button/gr-button';
+import {customElement, property} from '@polymer/decorators';
+import {IronIconElement} from '@polymer/iron-icon';
+
+const COPY_TIMEOUT_MS = 1000;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-copy-clipboard': GrCopyClipboard;
+ }
+}
+
+export interface GrCopyClipboard {
+ $: {button: GrButton; icon: IronIconElement; input: HTMLInputElement};
+}
+
+/** @extends PolymerElement */
+@customElement('gr-copy-clipboard')
+export class GrCopyClipboard extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ text: string | undefined;
+
+ @property({type: String})
+ buttonTitle: string | undefined;
+
+ @property({type: Boolean})
+ hasTooltip = false;
+
+ @property({type: Boolean})
+ hideInput = false;
+
+ focusOnCopy() {
+ this.$.button.focus();
+ }
+
+ _computeInputClass(hideInput: boolean) {
+ return hideInput ? 'hideInput' : '';
+ }
+
+ _handleInputClick(e: MouseEvent) {
+ e.preventDefault();
+ ((dom(e) as EventApi).rootTarget as HTMLInputElement).select();
+ }
+
+ _copyToClipboard(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (this.hideInput) {
+ this.$.input.style.display = 'block';
+ }
+ this.$.input.focus();
+ this.$.input.select();
+ document.execCommand('copy');
+ if (this.hideInput) {
+ this.$.input.style.display = 'none';
+ }
+ this.$.icon.icon = 'gr-icons:check';
+ this.async(
+ () => (this.$.icon.icon = 'gr-icons:content-copy'),
+ COPY_TIMEOUT_MS
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
index 58c00da..55b2483 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
@@ -24,12 +24,11 @@
suite('gr-copy-clipboard tests', () => {
let element;
- setup(done => {
+ setup(async () => {
element = basicFixture.instantiate();
element.text = `git fetch http://gerrit@localhost:8080/a/test-project
refs/changes/05/5/1 && git checkout FETCH_HEAD`;
- flushAsynchronousOperations();
- flush(done);
+ await flush();
});
test('copy to clipboard', () => {
@@ -67,7 +66,7 @@
assert.notEqual(getComputedStyle(element.$.input).display, 'none');
element.hideInput = true;
- flushAsynchronousOperations();
+ flush();
assert.equal(getComputedStyle(element.$.input).display, 'none');
});
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js
deleted file mode 100644
index 1c3a689..0000000
--- a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.js
+++ /dev/null
@@ -1,56 +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.
- */
-export const GrCountStringFormatter = {
- /**
- * Returns a count plus string that is pluralized when necessary.
- *
- * @param {number} count
- * @param {string} noun
- * @return {string}
- */
- computePluralString(count, noun) {
- return this.computeString(count, noun) + (count > 1 ? 's' : '');
- },
-
- /**
- * Returns a count plus string that is not pluralized.
- *
- * @param {number} count
- * @param {string} noun
- * @return {string}
- */
- computeString(count, noun) {
- if (count === 0) {
- return '';
- }
- return count + ' ' + noun;
- },
-
- /**
- * Returns a count plus arbitrary text.
- *
- * @param {number} count
- * @param {string} text
- * @return {string}
- */
- computeShortString(count, text) {
- if (count === 0) {
- return '';
- }
- return count + text;
- },
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
new file mode 100644
index 0000000..bbbce16
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-count-string-formatter/gr-count-string-formatter.ts
@@ -0,0 +1,44 @@
+/**
+ * @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.
+ */
+export const GrCountStringFormatter = {
+ /**
+ * Returns a count plus string that is pluralized when necessary.
+ */
+ computePluralString(count: number, noun: string): string {
+ return this.computeString(count, noun) + (count > 1 ? 's' : '');
+ },
+
+ /**
+ * Returns a count plus string that is not pluralized.
+ */
+ computeString(count: number, noun: string): string {
+ if (count === 0) {
+ return '';
+ }
+ return `${count} ${noun}`;
+ },
+
+ /**
+ * Returns a count plus arbitrary text.
+ */
+ computeShortString(count: number, text: string): string {
+ if (count === 0) {
+ return '';
+ }
+ return `${count}${text}`;
+ },
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
deleted file mode 100644
index de9dcc2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ /dev/null
@@ -1,477 +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 {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-cursor-manager_html.js';
-import {ScrollMode} from '../../../constants/constants.js';
-
-// Time in which pressing n key again after the toast navigates to next file
-const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
-
-/** @extends PolymerElement */
-class GrCursorManager extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-cursor-manager'; }
-
- static get properties() {
- return {
- stops: {
- type: Array,
- value() {
- return [];
- },
- observer: '_updateIndex',
- },
- /**
- * @type {?Object}
- */
- target: {
- type: Object,
- notify: true,
- observer: '_scrollToTarget',
- },
- /**
- * The height of content intended to be included with the target.
- *
- * @type {?number}
- */
- _targetHeight: Number,
-
- /**
- * The index of the current target (if any). -1 otherwise.
- */
- index: {
- type: Number,
- value: -1,
- notify: true,
- },
-
- /**
- * The class to apply to the current target. Use null for no class.
- */
- cursorTargetClass: {
- type: String,
- value: null,
- },
-
- /**
- * The scroll behavior for the cursor. Values are 'never' and
- * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
- * the viewport.
- * TODO (beckysiegel) figure out why it can be undefined
- *
- * @type {string|undefined}
- */
- scrollMode: {
- type: String,
- value: ScrollMode.NEVER,
- },
-
- /**
- * When true, will call element.focus() during scrolling.
- */
- focusOnMove: {
- type: Boolean,
- value: false,
- },
-
- /**
- * The scrollTopMargin defines height of invisible area at the top
- * of the page. If cursor locates inside this margin - it is
- * not visible, because it is covered by some other element.
- */
- scrollTopMargin: {
- type: Number,
- value: 0,
- },
- };
- }
-
- /** @override */
- detached() {
- super.detached();
- this.unsetCursor();
- }
-
- /**
- * Move the cursor forward. Clipped to the ends of the stop list.
- *
- * @param {!Function=} opt_condition Optional stop condition. If a condition
- * is passed the cursor will continue to move in the specified direction
- * until the condition is met.
- * @param {!Function=} opt_getTargetHeight Optional function to calculate the
- * height of the target's 'section'. The height of the target itself is
- * sometimes different, used by the diff cursor.
- * @param {boolean=} opt_clipToTop When none of the next indices match, move
- * back to first instead of to last.
- * @param {boolean=} opt_navigateToNextFile Navigate to next unreviewed file
- * if user presses next on the last diff chunk
- * @private
- */
-
- next(opt_condition, opt_getTargetHeight, opt_clipToTop,
- opt_navigateToNextFile) {
- this._moveCursor(1, opt_condition, opt_getTargetHeight, opt_clipToTop,
- opt_navigateToNextFile);
- }
-
- previous(opt_condition) {
- this._moveCursor(-1, opt_condition);
- }
-
- /**
- * Move the cursor to the row which is the closest to the viewport center
- * in vertical direction.
- * The method uses IntersectionObservers API. If browser
- * doesn't support this API the method does nothing
- *
- * @param {!Function=} opt_condition Optional condition. If a condition
- * is passed only stops which meet conditions are taken into account.
- */
- moveToVisibleArea(opt_condition) {
- if (!this.stops || !this._isIntersectionObserverSupported()) {
- return;
- }
- const filteredStops = opt_condition ? this.stops.filter(opt_condition)
- : this.stops;
- const dims = this._getWindowDims();
- const windowCenter =
- Math.round((dims.innerHeight + this.scrollTopMargin) / 2);
-
- let closestToTheCenter = null;
- let minDistanceToCenter = null;
- let unobservedCount = filteredStops.length;
-
- const observer = new IntersectionObserver(entries => {
- // This callback is called for the first time immediately.
- // Typically it gets all observed stops at once, but
- // sometimes can get them in several chunks.
- entries.forEach(entry => {
- observer.unobserve(entry.target);
-
- // In Edge it is recommended to use intersectionRatio instead of
- // isIntersecting.
- const isInsideViewport =
- entry.isIntersecting || entry.intersectionRatio > 0;
- if (!isInsideViewport) {
- return;
- }
- const center = entry.boundingClientRect.top + Math.round(
- entry.boundingClientRect.height / 2);
- const distanceToWindowCenter = Math.abs(center - windowCenter);
- if (minDistanceToCenter === null ||
- distanceToWindowCenter < minDistanceToCenter) {
- closestToTheCenter = entry.target;
- minDistanceToCenter = distanceToWindowCenter;
- }
- });
- unobservedCount -= entries.length;
- if (unobservedCount == 0 && closestToTheCenter) {
- // set cursor when all stops were observed.
- // In most cases the target is visible, so scroll is not
- // needed. But in rare cases the target can become invisible
- // at this point (due to some scrolling in window).
- // To avoid jumps set noScroll options.
- this.setCursor(closestToTheCenter, true);
- }
- });
- filteredStops.forEach(stop => {
- observer.observe(stop);
- });
- }
-
- _isIntersectionObserverSupported() {
- // The copy of this method exists in gr-app-element.js under the
- // name _isCursorManagerSupportMoveToVisibleLine
- // If you update this method, you must update gr-app-element.js
- // as well.
- return 'IntersectionObserver' in window;
- }
-
- /**
- * Set the cursor to an arbitrary element.
- *
- * @param {!HTMLElement} element
- * @param {boolean=} opt_noScroll prevent any potential scrolling in response
- * setting the cursor.
- */
- setCursor(element, opt_noScroll) {
- let behavior;
- if (opt_noScroll) {
- behavior = this.scrollMode;
- this.scrollMode = ScrollMode.NEVER;
- }
-
- this.unsetCursor();
- this.target = element;
- this._updateIndex();
- this._decorateTarget();
-
- if (opt_noScroll) { this.scrollMode = behavior; }
- }
-
- unsetCursor() {
- this._unDecorateTarget();
- this.index = -1;
- this.target = null;
- this._targetHeight = null;
- }
-
- isAtStart() {
- return this.index === 0;
- }
-
- isAtEnd() {
- return this.index === this.stops.length - 1;
- }
-
- moveToStart() {
- if (this.stops.length) {
- this.setCursor(this.stops[0]);
- }
- }
-
- moveToEnd() {
- if (this.stops.length) {
- this.setCursor(this.stops[this.stops.length - 1]);
- }
- }
-
- setCursorAtIndex(index, opt_noScroll) {
- this.setCursor(this.stops[index], opt_noScroll);
- }
-
- /**
- * Move the cursor forward or backward by delta. Clipped to the beginning or
- * end of stop list.
- *
- * @param {number} delta either -1 or 1.
- * @param {!Function=} opt_condition Optional stop condition. If a condition
- * is passed the cursor will continue to move in the specified direction
- * until the condition is met.
- * @param {!Function=} opt_getTargetHeight Optional function to calculate the
- * height of the target's 'section'. The height of the target itself is
- * sometimes different, used by the diff cursor.
- * @param {boolean=} opt_clipToTop When none of the next indices match, move
- * back to first instead of to last.
- * @param {boolean=} opt_navigateToNextFile Navigate to next unreviewed file
- * if user presses next on the last diff chunk
- * @private
- */
- _moveCursor(delta, opt_condition, opt_getTargetHeight, opt_clipToTop,
- opt_navigateToNextFile) {
- if (!this.stops.length) {
- this.unsetCursor();
- return;
- }
-
- this._unDecorateTarget();
-
- const newIndex = this._getNextindex(delta, opt_condition, opt_clipToTop);
-
- let newTarget = null;
- if (newIndex !== -1) {
- newTarget = this.stops[newIndex];
- }
-
- /*
- * If user presses n on the last diff chunk, show a toast informing user
- * that pressing n again will navigate them to next unreviewed file.
- * If click happens within the time limit, then navigate to next file
- */
- if (opt_navigateToNextFile && this.index === newIndex) {
- if (newIndex === this.stops.length - 1) {
- if (this._lastDisplayedNavigateToNextFileToast && (Date.now() -
- this._lastDisplayedNavigateToNextFileToast <=
- NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS)) {
- // reset for next file
- this._lastDisplayedNavigateToNextFileToast = null;
- this.dispatchEvent(new CustomEvent(
- 'navigate-to-next-unreviewed-file', {
- composed: true, bubbles: true,
- }));
- return;
- }
- this._lastDisplayedNavigateToNextFileToast = Date.now();
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Press n again to navigate to next unreviewed file',
- },
- composed: true, bubbles: true,
- }));
- return;
- }
- }
-
- this.index = newIndex;
- this.target = newTarget;
-
- if (!this.target) { return; }
-
- if (opt_getTargetHeight) {
- this._targetHeight = opt_getTargetHeight(newTarget);
- } else {
- this._targetHeight = newTarget.scrollHeight;
- }
-
- if (this.focusOnMove) { this.target.focus(); }
-
- this._decorateTarget();
- }
-
- _decorateTarget() {
- if (this.target && this.cursorTargetClass) {
- this.target.classList.add(this.cursorTargetClass);
- }
- }
-
- _unDecorateTarget() {
- if (this.target && this.cursorTargetClass) {
- this.target.classList.remove(this.cursorTargetClass);
- }
- }
-
- /**
- * Get the next stop index indicated by the delta direction.
- *
- * @param {number} delta either -1 or 1.
- * @param {!Function=} opt_condition Optional stop condition.
- * @param {boolean=} opt_clipToTop When none of the next indices match, move
- * back to first instead of to last.
- * @return {number} the new index.
- * @private
- */
- _getNextindex(delta, opt_condition, opt_clipToTop) {
- if (!this.stops.length) {
- return -1;
- }
- let newIndex = this.index;
- // If the cursor is not yet set and we are going backwards, start at the
- // back.
- if (this.index === -1 && delta < 0) {
- newIndex = this.stops.length;
- }
- do {
- newIndex = newIndex + delta;
- } while ((delta > 0 || newIndex > 0) &&
- (delta < 0 || newIndex < this.stops.length - 1) &&
- opt_condition && !opt_condition(this.stops[newIndex]));
-
- newIndex = Math.max(0, Math.min(this.stops.length - 1, newIndex));
-
- // If we failed to satisfy the condition:
- if (opt_condition && !opt_condition(this.stops[newIndex])) {
- if (delta < 0 || opt_clipToTop) {
- return 0;
- } else if (delta > 0) {
- return this.stops.length - 1;
- }
- return this.index;
- }
-
- return newIndex;
- }
-
- _updateIndex() {
- if (!this.target) {
- this.index = -1;
- return;
- }
-
- const newIndex = Array.prototype.indexOf.call(this.stops, this.target);
- if (newIndex === -1) {
- this.unsetCursor();
- } else {
- this.index = newIndex;
- }
- }
-
- /**
- * Calculate where the element is relative to the window.
- *
- * @param {!Object} target Target to scroll to.
- * @return {number} Distance to top of the target.
- */
- _getTop(target) {
- let top = target.offsetTop;
- for (let offsetParent = target.offsetParent;
- offsetParent;
- offsetParent = offsetParent.offsetParent) {
- top += offsetParent.offsetTop;
- }
- return top;
- }
-
- /**
- * @return {boolean}
- */
- _targetIsVisible(top) {
- const dims = this._getWindowDims();
- return this.scrollMode === ScrollMode.KEEP_VISIBLE &&
- top > (dims.pageYOffset + this.scrollTopMargin) &&
- top < dims.pageYOffset + dims.innerHeight;
- }
-
- _calculateScrollToValue(top, target) {
- const dims = this._getWindowDims();
- return top + this.scrollTopMargin - (dims.innerHeight / 3) +
- (target.offsetHeight / 2);
- }
-
- _scrollToTarget() {
- if (!this.target || this.scrollMode === ScrollMode.NEVER) {
- return;
- }
-
- const dims = this._getWindowDims();
- const top = this._getTop(this.target);
- const bottomIsVisible = this._targetHeight ?
- this._targetIsVisible(top + this._targetHeight) : true;
- const scrollToValue = this._calculateScrollToValue(top, this.target);
-
- if (this._targetIsVisible(top)) {
- // Don't scroll if either the bottom is visible or if the position that
- // would get scrolled to is higher up than the current position. This
- // would cause less of the target content to be displayed than is
- // already.
- if (bottomIsVisible || scrollToValue < dims.scrollY) {
- return;
- }
- }
-
- // Scroll the element to the middle of the window. Dividing by a third
- // instead of half the inner height feels a bit better otherwise the
- // element appears to be below the center of the window even when it
- // isn't.
- window.scrollTo(dims.scrollX, scrollToValue);
- }
-
- _getWindowDims() {
- return {
- scrollX: window.scrollX,
- scrollY: window.scrollY,
- innerHeight: window.innerHeight,
- pageYOffset: window.pageYOffset,
- };
- }
-}
-
-customElements.define(GrCursorManager.is, GrCursorManager);
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
new file mode 100644
index 0000000..9fdbb34
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.ts
@@ -0,0 +1,477 @@
+/**
+ * @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 {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-cursor-manager_html';
+import {ScrollMode} from '../../../constants/constants';
+import {customElement, property, observe} from '@polymer/decorators';
+
+export interface GrCursorManager {
+ $: {};
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-cursor-manager': GrCursorManager;
+ }
+}
+
+/**
+ * Return type for cursor moves, that indicate whether a move was possible.
+ */
+export enum CursorMoveResult {
+ /** The cursor was successfully moved. */
+ MOVED,
+ /** There were no stops - the cursor was reset. */
+ NO_STOPS,
+ /**
+ * There was no more matching stop to move to - the cursor was clipped to the
+ * end.
+ */
+ CLIPPED,
+ /** The abort condition would have been fulfilled for the new target. */
+ ABORTED,
+}
+
+/** A sentinel that can be inserted to disallow moving across. */
+export class AbortStop {}
+
+export type Stop = HTMLElement | AbortStop;
+
+/**
+ * Type guard and checker to check if a stop can be targetted.
+ * Abort stops cannot be targetted.
+ */
+export function isTargetable(stop: Stop): stop is HTMLElement {
+ return !(stop instanceof AbortStop);
+}
+
+@customElement('gr-cursor-manager')
+export class GrCursorManager extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object, notify: true})
+ target: HTMLElement | null = null;
+
+ /**
+ * The height of content intended to be included with the target.
+ */
+ @property({type: Number})
+ _targetHeight: number | null = null;
+
+ /**
+ * The index of the current target (if any). -1 otherwise.
+ */
+ @property({type: Number, notify: true})
+ index = -1;
+
+ /**
+ * The class to apply to the current target. Use null for no class.
+ */
+ @property({type: String})
+ cursorTargetClass: string | null = null;
+
+ /**
+ * The scroll behavior for the cursor. Values are 'never' and
+ * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+ * the viewport.
+ * TODO (beckysiegel) figure out why it can be undefined
+ *
+ * @type {string|undefined}
+ */
+ @property({type: String})
+ scrollMode: string = ScrollMode.NEVER;
+
+ /**
+ * When true, will call element.focus() during scrolling.
+ */
+ @property({type: Boolean})
+ focusOnMove = false;
+
+ @property({type: Array})
+ stops: Stop[] = [];
+
+ /** Only non-AbortStop stops. */
+ get targetableStops(): HTMLElement[] {
+ return this.stops.filter(isTargetable);
+ }
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.unsetCursor();
+ }
+
+ /**
+ * Move the cursor forward. Clipped to the ends of the stop list.
+ *
+ * @param options.filter Will keep going and skip any stops for which this
+ * condition is not met.
+ * @param options.getTargetHeight Optional function to calculate the
+ * height of the target's 'section'. The height of the target itself is
+ * sometimes different, used by the diff cursor.
+ * @param options.clipToTop When none of the next indices match, move
+ * back to first instead of to last.
+ * @return If a move was performed or why not.
+ * @private
+ */
+ next(
+ options: {
+ filter?: (stop: HTMLElement) => boolean;
+ getTargetHeight?: (target: HTMLElement) => number;
+ clipToTop?: boolean;
+ } = {}
+ ): CursorMoveResult {
+ return this._moveCursor(1, options);
+ }
+
+ previous(
+ options: {
+ filter?: (stop: HTMLElement) => boolean;
+ } = {}
+ ): CursorMoveResult {
+ return this._moveCursor(-1, options);
+ }
+
+ /**
+ * Move the cursor to the row which is the closest to the viewport center
+ * in vertical direction.
+ * The method uses IntersectionObservers API. If browser
+ * doesn't support this API the method does nothing
+ *
+ * @param condition Optional condition. If a condition
+ * is passed only stops which meet conditions are taken into account.
+ */
+ moveToVisibleArea(condition?: (el: Element) => boolean) {
+ if (!this.stops || !this._isIntersectionObserverSupported()) {
+ return;
+ }
+ const filteredStops = condition
+ ? this.targetableStops.filter(condition)
+ : this.targetableStops;
+ const dims = this._getWindowDims();
+ const windowCenter = Math.round(dims.innerHeight / 2);
+
+ let closestToTheCenter: HTMLElement | null = null;
+ let minDistanceToCenter: number | null = null;
+ let unobservedCount = filteredStops.length;
+
+ const observer = new IntersectionObserver(entries => {
+ // This callback is called for the first time immediately.
+ // Typically it gets all observed stops at once, but
+ // sometimes can get them in several chunks.
+ entries.forEach(entry => {
+ observer.unobserve(entry.target);
+
+ // In Edge it is recommended to use intersectionRatio instead of
+ // isIntersecting.
+ const isInsideViewport =
+ entry.isIntersecting || entry.intersectionRatio > 0;
+ if (!isInsideViewport) {
+ return;
+ }
+ const center =
+ entry.boundingClientRect.top +
+ Math.round(entry.boundingClientRect.height / 2);
+ const distanceToWindowCenter = Math.abs(center - windowCenter);
+ if (
+ minDistanceToCenter === null ||
+ distanceToWindowCenter < minDistanceToCenter
+ ) {
+ // entry.target comes from the filteredStops array,
+ // hence it is an HTMLElement
+ closestToTheCenter = entry.target as HTMLElement;
+ minDistanceToCenter = distanceToWindowCenter;
+ }
+ });
+ unobservedCount -= entries.length;
+ if (unobservedCount === 0 && closestToTheCenter) {
+ // set cursor when all stops were observed.
+ // In most cases the target is visible, so scroll is not
+ // needed. But in rare cases the target can become invisible
+ // at this point (due to some scrolling in window).
+ // To avoid jumps set noScroll options.
+ this.setCursor(closestToTheCenter, true);
+ }
+ });
+ filteredStops.forEach(stop => {
+ observer.observe(stop);
+ });
+ }
+
+ _isIntersectionObserverSupported() {
+ // The copy of this method exists in gr-app-element.js under the
+ // name _isCursorManagerSupportMoveToVisibleLine
+ // If you update this method, you must update gr-app-element.js
+ // as well.
+ return 'IntersectionObserver' in window;
+ }
+
+ /**
+ * Set the cursor to an arbitrary stop - if the given element is not one of
+ * the stops, unset the cursor.
+ *
+ * @param noScroll prevent any potential scrolling in response
+ * setting the cursor.
+ */
+ setCursor(element: HTMLElement, noScroll?: boolean) {
+ if (!this.targetableStops.includes(element)) {
+ this.unsetCursor();
+ return;
+ }
+ let behavior;
+ if (noScroll) {
+ behavior = this.scrollMode;
+ this.scrollMode = ScrollMode.NEVER;
+ }
+
+ this.unsetCursor();
+ this.target = element;
+ this._updateIndex();
+ this._decorateTarget();
+
+ if (noScroll && behavior) {
+ this.scrollMode = behavior;
+ }
+ }
+
+ unsetCursor() {
+ this._unDecorateTarget();
+ this.index = -1;
+ this.target = null;
+ this._targetHeight = null;
+ }
+
+ isAtStart() {
+ return this.index === 0;
+ }
+
+ isAtEnd() {
+ // Unset cursor should not be considered "at end", even when there are no
+ // cursor stops.
+ return this.index !== -1 && this.index === this.stops.length - 1;
+ }
+
+ moveToStart() {
+ if (this.stops.length) {
+ this.setCursorAtIndex(0);
+ }
+ }
+
+ moveToEnd() {
+ if (this.stops.length) {
+ this.setCursorAtIndex(this.stops.length - 1);
+ }
+ }
+
+ setCursorAtIndex(index: number, noScroll?: boolean) {
+ const stop = this.stops[index];
+ if (isTargetable(stop)) {
+ this.setCursor(stop, noScroll);
+ }
+ }
+
+ /**
+ * Move the cursor forward or backward by delta. Clipped to the beginning or
+ * end of stop list.
+ *
+ * @param delta either -1 or 1.
+ * @param options.abort Will abort moving the cursor when encountering a
+ * stop for which this condition is met. Will abort even if the stop
+ * would have been filtered
+ * @param options.filter Will keep going and skip any stops for which this
+ * condition is not met.
+ * @param options.getTargetHeight Optional function to calculate the
+ * height of the target's 'section'. The height of the target itself is
+ * sometimes different, used by the diff cursor.
+ * @param options.clipToTop When none of the next indices match, move
+ * back to first instead of to last.
+ * @return If a move was performed or why not.
+ * @private
+ */
+ _moveCursor(
+ delta: number,
+ {
+ filter,
+ getTargetHeight,
+ clipToTop,
+ }: {
+ filter?: (stop: HTMLElement) => boolean;
+ getTargetHeight?: (target: HTMLElement) => number;
+ clipToTop?: boolean;
+ } = {}
+ ): CursorMoveResult {
+ if (!this.stops.length) {
+ this.unsetCursor();
+ return CursorMoveResult.NO_STOPS;
+ }
+
+ let newIndex = this.index;
+ // If the cursor is not yet set and we are going backwards, start at the
+ // back.
+ if (this.index === -1 && delta < 0) {
+ newIndex = this.stops.length;
+ }
+
+ let clipped = false;
+ let newStop: Stop;
+ do {
+ newIndex += delta;
+ if (
+ (delta > 0 && newIndex >= this.stops.length) ||
+ (delta < 0 && newIndex < 0)
+ ) {
+ newIndex = delta < 0 || clipToTop ? 0 : this.stops.length - 1;
+ newStop = this.stops[newIndex];
+ clipped = true;
+ break;
+ }
+ // Sadly needed so that type narrowing understands that this.stops[newIndex] is
+ // targetable after I have checked that.
+ newStop = this.stops[newIndex];
+ } while (isTargetable(newStop) && filter && !filter(newStop));
+
+ if (!isTargetable(newStop)) {
+ return CursorMoveResult.ABORTED;
+ }
+
+ this._unDecorateTarget();
+
+ this.index = newIndex;
+ this.target = newStop;
+
+ if (getTargetHeight) {
+ this._targetHeight = getTargetHeight(this.target);
+ } else {
+ this._targetHeight = this.target.scrollHeight;
+ }
+
+ if (this.focusOnMove) {
+ this.target.focus();
+ }
+
+ this._decorateTarget();
+
+ return clipped ? CursorMoveResult.CLIPPED : CursorMoveResult.MOVED;
+ }
+
+ _decorateTarget() {
+ if (this.target && this.cursorTargetClass) {
+ this.target.classList.add(this.cursorTargetClass);
+ }
+ }
+
+ _unDecorateTarget() {
+ if (this.target && this.cursorTargetClass) {
+ this.target.classList.remove(this.cursorTargetClass);
+ }
+ }
+
+ @observe('stops')
+ _updateIndex() {
+ if (!this.target) {
+ this.index = -1;
+ return;
+ }
+
+ const newIndex = this.stops.indexOf(this.target);
+ if (newIndex === -1) {
+ this.unsetCursor();
+ } else {
+ this.index = newIndex;
+ }
+ }
+
+ /**
+ * Calculate where the element is relative to the window.
+ *
+ * @param target Target to scroll to.
+ * @return Distance to top of the target.
+ */
+ _getTop(target: HTMLElement) {
+ let top: number = target.offsetTop;
+ for (
+ let offsetParent = target.offsetParent;
+ offsetParent;
+ offsetParent = (offsetParent as HTMLElement).offsetParent
+ ) {
+ top += (offsetParent as HTMLElement).offsetTop;
+ }
+ return top;
+ }
+
+ /**
+ * @return
+ */
+ _targetIsVisible(top: number) {
+ const dims = this._getWindowDims();
+ return (
+ this.scrollMode === ScrollMode.KEEP_VISIBLE &&
+ top > dims.pageYOffset &&
+ top < dims.pageYOffset + dims.innerHeight
+ );
+ }
+
+ _calculateScrollToValue(top: number, target: HTMLElement) {
+ const dims = this._getWindowDims();
+ return top + -dims.innerHeight / 3 + target.offsetHeight / 2;
+ }
+
+ @observe('target')
+ _scrollToTarget() {
+ if (!this.target || this.scrollMode === ScrollMode.NEVER) {
+ return;
+ }
+
+ const dims = this._getWindowDims();
+ const top = this._getTop(this.target);
+ const bottomIsVisible = this._targetHeight
+ ? this._targetIsVisible(top + this._targetHeight)
+ : true;
+ const scrollToValue = this._calculateScrollToValue(top, this.target);
+
+ if (this._targetIsVisible(top)) {
+ // Don't scroll if either the bottom is visible or if the position that
+ // would get scrolled to is higher up than the current position. This
+ // would cause less of the target content to be displayed than is
+ // already.
+ if (bottomIsVisible || scrollToValue < dims.scrollY) {
+ return;
+ }
+ }
+
+ // Scroll the element to the middle of the window. Dividing by a third
+ // instead of half the inner height feels a bit better otherwise the
+ // element appears to be below the center of the window even when it
+ // isn't.
+ window.scrollTo(dims.scrollX, scrollToValue);
+ }
+
+ _getWindowDims() {
+ return {
+ scrollX: window.scrollX,
+ scrollY: window.scrollY,
+ innerHeight: window.innerHeight,
+ pageYOffset: window.pageYOffset,
+ };
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
index bc07d84..33aeafc 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -18,6 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-cursor-manager.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {AbortStop, CursorMoveResult} from './gr-cursor-manager.js';
const basicTestFixutre = fixtureFromTemplate(html`
<gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
@@ -47,7 +48,7 @@
assert.isNotOk(element.target);
// Initialize the cursor with its stops.
- element.stops = list.querySelectorAll('li');
+ element.stops = [...list.querySelectorAll('li')];
// It should have the stops but it should not be targeting any of them.
assert.isNotNull(element.stops);
@@ -66,10 +67,11 @@
assert.isFalse(element.isAtEnd());
// Progress the cursor.
- element.next();
+ let result = element.next();
// Confirm that the next stop is selected and that the previous stop is
// unselected.
+ assert.equal(result, CursorMoveResult.MOVED);
assert.equal(element.index, 3);
assert.equal(element.target, list.children[3]);
assert.isTrue(element.isAtEnd());
@@ -77,19 +79,23 @@
assert.isTrue(list.children[3].classList.contains('targeted'));
// Progress the cursor.
- element.next();
+ result = element.next();
// We should still be at the end.
+ assert.equal(result, CursorMoveResult.CLIPPED);
assert.equal(element.index, 3);
assert.equal(element.target, list.children[3]);
assert.isTrue(element.isAtEnd());
// Wind the cursor all the way back to the first stop.
- element.previous();
- element.previous();
- element.previous();
+ result = element.previous();
+ assert.equal(result, CursorMoveResult.MOVED);
+ result = element.previous();
+ assert.equal(result, CursorMoveResult.MOVED);
+ result = element.previous();
+ assert.equal(result, CursorMoveResult.MOVED);
- // The element state should reflect the end of the list.
+ // The element state should reflect the start of the list.
assert.equal(element.index, 0);
assert.equal(element.target, list.children[0]);
assert.isTrue(element.isAtStart());
@@ -98,7 +104,7 @@
const newLi = document.createElement('li');
newLi.textContent = 'Z';
list.insertBefore(newLi, list.children[0]);
- element.stops = list.querySelectorAll('li');
+ element.stops = [...list.querySelectorAll('li')];
assert.equal(element.index, 1);
@@ -112,9 +118,10 @@
});
test('next() goes to first element when no cursor is set', () => {
- element.stops = list.querySelectorAll('li');
- element.next();
+ element.stops = [...list.querySelectorAll('li')];
+ const result = element.next();
+ assert.equal(result, CursorMoveResult.MOVED);
assert.equal(element.index, 0);
assert.equal(element.target, list.children[0]);
assert.isTrue(list.children[0].classList.contains('targeted'));
@@ -122,10 +129,23 @@
assert.isFalse(element.isAtEnd());
});
- test('next() goes to first element when no cursor is set', () => {
- element.stops = list.querySelectorAll('li');
- element.previous();
+ test('next() resets the cursor when there are no stops', () => {
+ element.stops = [];
+ const result = element.next();
+ assert.equal(result, CursorMoveResult.NO_STOPS);
+ assert.equal(element.index, -1);
+ assert.isNotOk(element.target);
+ assert.isFalse(list.children[1].classList.contains('targeted'));
+ assert.isFalse(element.isAtStart());
+ assert.isFalse(element.isAtEnd());
+ });
+
+ test('previous() goes to last element when no cursor is set', () => {
+ element.stops = [...list.querySelectorAll('li')];
+ const result = element.previous();
+
+ assert.equal(result, CursorMoveResult.MOVED);
const lastIndex = list.children.length - 1;
assert.equal(element.index, lastIndex);
assert.equal(element.target, list.children[lastIndex]);
@@ -134,9 +154,21 @@
assert.isTrue(element.isAtEnd());
});
+ test('previous() resets the cursor when there are no stops', () => {
+ element.stops = [];
+ const result = element.previous();
+
+ assert.equal(result, CursorMoveResult.NO_STOPS);
+ assert.equal(element.index, -1);
+ assert.isNotOk(element.target);
+ assert.isFalse(list.children[1].classList.contains('targeted'));
+ assert.isFalse(element.isAtStart());
+ assert.isFalse(element.isAtEnd());
+ });
+
test('_moveCursor', () => {
// Initialize the cursor with its stops.
- element.stops = list.querySelectorAll('li');
+ element.stops = [...list.querySelectorAll('li')];
// Select the first stop.
element.setCursor(list.children[0]);
const getTargetHeight = sinon.stub();
@@ -146,21 +178,21 @@
assert.isFalse(getTargetHeight.called);
// Move the cursor with an optional get target height function.
- element._moveCursor(1, null, getTargetHeight);
+ element._moveCursor(1, {getTargetHeight});
assert.isTrue(getTargetHeight.called);
});
test('_moveCursor from for invalid index does not check height', () => {
element.stops = [];
const getTargetHeight = sinon.stub();
- element._moveCursor(1, () => false, getTargetHeight);
+ element._moveCursor(1, () => false, {getTargetHeight});
assert.isFalse(getTargetHeight.called);
});
- test('opt_noScroll', () => {
+ test('setCursorAtIndex with noScroll', () => {
sinon.stub(element, '_targetIsVisible').callsFake(() => false);
const scrollStub = sinon.stub(window, 'scrollTo');
- element.stops = list.querySelectorAll('li');
+ element.stops = [...list.querySelectorAll('li')];
element.scrollMode = 'keep-visible';
element.setCursorAtIndex(1, true);
@@ -170,36 +202,38 @@
assert.isTrue(scrollStub.called);
});
- test('_getNextindex', () => {
+ test('move with filter', () => {
const isLetterB = function(row) {
return row.textContent === 'B';
};
- element.stops = list.querySelectorAll('li');
+ element.stops = [...list.querySelectorAll('li')];
// Start cursor at the first stop.
element.setCursor(list.children[0]);
// Move forward to meet the next condition.
- assert.equal(element._getNextindex(1, isLetterB), 1);
- element.index = 1;
+ element.next({filter: isLetterB});
+ assert.equal(element.index, 1);
// Nothing else meets the condition, should be at last stop.
- assert.equal(element._getNextindex(1, isLetterB), 3);
- element.index = 3;
+ element.next({filter: isLetterB});
+ assert.equal(element.index, 3);
// Should stay at last stop if try to proceed.
- assert.equal(element._getNextindex(1, isLetterB), 3);
+ element.next({filter: isLetterB});
+ assert.equal(element.index, 3);
// Go back to the previous condition met. Should be back at.
// stop 1.
- assert.equal(element._getNextindex(-1, isLetterB), 1);
- element.index = 1;
+ element.previous({filter: isLetterB});
+ assert.equal(element.index, 1);
// Go back. No more meet the condition. Should be at stop 0.
- assert.equal(element._getNextindex(-1, isLetterB), 0);
+ element.previous({filter: isLetterB});
+ assert.equal(element.index, 0);
});
test('focusOnMove prop', () => {
- const listEls = list.querySelectorAll('li');
+ const listEls = [...list.querySelectorAll('li')];
for (let i = 0; i < listEls.length; i++) {
sinon.spy(listEls[i], 'focus');
}
@@ -218,7 +252,7 @@
suite('_scrollToTarget', () => {
let scrollStub;
setup(() => {
- element.stops = list.querySelectorAll('li');
+ element.stops = [...list.querySelectorAll('li')];
element.scrollMode = 'keep-visible';
// There is a target which has a targetNext
@@ -282,4 +316,50 @@
905);
});
});
+
+ suite('AbortStops', () => {
+ test('next() does not skip AbortStops', () => {
+ element.stops = [
+ document.createElement('li'),
+ new AbortStop(),
+ document.createElement('li'),
+ ];
+ element.setCursorAtIndex(0);
+
+ const result = element.next();
+
+ assert.equal(result, CursorMoveResult.ABORTED);
+ assert.equal(element.index, 0);
+ });
+
+ test('setCursorAtIndex() does not target AbortStops', () => {
+ element.stops = [
+ document.createElement('li'),
+ new AbortStop(),
+ document.createElement('li'),
+ ];
+ element.setCursorAtIndex(1);
+ assert.equal(element.index, -1);
+ });
+
+ test('moveToStart() does not target AbortStop', () => {
+ element.stops = [
+ new AbortStop(),
+ document.createElement('li'),
+ document.createElement('li'),
+ ];
+ element.moveToStart();
+ assert.equal(element.index, -1);
+ });
+
+ test('moveToEnd() does not target AbortStop', () => {
+ element.stops = [
+ document.createElement('li'),
+ document.createElement('li'),
+ new AbortStop(),
+ ];
+ element.moveToEnd();
+ assert.equal(element.index, -1);
+ });
+ });
});
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
deleted file mode 100644
index 682b7f7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ /dev/null
@@ -1,237 +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 '../gr-rest-api-interface/gr-rest-api-interface.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-date-formatter_html.js';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin.js';
-import {parseDate, fromNow, isValidDate, isWithinDay, isWithinHalfYear, formatDate, utcOffsetString} from '../../../utils/date-util.js';
-
-const TimeFormats = {
- TIME_12: 'h:mm A', // 2:14 PM
- TIME_12_WITH_SEC: 'h:mm:ss A', // 2:14:00 PM
- TIME_24: 'HH:mm', // 14:14
- TIME_24_WITH_SEC: 'HH:mm:ss', // 14:14:00
-};
-
-const DateFormats = {
- STD: {
- short: 'MMM DD', // Aug 29
- full: 'MMM DD, YYYY', // Aug 29, 1997
- },
- US: {
- short: 'MM/DD', // 08/29
- full: 'MM/DD/YY', // 08/29/97
- },
- ISO: {
- short: 'MM-DD', // 08-29
- full: 'YYYY-MM-DD', // 1997-08-29
- },
- EURO: {
- short: 'DD. MMM', // 29. Aug
- full: 'DD.MM.YYYY', // 29.08.1997
- },
- UK: {
- short: 'DD/MM', // 29/08
- full: 'DD/MM/YYYY', // 29/08/1997
- },
-};
-
-/**
- * @extends PolymerElement
- */
-class GrDateFormatter extends TooltipMixin(
- GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-date-formatter'; }
-
- static get properties() {
- return {
- dateStr: {
- type: String,
- value: null,
- notify: true,
- },
- showDateAndTime: {
- type: Boolean,
- value: false,
- },
-
- /**
- * When true, the detailed date appears in a GR-TOOLTIP rather than in the
- * native browser tooltip.
- */
- hasTooltip: Boolean,
-
- /**
- * The title to be used as the native tooltip or by the tooltip behavior.
- */
- title: {
- type: String,
- reflectToAttribute: true,
- computed: '_computeFullDateStr(dateStr, _timeFormat, _dateFormat)',
- },
-
- /** @type {?{short: string, full: string}} */
- _dateFormat: Object,
- _timeFormat: String, // No default value to prevent flickering.
- _relative: Boolean, // No default value to prevent flickering.
- };
- }
-
- constructor() {
- super();
- }
-
- /** @override */
- attached() {
- super.attached();
- this._loadPreferences();
- }
-
- _getUtcOffsetString() {
- return utcOffsetString();
- }
-
- _loadPreferences() {
- return this._getLoggedIn().then(loggedIn => {
- if (!loggedIn) {
- this._timeFormat = TimeFormats.TIME_24;
- this._dateFormat = DateFormats.STD;
- this._relative = false;
- return;
- }
- return Promise.all([
- this._loadTimeFormat(),
- this._loadRelative(),
- ]);
- });
- }
-
- _loadTimeFormat() {
- return this._getPreferences().then(preferences => {
- const timeFormat = preferences && preferences.time_format;
- const dateFormat = preferences && preferences.date_format;
- this._decideTimeFormat(timeFormat);
- this._decideDateFormat(dateFormat);
- });
- }
-
- _decideTimeFormat(timeFormat) {
- switch (timeFormat) {
- case 'HHMM_12':
- this._timeFormat = TimeFormats.TIME_12;
- break;
- case 'HHMM_24':
- this._timeFormat = TimeFormats.TIME_24;
- break;
- default:
- throw Error('Invalid time format: ' + timeFormat);
- }
- }
-
- _decideDateFormat(dateFormat) {
- switch (dateFormat) {
- case 'STD':
- this._dateFormat = DateFormats.STD;
- break;
- case 'US':
- this._dateFormat = DateFormats.US;
- break;
- case 'ISO':
- this._dateFormat = DateFormats.ISO;
- break;
- case 'EURO':
- this._dateFormat = DateFormats.EURO;
- break;
- case 'UK':
- this._dateFormat = DateFormats.UK;
- break;
- default:
- throw Error('Invalid date format: ' + dateFormat);
- }
- }
-
- _loadRelative() {
- return this._getPreferences().then(prefs => {
- // prefs.relative_date_in_change_table is not set when false.
- this._relative = !!(prefs && prefs.relative_date_in_change_table);
- });
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _getPreferences() {
- return this.$.restAPI.getPreferences();
- }
-
- _computeDateStr(
- dateStr, timeFormat, dateFormat, relative, showDateAndTime
- ) {
- if (!dateStr || !timeFormat || !dateFormat) { return ''; }
- const date = parseDate(dateStr);
- if (!isValidDate(date)) { return ''; }
- if (relative) {
- return fromNow(date);
- }
- const now = new Date();
- let format = dateFormat.full;
- if (isWithinDay(now, date)) {
- format = timeFormat;
- } else {
- if (isWithinHalfYear(now, date)) {
- format = dateFormat.short;
- }
- if (this.showDateAndTime) {
- format = `${format} ${timeFormat}`;
- }
- }
- return formatDate(date, format);
- }
-
- _timeToSecondsFormat(timeFormat) {
- return timeFormat === TimeFormats.TIME_12 ?
- TimeFormats.TIME_12_WITH_SEC :
- TimeFormats.TIME_24_WITH_SEC;
- }
-
- _computeFullDateStr(dateStr, timeFormat, dateFormat) {
- // Polymer 2: check for undefined
- if ([
- dateStr,
- timeFormat,
- dateFormat,
- ].includes(undefined)) {
- return undefined;
- }
-
- if (!dateStr) { return ''; }
- const date = parseDate(dateStr);
- if (!isValidDate(date)) { return ''; }
- let format = dateFormat.full + ', ';
- format += this._timeToSecondsFormat(timeFormat);
- return formatDate(date, format) + this._getUtcOffsetString();
- }
-}
-
-customElements.define(GrDateFormatter.is, GrDateFormatter);
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
new file mode 100644
index 0000000..c64dc2a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.ts
@@ -0,0 +1,279 @@
+/**
+ * @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 '../gr-rest-api-interface/gr-rest-api-interface';
+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-date-formatter_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {property, customElement} from '@polymer/decorators';
+import {
+ parseDate,
+ fromNow,
+ isValidDate,
+ isWithinDay,
+ isWithinHalfYear,
+ formatDate,
+ utcOffsetString,
+} from '../../../utils/date-util';
+import {TimeFormat, DateFormat} from '../../../constants/constants';
+import {assertNever} from '../../../utils/common-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {Timestamp} from '../../../types/common';
+
+const TimeFormats = {
+ TIME_12: 'h:mm A', // 2:14 PM
+ TIME_12_WITH_SEC: 'h:mm:ss A', // 2:14:00 PM
+ TIME_24: 'HH:mm', // 14:14
+ TIME_24_WITH_SEC: 'HH:mm:ss', // 14:14:00
+};
+
+const DateFormats = {
+ STD: {
+ short: 'MMM DD', // Aug 29
+ full: 'MMM DD, YYYY', // Aug 29, 1997
+ },
+ US: {
+ short: 'MM/DD', // 08/29
+ full: 'MM/DD/YY', // 08/29/97
+ },
+ ISO: {
+ short: 'MM-DD', // 08-29
+ full: 'YYYY-MM-DD', // 1997-08-29
+ },
+ EURO: {
+ short: 'DD. MMM', // 29. Aug
+ full: 'DD.MM.YYYY', // 29.08.1997
+ },
+ UK: {
+ short: 'DD/MM', // 29/08
+ full: 'DD/MM/YYYY', // 29/08/1997
+ },
+};
+
+interface DateFormatPair {
+ short: string;
+ full: string;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-date-formatter': GrDateFormatter;
+ }
+}
+
+export interface GrDateFormatter {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+@customElement('gr-date-formatter')
+export class GrDateFormatter extends TooltipMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, notify: true})
+ dateStr: string | null = null;
+
+ @property({type: Boolean})
+ showDateAndTime = false;
+
+ /**
+ * When true, the detailed date appears in a GR-TOOLTIP rather than in the
+ * native browser tooltip.
+ */
+ @property({type: Boolean})
+ hasTooltip = false;
+
+ /**
+ * The title to be used as the native tooltip or by the tooltip behavior.
+ */
+ @property({
+ type: String,
+ reflectToAttribute: true,
+ computed: '_computeFullDateStr(dateStr, _timeFormat, _dateFormat)',
+ })
+ title = '';
+
+ /** @type {?{short: string, full: string}} */
+ @property({type: Object})
+ _dateFormat?: DateFormatPair;
+
+ @property({type: String})
+ _timeFormat?: string;
+
+ @property({type: Boolean})
+ _relative = false;
+
+ @property({type: Boolean})
+ forceRelative = false;
+
+ @property({type: Boolean})
+ relativeOptionNoAgo = false;
+
+ constructor() {
+ super();
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._loadPreferences();
+ }
+
+ _getUtcOffsetString() {
+ return utcOffsetString();
+ }
+
+ _loadPreferences() {
+ return this._getLoggedIn().then(loggedIn => {
+ if (!loggedIn) {
+ this._timeFormat = TimeFormats.TIME_24;
+ this._dateFormat = DateFormats.STD;
+ this._relative = this.forceRelative;
+ return;
+ }
+ return Promise.all([this._loadTimeFormat(), this._loadRelative()]);
+ });
+ }
+
+ _loadTimeFormat() {
+ return this._getPreferences().then(preferences => {
+ if (!preferences) {
+ throw Error('Preferences is not set');
+ }
+ this._decideTimeFormat(preferences.time_format);
+ this._decideDateFormat(preferences.date_format);
+ });
+ }
+
+ _decideTimeFormat(timeFormat: TimeFormat) {
+ switch (timeFormat) {
+ case TimeFormat.HHMM_12:
+ this._timeFormat = TimeFormats.TIME_12;
+ break;
+ case TimeFormat.HHMM_24:
+ this._timeFormat = TimeFormats.TIME_24;
+ break;
+ default:
+ assertNever(timeFormat, `Invalid time format: ${timeFormat}`);
+ }
+ }
+
+ _decideDateFormat(dateFormat: DateFormat) {
+ switch (dateFormat) {
+ case DateFormat.STD:
+ this._dateFormat = DateFormats.STD;
+ break;
+ case DateFormat.US:
+ this._dateFormat = DateFormats.US;
+ break;
+ case DateFormat.ISO:
+ this._dateFormat = DateFormats.ISO;
+ break;
+ case DateFormat.EURO:
+ this._dateFormat = DateFormats.EURO;
+ break;
+ case DateFormat.UK:
+ this._dateFormat = DateFormats.UK;
+ break;
+ default:
+ assertNever(dateFormat, `Invalid date format: ${dateFormat}`);
+ }
+ }
+
+ _loadRelative() {
+ return this._getPreferences().then(prefs => {
+ // prefs.relative_date_in_change_table is not set when false.
+ this._relative =
+ this.forceRelative || !!(prefs && prefs.relative_date_in_change_table);
+ });
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ _getPreferences() {
+ return this.$.restAPI.getPreferences();
+ }
+
+ _computeDateStr(
+ dateStr?: Timestamp,
+ timeFormat?: string,
+ dateFormat?: DateFormatPair,
+ relative?: boolean,
+ showDateAndTime?: boolean
+ ) {
+ if (!dateStr || !timeFormat || !dateFormat) {
+ return '';
+ }
+ const date = parseDate(dateStr);
+ if (!isValidDate(date)) {
+ return '';
+ }
+ if (relative) {
+ return fromNow(date, this.relativeOptionNoAgo);
+ }
+ const now = new Date();
+ let format = dateFormat.full;
+ if (isWithinDay(now, date)) {
+ format = timeFormat;
+ } else {
+ if (isWithinHalfYear(now, date)) {
+ format = dateFormat.short;
+ }
+ if (this.showDateAndTime || showDateAndTime) {
+ format = `${format} ${timeFormat}`;
+ }
+ }
+ return formatDate(date, format);
+ }
+
+ _timeToSecondsFormat(timeFormat: string | undefined) {
+ return timeFormat === TimeFormats.TIME_12
+ ? TimeFormats.TIME_12_WITH_SEC
+ : TimeFormats.TIME_24_WITH_SEC;
+ }
+
+ _computeFullDateStr(
+ dateStr?: Timestamp,
+ timeFormat?: string,
+ dateFormat?: DateFormatPair
+ ) {
+ // Polymer 2: check for undefined
+ if ([dateStr, timeFormat].includes(undefined) || !dateFormat) {
+ return undefined;
+ }
+
+ if (!dateStr) {
+ return '';
+ }
+ const date = parseDate(dateStr);
+ if (!isValidDate(date)) {
+ return '';
+ }
+ let format = dateFormat.full + ', ';
+ format += this._timeToSecondsFormat(timeFormat);
+ return formatDate(date, format) + this._getUtcOffsetString();
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
index 804c3b7..0a7f6dd 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.js
@@ -41,7 +41,7 @@
}
function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
- expectedTooltip, done) {
+ expectedTooltip) {
// Normalize and convert the date to mimic server response.
dateStr = normalizedDate(dateStr)
.toJSON()
@@ -49,16 +49,14 @@
.slice(0, -1);
sinon.useFakeTimers(normalizedDate(nowStr).getTime());
element.dateStr = dateStr;
- flush(() => {
- const span = element.shadowRoot
- .querySelector('span');
- assert.equal(span.textContent.trim(), expected);
- assert.equal(element.title, expectedTooltip);
- element.showDateAndTime = true;
- flushAsynchronousOperations();
- assert.equal(span.textContent.trim(), expectedWithDateAndTime);
- done();
- });
+ flush();
+ const span = element.shadowRoot
+ .querySelector('span');
+ assert.equal(span.textContent.trim(), expected);
+ assert.equal(element.title, expectedTooltip);
+ element.showDateAndTime = true;
+ flush();
+ assert.equal(span.textContent.trim(), expectedWithDateAndTime);
}
function stubRestAPI(preferences) {
@@ -87,36 +85,36 @@
assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
});
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'15:34',
'15:34',
- 'Jul 29, 2015, 15:34:14', done);
+ 'Jul 29, 2015, 15:34:14');
});
- test('Within 24 hours on different days', done => {
+ test('Within 24 hours on different days', () => {
testDates('2015-07-29 03:34:14.985000000',
'2015-07-28 20:25:14.985000000',
'Jul 28',
'Jul 28 20:25',
- 'Jul 28, 2015, 20:25:14', done);
+ 'Jul 28, 2015, 20:25:14');
});
- test('More than 24 hours but less than six months', done => {
+ test('More than 24 hours but less than six months', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-06-15 03:25:14.985000000',
'Jun 15',
'Jun 15 03:25',
- 'Jun 15, 2015, 03:25:14', done);
+ 'Jun 15, 2015, 03:25:14');
});
- test('More than six months', done => {
+ test('More than six months', () => {
testDates('2015-09-15 20:34:00.000000000',
'2015-01-15 03:25:00.000000000',
'Jan 15, 2015',
'Jan 15, 2015 03:25',
- 'Jan 15, 2015, 03:25:00', done);
+ 'Jan 15, 2015, 03:25:00');
});
});
@@ -131,28 +129,28 @@
return element._loadPreferences();
}));
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'15:34',
'15:34',
- '07/29/15, 15:34:14', done);
+ '07/29/15, 15:34:14');
});
- test('Within 24 hours on different days', done => {
+ test('Within 24 hours on different days', () => {
testDates('2015-07-29 03:34:14.985000000',
'2015-07-28 20:25:14.985000000',
'07/28',
'07/28 20:25',
- '07/28/15, 20:25:14', done);
+ '07/28/15, 20:25:14');
});
- test('More than 24 hours but less than six months', done => {
+ test('More than 24 hours but less than six months', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-06-15 03:25:14.985000000',
'06/15',
'06/15 03:25',
- '06/15/15, 03:25:14', done);
+ '06/15/15, 03:25:14');
});
});
@@ -167,28 +165,28 @@
return element._loadPreferences();
}));
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'15:34',
'15:34',
- '2015-07-29, 15:34:14', done);
+ '2015-07-29, 15:34:14');
});
- test('Within 24 hours on different days', done => {
+ test('Within 24 hours on different days', () => {
testDates('2015-07-29 03:34:14.985000000',
'2015-07-28 20:25:14.985000000',
'07-28',
'07-28 20:25',
- '2015-07-28, 20:25:14', done);
+ '2015-07-28, 20:25:14');
});
- test('More than 24 hours but less than six months', done => {
+ test('More than 24 hours but less than six months', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-06-15 03:25:14.985000000',
'06-15',
'06-15 03:25',
- '2015-06-15, 03:25:14', done);
+ '2015-06-15, 03:25:14');
});
});
@@ -203,28 +201,28 @@
return element._loadPreferences();
}));
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'15:34',
'15:34',
- '29.07.2015, 15:34:14', done);
+ '29.07.2015, 15:34:14');
});
- test('Within 24 hours on different days', done => {
+ test('Within 24 hours on different days', () => {
testDates('2015-07-29 03:34:14.985000000',
'2015-07-28 20:25:14.985000000',
'28. Jul',
'28. Jul 20:25',
- '28.07.2015, 20:25:14', done);
+ '28.07.2015, 20:25:14');
});
- test('More than 24 hours but less than six months', done => {
+ test('More than 24 hours but less than six months', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-06-15 03:25:14.985000000',
'15. Jun',
'15. Jun 03:25',
- '15.06.2015, 03:25:14', done);
+ '15.06.2015, 03:25:14');
});
});
@@ -239,28 +237,28 @@
return element._loadPreferences();
}));
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'15:34',
'15:34',
- '29/07/2015, 15:34:14', done);
+ '29/07/2015, 15:34:14');
});
- test('Within 24 hours on different days', done => {
+ test('Within 24 hours on different days', () => {
testDates('2015-07-29 03:34:14.985000000',
'2015-07-28 20:25:14.985000000',
'28/07',
'28/07 20:25',
- '28/07/2015, 20:25:14', done);
+ '28/07/2015, 20:25:14');
});
- test('More than 24 hours but less than six months', done => {
+ test('More than 24 hours but less than six months', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-06-15 03:25:14.985000000',
'15/06',
'15/06 03:25',
- '15/06/2015, 03:25:14', done);
+ '15/06/2015, 03:25:14');
});
});
@@ -276,12 +274,12 @@
})
);
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'3:34 PM',
'3:34 PM',
- 'Jul 29, 2015, 3:34:14 PM', done);
+ 'Jul 29, 2015, 3:34:14 PM');
});
});
@@ -297,12 +295,12 @@
})
);
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'3:34 PM',
'3:34 PM',
- '07/29/15, 3:34:14 PM', done);
+ '07/29/15, 3:34:14 PM');
});
});
@@ -318,12 +316,12 @@
})
);
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'3:34 PM',
'3:34 PM',
- '2015-07-29, 3:34:14 PM', done);
+ '2015-07-29, 3:34:14 PM');
});
});
@@ -339,12 +337,12 @@
})
);
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'3:34 PM',
'3:34 PM',
- '29.07.2015, 3:34:14 PM', done);
+ '29.07.2015, 3:34:14 PM');
});
});
@@ -360,12 +358,12 @@
})
);
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'3:34 PM',
'3:34 PM',
- '29/07/2015, 3:34:14 PM', done);
+ '29/07/2015, 3:34:14 PM');
});
});
@@ -380,20 +378,20 @@
return element._loadPreferences();
}));
- test('Within 24 hours on same day', done => {
+ test('Within 24 hours on same day', () => {
testDates('2015-07-29 20:34:14.985000000',
'2015-07-29 15:34:14.985000000',
'5 hours ago',
'5 hours ago',
- 'Jul 29, 2015, 3:34:14 PM', done);
+ 'Jul 29, 2015, 3:34:14 PM');
});
- test('More than six months', done => {
+ test('More than six months', () => {
testDates('2015-09-15 20:34:00.000000000',
'2015-01-15 03:25:00.000000000',
'8 months ago',
'8 months ago',
- 'Jan 15, 2015, 3:25:00 AM', done);
+ 'Jan 15, 2015, 3:25:00 AM');
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
deleted file mode 100644
index 2292ae7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.js
+++ /dev/null
@@ -1,115 +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 '../gr-button/gr-button.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-dialog_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrDialog extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-dialog'; }
- /**
- * Fired when the confirm button is pressed.
- *
- * @event confirm
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event cancel
- */
-
- static get properties() {
- return {
- confirmLabel: {
- type: String,
- value: 'Confirm',
- },
- // Supplying an empty cancel label will hide the button completely.
- cancelLabel: {
- type: String,
- value: 'Cancel',
- },
- disabled: {
- type: Boolean,
- value: false,
- },
- confirmOnEnter: {
- type: Boolean,
- value: false,
- },
- confirmTooltip: {
- type: String,
- observer: '_handleConfirmTooltipUpdate',
- },
- };
- }
-
- /** @override */
- ready() {
- super.ready();
- this._ensureAttribute('role', 'dialog');
- }
-
- _handleConfirmTooltipUpdate(confirmTooltip) {
- if (confirmTooltip) {
- this.$.confirm.setAttribute('has-tooltip', true);
- } else {
- this.$.confirm.removeAttribute('has-tooltip');
- }
- }
-
- _handleConfirm(e) {
- if (this.disabled) { return; }
-
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('confirm', {
- composed: true, bubbles: false,
- }));
- }
-
- _handleCancelTap(e) {
- e.preventDefault();
- e.stopPropagation();
- this.dispatchEvent(new CustomEvent('cancel', {
- composed: true, bubbles: false,
- }));
- }
-
- _handleKeydown(e) {
- if (this.confirmOnEnter && e.keyCode === 13) { this._handleConfirm(e); }
- }
-
- resetFocus() {
- this.$.confirm.focus();
- }
-
- _computeCancelClass(cancelLabel) {
- return cancelLabel.length ? '' : 'hidden';
- }
-}
-
-customElements.define(GrDialog.is, GrDialog);
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
new file mode 100644
index 0000000..fa6403a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -0,0 +1,128 @@
+/**
+ * @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 '../gr-button/gr-button';
+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-dialog_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {GrButton} from '../gr-button/gr-button';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-dialog': GrDialog;
+ }
+}
+
+export interface GrDialog {
+ $: {
+ confirm: GrButton;
+ };
+}
+
+@customElement('gr-dialog')
+export class GrDialog 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: String})
+ confirmLabel = 'Confirm';
+
+ // Supplying an empty cancel label will hide the button completely.
+ @property({type: String})
+ cancelLabel = 'Cancel';
+
+ @property({type: Boolean})
+ disabled = false;
+
+ @property({type: Boolean})
+ confirmOnEnter = false;
+
+ @property({type: String})
+ confirmTooltip?: string;
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._ensureAttribute('role', 'dialog');
+ }
+
+ @observe('confirmTooltip')
+ _handleConfirmTooltipUpdate(confirmTooltip?: string) {
+ if (confirmTooltip) {
+ this.$.confirm.setAttribute('has-tooltip', 'true');
+ } else {
+ this.$.confirm.removeAttribute('has-tooltip');
+ }
+ }
+
+ _handleConfirm(e: KeyboardEvent) {
+ if (this.disabled) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('confirm', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleCancelTap(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.dispatchEvent(
+ new CustomEvent('cancel', {
+ composed: true,
+ bubbles: false,
+ })
+ );
+ }
+
+ _handleKeydown(e: KeyboardEvent) {
+ if (this.confirmOnEnter && e.keyCode === 13) {
+ this._handleConfirm(e);
+ }
+ }
+
+ resetFocus() {
+ this.$.confirm.focus();
+ }
+
+ _computeCancelClass(cancelLabel: string) {
+ return cancelLabel.length ? '' : 'hidden';
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
index ce36d7b..1238168 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.js
@@ -28,36 +28,36 @@
element = basicFixture.instantiate();
});
- test('events', done => {
- let numEvents = 0;
- function handler() { if (++numEvents == 2) { done(); } }
+ test('events', () => {
+ const confirm = sinon.stub();
+ const cancel = sinon.stub();
+ element.addEventListener('confirm', confirm);
+ element.addEventListener('cancel', cancel);
- element.addEventListener('confirm', handler);
- element.addEventListener('cancel', handler);
+ MockInteractions.tap(
+ element.shadowRoot.querySelector('gr-button[primary]'));
+ assert.equal(confirm.callCount, 1);
- MockInteractions.tap(element.shadowRoot
- .querySelector('gr-button[primary]'));
- MockInteractions.tap(element.shadowRoot
- .querySelector('gr-button:not([primary])'));
+ MockInteractions.tap(
+ element.shadowRoot.querySelector('gr-button:not([primary])'));
+ assert.equal(cancel.callCount, 1);
});
test('confirmOnEnter', () => {
element.confirmOnEnter = false;
const handleConfirmStub = sinon.stub(element, '_handleConfirm');
const handleKeydownSpy = sinon.spy(element, '_handleKeydown');
- MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
- .querySelector('main'),
- 13, null, 'enter');
- flushAsynchronousOperations();
+ MockInteractions.pressAndReleaseKeyOn(
+ element.shadowRoot.querySelector('main'), 13, null, 'enter');
+ flush();
assert.isTrue(handleKeydownSpy.called);
assert.isFalse(handleConfirmStub.called);
element.confirmOnEnter = true;
- MockInteractions.pressAndReleaseKeyOn(element.shadowRoot
- .querySelector('main'),
- 13, null, 'enter');
- flushAsynchronousOperations();
+ MockInteractions.pressAndReleaseKeyOn(
+ element.shadowRoot.querySelector('main'), 13, null, 'enter');
+ flush();
assert.isTrue(handleConfirmStub.called);
});
@@ -73,19 +73,17 @@
assert.isNull(element.$.confirm.getAttribute('has-tooltip'));
});
- test('tooltip added if confirm tooltip is passed', done => {
+ test('tooltip added if confirm tooltip is passed', () => {
element.confirmTooltip = 'confirm tooltip';
- flush(() => {
- assert(element.$.confirm.getAttribute('has-tooltip'));
- done();
- });
+ flush();
+ assert(element.$.confirm.getAttribute('has-tooltip'));
});
});
test('empty cancel label hides cancel btn', () => {
assert.isFalse(isHidden(element.$.cancel));
element.cancelLabel = '';
- flushAsynchronousOperations();
+ flush();
assert.isTrue(isHidden(element.$.cancel));
});
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
deleted file mode 100644
index 1d00941..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.js
+++ /dev/null
@@ -1,93 +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 '@polymer/iron-input/iron-input.js';
-import '../../../styles/shared-styles.js';
-import '../gr-button/gr-button.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-select/gr-select.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-diff-preferences_html.js';
-
-/** @extends PolymerElement */
-class GrDiffPreferences extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-diff-preferences'; }
-
- static get properties() {
- return {
- hasUnsavedChanges: {
- type: Boolean,
- notify: true,
- value: false,
- },
-
- /** @type {?} */
- diffPrefs: Object,
- };
- }
-
- loadData() {
- return this.$.restAPI.getDiffPreferences().then(prefs => {
- this.diffPrefs = prefs;
- });
- }
-
- _handleDiffPrefsChanged() {
- this.hasUnsavedChanges = true;
- }
-
- _handleLineWrappingTap() {
- this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
- this._handleDiffPrefsChanged();
- }
-
- _handleShowTabsTap() {
- this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
- this._handleDiffPrefsChanged();
- }
-
- _handleShowTrailingWhitespaceTap() {
- this.set('diffPrefs.show_whitespace_errors',
- this.$.showTrailingWhitespaceInput.checked);
- this._handleDiffPrefsChanged();
- }
-
- _handleSyntaxHighlightTap() {
- this.set('diffPrefs.syntax_highlighting',
- this.$.syntaxHighlightInput.checked);
- this._handleDiffPrefsChanged();
- }
-
- _handleAutomaticReviewTap() {
- this.set('diffPrefs.manual_review',
- !this.$.automaticReviewInput.checked);
- this._handleDiffPrefsChanged();
- }
-
- save() {
- return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(res => {
- this.hasUnsavedChanges = false;
- });
- }
-}
-
-customElements.define(GrDiffPreferences.is, GrDiffPreferences);
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
new file mode 100644
index 0000000..02d039a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences.ts
@@ -0,0 +1,112 @@
+/**
+ * @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 '@polymer/iron-input/iron-input';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-select/gr-select';
+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-diff-preferences_html';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {DiffPreferencesInfo} from '../../../types/common';
+import {GrSelect} from '../gr-select/gr-select';
+
+export interface GrDiffPreferences {
+ $: {
+ restAPI: RestApiService & Element;
+ lineWrappingInput: HTMLInputElement;
+ showTabsInput: HTMLInputElement;
+ showTrailingWhitespaceInput: HTMLInputElement;
+ automaticReviewInput: HTMLInputElement;
+ syntaxHighlightInput: HTMLInputElement;
+ contextSelect: GrSelect;
+ };
+ save(): Promise<void>;
+}
+
+@customElement('gr-diff-preferences')
+export class GrDiffPreferences extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean, notify: true})
+ hasUnsavedChanges = false;
+
+ @property({type: Object})
+ diffPrefs?: DiffPreferencesInfo;
+
+ loadData() {
+ return this.$.restAPI.getDiffPreferences().then(prefs => {
+ this.diffPrefs = prefs;
+ });
+ }
+
+ _handleDiffPrefsChanged() {
+ this.hasUnsavedChanges = true;
+ }
+
+ _handleLineWrappingTap() {
+ this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
+ this._handleDiffPrefsChanged();
+ }
+
+ _handleShowTabsTap() {
+ this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
+ this._handleDiffPrefsChanged();
+ }
+
+ _handleShowTrailingWhitespaceTap() {
+ this.set(
+ 'diffPrefs.show_whitespace_errors',
+ this.$.showTrailingWhitespaceInput.checked
+ );
+ this._handleDiffPrefsChanged();
+ }
+
+ _handleSyntaxHighlightTap() {
+ this.set(
+ 'diffPrefs.syntax_highlighting',
+ this.$.syntaxHighlightInput.checked
+ );
+ this._handleDiffPrefsChanged();
+ }
+
+ _handleAutomaticReviewTap() {
+ this.set('diffPrefs.manual_review', !this.$.automaticReviewInput.checked);
+ this._handleDiffPrefsChanged();
+ }
+
+ save() {
+ if (!this.diffPrefs)
+ return Promise.reject(new Error('Missing diff preferences'));
+ return this.$.restAPI.saveDiffPreferences(this.diffPrefs).then(_ => {
+ this.hasUnsavedChanges = false;
+ });
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-diff-preferences': GrDiffPreferences;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
index f9d971d..54022ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_html.ts
@@ -49,7 +49,7 @@
<input
id="lineWrappingInput"
type="checkbox"
- checked$="[[diffPrefs.line_wrapping]]"
+ checked="[[diffPrefs.line_wrapping]]"
on-change="_handleLineWrappingTap"
/>
</span>
@@ -132,7 +132,7 @@
<input
id="showTabsInput"
type="checkbox"
- checked$="[[diffPrefs.show_tabs]]"
+ checked="[[diffPrefs.show_tabs]]"
on-change="_handleShowTabsTap"
/>
</span>
@@ -143,7 +143,7 @@
<input
id="showTrailingWhitespaceInput"
type="checkbox"
- checked$="[[diffPrefs.show_whitespace_errors]]"
+ checked="[[diffPrefs.show_whitespace_errors]]"
on-change="_handleShowTrailingWhitespaceTap"
/>
</span>
@@ -154,7 +154,7 @@
<input
id="syntaxHighlightInput"
type="checkbox"
- checked$="[[diffPrefs.syntax_highlighting]]"
+ checked="[[diffPrefs.syntax_highlighting]]"
on-change="_handleSyntaxHighlightTap"
/>
</span>
@@ -165,7 +165,7 @@
<input
id="automaticReviewInput"
type="checkbox"
- checked$="[[!diffPrefs.manual_review]]"
+ checked="[[!diffPrefs.manual_review]]"
on-change="_handleAutomaticReviewTap"
/>
</span>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
deleted file mode 100644
index 4ecc99d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
+++ /dev/null
@@ -1,98 +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/paper-tabs/paper-tabs.js';
-import '../gr-shell-command/gr-shell-command.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.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-download-commands_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrDownloadCommands extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-download-commands'; }
-
- static get properties() {
- return {
- commands: Array,
- _loggedIn: {
- type: Boolean,
- value: false,
- observer: '_loggedInChanged',
- },
- schemes: Array,
- selectedScheme: {
- type: String,
- notify: true,
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this._getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- });
- }
-
- focusOnCopy() {
- this.shadowRoot.querySelector('gr-shell-command').focusOnCopy();
- }
-
- _getLoggedIn() {
- return this.$.restAPI.getLoggedIn();
- }
-
- _loggedInChanged(loggedIn) {
- if (!loggedIn) { return; }
- return this.$.restAPI.getPreferences().then(prefs => {
- if (prefs.download_scheme) {
- // Note (issue 5180): normalize the download scheme with lower-case.
- this.selectedScheme = prefs.download_scheme.toLowerCase();
- }
- });
- }
-
- _handleTabChange(e) {
- const scheme = this.schemes[e.detail.value];
- if (scheme && scheme !== this.selectedScheme) {
- this.set('selectedScheme', scheme);
- if (this._loggedIn) {
- this.$.restAPI.savePreferences(
- {download_scheme: this.selectedScheme});
- }
- }
- }
-
- _computeSelected(schemes, selectedScheme) {
- return (schemes.findIndex(scheme => scheme === selectedScheme) || 0) +
- '';
- }
-
- _computeShowTabs(schemes) {
- return schemes.length > 1 ? '' : 'hidden';
- }
-}
-
-customElements.define(GrDownloadCommands.is, GrDownloadCommands);
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
new file mode 100644
index 0000000..97747a0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.ts
@@ -0,0 +1,121 @@
+/**
+ * @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/paper-tabs/paper-tabs';
+import '../gr-shell-command/gr-shell-command';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+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-download-commands_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-download-commands': GrDownloadCommands;
+ }
+}
+
+export interface GrDownloadCommands {
+ $: {
+ downloadTabs: PaperTabsElement;
+ restAPI: RestApiService & Element;
+ };
+}
+
+export interface Command {
+ title: string;
+ command: string;
+}
+
+@customElement('gr-download-commands')
+export class GrDownloadCommands extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ // TODO(TS): maybe default to [] as only used in dom-repeat
+ @property({type: Array})
+ comamnds?: Command[];
+
+ @property({type: Boolean})
+ _loggedIn = false;
+
+ @property({type: Array})
+ schemes: string[] = [];
+
+ @property({type: String, notify: true})
+ selectedScheme?: string;
+
+ /** @override */
+ attached() {
+ super.attached();
+ this._getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ });
+ }
+
+ focusOnCopy() {
+ // TODO(TS): remove ! assertion later
+ this.shadowRoot!.querySelector('gr-shell-command')!.focusOnCopy();
+ }
+
+ _getLoggedIn() {
+ return this.$.restAPI.getLoggedIn();
+ }
+
+ @observe('_loggedIn')
+ _loggedInChanged(loggedIn: boolean) {
+ if (!loggedIn) {
+ return;
+ }
+ return this.$.restAPI.getPreferences().then(prefs => {
+ if (prefs?.download_scheme) {
+ // Note (issue 5180): normalize the download scheme with lower-case.
+ this.selectedScheme = prefs.download_scheme.toLowerCase();
+ }
+ });
+ }
+
+ _handleTabChange(e: CustomEvent<{value: number}>) {
+ const scheme = this.schemes[e.detail.value];
+ if (scheme && scheme !== this.selectedScheme) {
+ this.set('selectedScheme', scheme);
+ if (this._loggedIn) {
+ this.$.restAPI.savePreferences({download_scheme: this.selectedScheme});
+ }
+ }
+ }
+
+ _computeSelected(schemes: string[], selectedScheme?: string) {
+ return `${schemes.findIndex(scheme => scheme === selectedScheme) || 0}`;
+ }
+
+ _computeShowTabs(schemes: string[]) {
+ return schemes.length > 1 ? '' : 'hidden';
+ }
+
+ // TODO: maybe unify with strToClassName from dom-util
+ _computeClass(title: string) {
+ // Only retain [a-z] chars, so "Cherry Pick" becomes "cherrypick".
+ return '_label_' + title.replace(/[^a-z]+/gi, '').toLowerCase();
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
index 3bf0e87..35385fa 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_html.ts
@@ -66,6 +66,7 @@
<div class="commands" hidden$="[[!schemes.length]]" hidden="">
<template is="dom-repeat" items="[[commands]]" as="command">
<gr-shell-command
+ class$="[[_computeClass(command.title)]]"
label="[[command.title]]"
command="[[command.command]]"
></gr-shell-command>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
index 0f8b97d..5429506 100644
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
@@ -49,13 +49,12 @@
});
suite('unauthenticated', () => {
- setup(done => {
+ setup(async () => {
element = basicFixture.instantiate();
element.schemes = SCHEMES;
element.commands = COMMANDS;
element.selectedScheme = SELECTED_SCHEME;
- flushAsynchronousOperations();
- flush(done);
+ await flush();
});
test('focusOnCopy', () => {
@@ -79,17 +78,16 @@
.querySelector('.commands')));
});
- test('tab selection', done => {
+ test('tab selection', () => {
assert.equal(element.$.downloadTabs.selected, '0');
MockInteractions.tap(element.shadowRoot
.querySelector('[data-scheme="ssh"]'));
- flushAsynchronousOperations();
+ flush();
assert.equal(element.selectedScheme, 'ssh');
assert.equal(element.$.downloadTabs.selected, '2');
- done();
});
- test('loads scheme from preferences', done => {
+ test('loads scheme from preferences', () => {
stub('gr-rest-api-interface', {
getPreferences() {
return Promise.resolve({download_scheme: 'repo'});
@@ -97,22 +95,20 @@
});
element._loggedIn = true;
assert.isTrue(element.$.restAPI.getPreferences.called);
- element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+ return element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
assert.equal(element.selectedScheme, 'repo');
- done();
});
});
- test('normalize scheme from preferences', done => {
+ test('normalize scheme from preferences', () => {
stub('gr-rest-api-interface', {
getPreferences() {
return Promise.resolve({download_scheme: 'REPO'});
},
});
element._loggedIn = true;
- element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+ return element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
assert.equal(element.selectedScheme, 'repo');
- done();
});
});
@@ -121,7 +117,7 @@
const savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences')
.callsFake(() => Promise.resolve());
- flushAsynchronousOperations();
+ flush();
const repoTab = element.shadowRoot
.querySelector('paper-tab[data-scheme="repo"]');
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
deleted file mode 100644
index f84ef4a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
+++ /dev/null
@@ -1,146 +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-dropdown/iron-dropdown.js';
-import '@polymer/paper-item/paper-item.js';
-import '@polymer/paper-listbox/paper-listbox.js';
-import '../../../styles/shared-styles.js';
-import '../gr-button/gr-button.js';
-import '../gr-date-formatter/gr-date-formatter.js';
-import '../gr-select/gr-select.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-dropdown-list_html.js';
-
-/**
- * fired when the selected value of the dropdown changes
- *
- * @event {change}
- */
-
-const Defs = {};
-
-/**
- * Requred values are text and value. mobileText and triggerText will
- * fall back to text if not provided.
- *
- * If bottomText is not provided, nothing will display on the second
- * line.
- *
- * If date is not provided, nothing will be displayed in its place.
- *
- * @typedef {{
- * text: string,
- * value: (string|number),
- * bottomText: (string|undefined),
- * triggerText: (string|undefined),
- * mobileText: (string|undefined),
- * date: (!Date|undefined),
- * }}
- */
-Defs.item;
-
-/** @extends PolymerElement */
-class GrDropdownList extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-dropdown-list'; }
- /**
- * Fired when the selected value changes
- *
- * @event value-change
- *
- * @property {string|number} value
- */
-
- static get properties() {
- return {
- initialCount: Number,
- /** @type {!Array<!Defs.item>} */
- items: Object,
- text: String,
- disabled: {
- type: Boolean,
- value: false,
- },
- value: {
- type: String,
- notify: true,
- },
- };
- }
-
- static get observers() {
- return [
- '_handleValueChange(value, items)',
- ];
- }
-
- /**
- * Handle a click on the iron-dropdown element.
- *
- * @param {!Event} e
- */
- _handleDropdownClick(e) {
- // async is needed so that that the click event is fired before the
- // dropdown closes (This was a bug for touch devices).
- this.async(() => {
- this.$.dropdown.close();
- }, 1);
- }
-
- /**
- * Handle a click on the button to open the dropdown.
- *
- * @param {!Event} e
- */
- _showDropdownTapHandler(e) {
- this._open();
- }
-
- /**
- * Open the dropdown.
- */
- _open() {
- this.$.dropdown.open();
- }
-
- _computeMobileText(item) {
- return item.mobileText ? item.mobileText : item.text;
- }
-
- _handleValueChange(value, items) {
- // Polymer 2: check for undefined
- if ([value, items].some(arg => arg === undefined)) {
- return;
- }
-
- if (!value) { return; }
- const selectedObj = items.find(item => item.value + '' === value + '');
- if (!selectedObj) { return; }
- this.text = selectedObj.triggerText? selectedObj.triggerText :
- selectedObj.text;
- this.dispatchEvent(new CustomEvent('value-change', {
- detail: {value},
- bubbles: false,
- }));
- }
-}
-
-customElements.define(GrDropdownList.is, GrDropdownList);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
new file mode 100644
index 0000000..2ea72ca
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.ts
@@ -0,0 +1,158 @@
+/**
+ * @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-dropdown/iron-dropdown';
+import '@polymer/paper-item/paper-item';
+import '@polymer/paper-listbox/paper-listbox';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+import '../gr-date-formatter/gr-date-formatter';
+import '../gr-select/gr-select';
+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-dropdown-list_html';
+import {customElement, property, observe} from '@polymer/decorators';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {Timestamp} from '../../../types/common';
+
+/**
+ * fired when the selected value of the dropdown changes
+ *
+ * @event {change}
+ */
+
+/**
+ * Requred values are text and value. mobileText and triggerText will
+ * fall back to text if not provided.
+ *
+ * If bottomText is not provided, nothing will display on the second
+ * line.
+ *
+ * If date is not provided, nothing will be displayed in its place.
+ */
+export interface DropdownItem {
+ text: string;
+ value: string | number;
+ bottomText?: string;
+ triggerText?: string;
+ mobileText?: string;
+ date?: Timestamp;
+ disabled?: boolean;
+}
+
+export interface GrDropdownList {
+ $: {
+ dropdown: IronDropdownElement;
+ };
+}
+
+export interface ValueChangeDetail {
+ value: string;
+}
+
+export type DropDownValueChangeEvent = CustomEvent<ValueChangeDetail>;
+
+@customElement('gr-dropdown-list')
+export class GrDropdownList extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the selected value changes
+ *
+ * @event value-change
+ *
+ * @property {string|number} value
+ */
+
+ @property({type: Number})
+ initialCount = 75;
+
+ @property({type: Object})
+ items?: DropdownItem[];
+
+ @property({type: String})
+ text?: string;
+
+ @property({type: Boolean})
+ disabled = false;
+
+ @property({type: String, notify: true})
+ value?: string;
+
+ @property({type: Boolean})
+ showCopyForTriggerText = false;
+
+ /**
+ * Handle a click on the iron-dropdown element.
+ */
+ _handleDropdownClick() {
+ // async is needed so that that the click event is fired before the
+ // dropdown closes (This was a bug for touch devices).
+ this.async(() => {
+ this.$.dropdown.close();
+ }, 1);
+ }
+
+ /**
+ * Handle a click on the button to open the dropdown.
+ */
+ _showDropdownTapHandler() {
+ this.open();
+ }
+
+ /**
+ * Open the dropdown.
+ */
+ open() {
+ this.$.dropdown.open();
+ }
+
+ _computeMobileText(item: DropdownItem) {
+ return item.mobileText ? item.mobileText : item.text;
+ }
+
+ @observe('value', 'items')
+ _handleValueChange(value?: string, items?: DropdownItem[]) {
+ if (!value || !items) {
+ return;
+ }
+ const selectedObj = items.find(item => `${item.value}` === `${value}`);
+ if (!selectedObj) {
+ return;
+ }
+ this.text = selectedObj.triggerText
+ ? selectedObj.triggerText
+ : selectedObj.text;
+ const detail: ValueChangeDetail = {value};
+ this.dispatchEvent(
+ new CustomEvent('value-change', {
+ detail,
+ bubbles: false,
+ })
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-dropdown-list': GrDropdownList;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
index 629b0ad..26a6b3f 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_html.ts
@@ -35,9 +35,7 @@
background-color: var(--dropdown-background-color);
box-shadow: var(--elevation-level-2);
max-height: 70vh;
- margin-top: var(--spacing-xxl);
min-width: 266px;
- @apply --dropdown-content-style;
}
paper-listbox {
--paper-listbox: {
@@ -127,10 +125,18 @@
slot="dropdown-trigger"
>
<span id="triggerText">[[text]]</span>
+ <gr-copy-clipboard
+ hidden="[[!showCopyForTriggerText]]"
+ hide-input=""
+ text="[[text]]"
+ ></gr-copy-clipboard>
</gr-button>
<iron-dropdown
id="dropdown"
vertical-align="top"
+ horizontal-align="left"
+ dynamic-align
+ no-overlap
allow-outside-scroll="true"
on-click="_handleDropdownClick"
>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
index 8d7de0e..e3d7ed70 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-dropdown-list.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
const basicFixture = fixtureFromElement('gr-dropdown-list');
@@ -31,8 +30,24 @@
element = basicFixture.instantiate();
});
+ test('hide copy by default', () => {
+ const copyEl = element.shadowRoot
+ .querySelector('#triggerText + gr-copy-clipboard');
+ assert.isTrue(!!copyEl);
+ assert.isTrue(copyEl.hidden);
+ });
+
+ test('show copy if enabled', () => {
+ element.showCopyForTriggerText = true;
+ flush();
+ const copyEl = element.shadowRoot.querySelector(
+ '#triggerText + gr-copy-clipboard');
+ assert.isTrue(!!copyEl);
+ assert.isFalse(copyEl.hidden);
+ });
+
test('tap on trigger opens menu', () => {
- sinon.stub(element, '_open')
+ sinon.stub(element, 'open')
.callsFake(() => { element.$.dropdown.open(); });
assert.isFalse(element.$.dropdown.opened);
MockInteractions.tap(element.$.trigger);
@@ -49,7 +64,7 @@
assert.equal(element._computeMobileText(item), item.mobileText);
});
- test('options are selected and laid out correctly', done => {
+ test('options are selected and laid out correctly', async () => {
element.value = 2;
element.items = [
{
@@ -76,78 +91,77 @@
assert.equal(element.shadowRoot
.querySelector('paper-listbox').selected, element.value);
assert.equal(element.text, 'Button Text 2');
- flush(() => {
- const items = dom(element.root).querySelectorAll('paper-item');
- const mobileItems = dom(element.root).querySelectorAll('option');
- assert.equal(items.length, 3);
- assert.equal(mobileItems.length, 3);
+ await flush();
- // First Item
- // The first item should be disabled, has no bottom text, and no date.
- assert.isFalse(!!items[0].disabled);
- assert.isFalse(mobileItems[0].disabled);
- assert.isFalse(items[0].classList.contains('iron-selected'));
- assert.isFalse(mobileItems[0].selected);
+ const items = element.root.querySelectorAll('paper-item');
+ const mobileItems = element.root.querySelectorAll('option');
+ assert.equal(items.length, 3);
+ assert.equal(mobileItems.length, 3);
- assert.isNotOk(dom(items[0]).querySelector('gr-date-formatter'));
- assert.isNotOk(dom(items[0]).querySelector('.bottomContent'));
- assert.equal(items[0].dataset.value, element.items[0].value);
- assert.equal(mobileItems[0].value, element.items[0].value);
- assert.equal(dom(items[0]).querySelector('.topContent div')
- .innerText, element.items[0].text);
+ // First Item
+ // The first item should be disabled, has no bottom text, and no date.
+ assert.isFalse(!!items[0].disabled);
+ assert.isFalse(mobileItems[0].disabled);
+ assert.isFalse(items[0].classList.contains('iron-selected'));
+ assert.isFalse(mobileItems[0].selected);
- // Since no mobile specific text, it should fall back to text.
- assert.equal(mobileItems[0].text, element.items[0].text);
+ assert.isNotOk(items[0].querySelector('gr-date-formatter'));
+ assert.isNotOk(items[0].querySelector('.bottomContent'));
+ assert.equal(items[0].dataset.value, element.items[0].value);
+ assert.equal(mobileItems[0].value, element.items[0].value);
+ assert.equal(items[0].querySelector('.topContent div')
+ .innerText, element.items[0].text);
- // Second Item
- // The second item should have top text, bottom text, and no date.
- assert.isFalse(!!items[1].disabled);
- assert.isFalse(mobileItems[1].disabled);
- assert.isTrue(items[1].classList.contains('iron-selected'));
- assert.isTrue(mobileItems[1].selected);
+ // Since no mobile specific text, it should fall back to text.
+ assert.equal(mobileItems[0].text, element.items[0].text);
- assert.isNotOk(dom(items[1]).querySelector('gr-date-formatter'));
- assert.isOk(dom(items[1]).querySelector('.bottomContent'));
- assert.equal(items[1].dataset.value, element.items[1].value);
- assert.equal(mobileItems[1].value, element.items[1].value);
- assert.equal(dom(items[1]).querySelector('.topContent div')
- .innerText, element.items[1].text);
+ // Second Item
+ // The second item should have top text, bottom text, and no date.
+ assert.isFalse(!!items[1].disabled);
+ assert.isFalse(mobileItems[1].disabled);
+ assert.isTrue(items[1].classList.contains('iron-selected'));
+ assert.isTrue(mobileItems[1].selected);
- // Since there is mobile specific text, it should that.
- assert.equal(mobileItems[1].text, element.items[1].mobileText);
+ assert.isNotOk(items[1].querySelector('gr-date-formatter'));
+ assert.isOk(items[1].querySelector('.bottomContent'));
+ assert.equal(items[1].dataset.value, element.items[1].value);
+ assert.equal(mobileItems[1].value, element.items[1].value);
+ assert.equal(items[1].querySelector('.topContent div')
+ .innerText, element.items[1].text);
- // Since this item is selected, and it has triggerText defined, that
- // should be used.
- assert.equal(element.text, element.items[1].triggerText);
+ // Since there is mobile specific text, it should that.
+ assert.equal(mobileItems[1].text, element.items[1].mobileText);
- // Third item
- // The third item should be disabled, and have a date, and bottom content.
- assert.isTrue(!!items[2].disabled);
- assert.isTrue(mobileItems[2].disabled);
- assert.isFalse(items[2].classList.contains('iron-selected'));
- assert.isFalse(mobileItems[2].selected);
+ // Since this item is selected, and it has triggerText defined, that
+ // should be used.
+ assert.equal(element.text, element.items[1].triggerText);
- assert.isOk(dom(items[2]).querySelector('gr-date-formatter'));
- assert.isOk(dom(items[2]).querySelector('.bottomContent'));
- assert.equal(items[2].dataset.value, element.items[2].value);
- assert.equal(mobileItems[2].value, element.items[2].value);
- assert.equal(dom(items[2]).querySelector('.topContent div')
- .innerText, element.items[2].text);
+ // Third item
+ // The third item should be disabled, and have a date, and bottom content.
+ assert.isTrue(!!items[2].disabled);
+ assert.isTrue(mobileItems[2].disabled);
+ assert.isFalse(items[2].classList.contains('iron-selected'));
+ assert.isFalse(mobileItems[2].selected);
- // Since there is mobile specific text, it should that.
- assert.equal(mobileItems[2].text, element.items[2].mobileText);
+ assert.isOk(items[2].querySelector('gr-date-formatter'));
+ assert.isOk(items[2].querySelector('.bottomContent'));
+ assert.equal(items[2].dataset.value, element.items[2].value);
+ assert.equal(mobileItems[2].value, element.items[2].value);
+ assert.equal(items[2].querySelector('.topContent div')
+ .innerText, element.items[2].text);
- // Select a new item.
- MockInteractions.tap(items[0]);
- flushAsynchronousOperations();
- assert.equal(element.value, 1);
- assert.isTrue(items[0].classList.contains('iron-selected'));
- assert.isTrue(mobileItems[0].selected);
+ // Since there is mobile specific text, it should that.
+ assert.equal(mobileItems[2].text, element.items[2].mobileText);
- // Since no triggerText, the fallback is used.
- assert.equal(element.text, element.items[0].text);
- done();
- });
+ // Select a new item.
+ MockInteractions.tap(items[0]);
+ flush();
+ assert.equal(element.value, 1);
+ assert.isTrue(items[0].classList.contains('iron-selected'));
+ assert.isTrue(mobileItems[0].selected);
+
+ // Since no triggerText, the fallback is used.
+ assert.equal(element.text, element.items[0].text);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
deleted file mode 100644
index 0f3d566..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ /dev/null
@@ -1,329 +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 '@polymer/iron-dropdown/iron-dropdown.js';
-import '../gr-button/gr-button.js';
-import '../gr-cursor-manager/gr-cursor-manager.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import '../gr-tooltip-content/gr-tooltip-content.js';
-import '../../../styles/shared-styles.js';
-import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-dropdown_html.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-const REL_NOOPENER = 'noopener';
-const REL_EXTERNAL = 'external';
-
-/**
- * @extends PolymerElement
- */
-class GrDropdown extends KeyboardShortcutMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-dropdown'; }
- /**
- * Fired when a non-link dropdown item with the given ID is tapped.
- *
- * @event tap-item-<id>
- */
-
- /**
- * Fired when a non-link dropdown item is tapped.
- *
- * @event tap-item
- */
-
- static get properties() {
- return {
- items: {
- type: Array,
- observer: '_resetCursorStops',
- },
- downArrow: Boolean,
- topContent: Object,
- horizontalAlign: {
- type: String,
- value: 'left',
- },
-
- /**
- * Style the dropdown trigger as a link (rather than a button).
- */
- link: {
- type: Boolean,
- value: false,
- },
-
- verticalOffset: {
- type: Number,
- value: 40,
- },
-
- /**
- * List the IDs of dropdown buttons to be disabled. (Note this only
- * diisables bittons and not link entries.)
- */
- disabledIds: {
- type: Array,
- value() { return []; },
- },
-
- /**
- * The elements of the list.
- */
- _listElements: {
- type: Array,
- value() { return []; },
- },
- };
- }
-
- get keyBindings() {
- return {
- 'down': '_handleDown',
- 'enter space': '_handleEnter',
- 'tab': '_handleTab',
- 'up': '_handleUp',
- };
- }
-
- /**
- * Handle the up key.
- *
- * @param {!Event} e
- */
- _handleUp(e) {
- if (this.$.dropdown.opened) {
- e.preventDefault();
- e.stopPropagation();
- this.$.cursor.previous();
- } else {
- this._open();
- }
- }
-
- /**
- * Handle the down key.
- *
- * @param {!Event} e
- */
- _handleDown(e) {
- if (this.$.dropdown.opened) {
- e.preventDefault();
- e.stopPropagation();
- this.$.cursor.next();
- } else {
- this._open();
- }
- }
-
- /**
- * Handle the tab key.
- *
- * @param {!Event} e
- */
- _handleTab(e) {
- if (this.$.dropdown.opened) {
- // Tab in a native select is a no-op. Emulate this.
- e.preventDefault();
- e.stopPropagation();
- }
- }
-
- /**
- * Handle the enter key.
- *
- * @param {!Event} e
- */
- _handleEnter(e) {
- e.preventDefault();
- e.stopPropagation();
- if (this.$.dropdown.opened) {
- // TODO(milutin): This solution is not particularly robust in general.
- // Since gr-tooltip-content click on shadow dom is not propagated down,
- // we have to target `a` inside it.
- const el = this.$.cursor.target.querySelector(':not([hidden]) a');
- if (el) { el.click(); }
- } else {
- this._open();
- }
- }
-
- /**
- * Handle a click on the iron-dropdown element.
- *
- * @param {!Event} e
- */
- _handleDropdownClick(e) {
- this._close();
- }
-
- /**
- * Handle a click on the button to open the dropdown.
- *
- * @param {!Event} e
- */
- _dropdownTriggerTapHandler(e) {
- e.preventDefault();
- e.stopPropagation();
- if (this.$.dropdown.opened) {
- this._close();
- } else {
- this._open();
- }
- }
-
- /**
- * Open the dropdown and initialize the cursor.
- */
- _open() {
- this.$.dropdown.open();
- this._resetCursorStops();
- this.$.cursor.setCursorAtIndex(0);
- this.$.cursor.target.focus();
- }
-
- _close() {
- // async is needed so that that the click event is fired before the
- // dropdown closes (This was a bug for touch devices).
- this.async(() => {
- this.$.dropdown.close();
- }, 1);
- }
-
- /**
- * Get the class for a top-content item based on the given boolean.
- *
- * @param {boolean} bold Whether the item is bold.
- * @return {string} The class for the top-content item.
- */
- _getClassIfBold(bold) {
- return bold ? 'bold-text' : '';
- }
-
- /**
- * Build a URL for the given host and path. The base URL will be only added,
- * if it is not already included in the path.
- *
- * @param {!string} host
- * @param {!string} path
- * @return {!string} The scheme-relative URL.
- */
- _computeURLHelper(host, path) {
- const base = path.startsWith(getBaseUrl()) ?
- '' : getBaseUrl();
- return '//' + host + base + path;
- }
-
- /**
- * Build a scheme-relative URL for the current host. Will include the base
- * URL if one is present. Note: the URL will be scheme-relative but absolute
- * with regard to the host.
- *
- * @param {!string} path The path for the URL.
- * @return {!string} The scheme-relative URL.
- */
- _computeRelativeURL(path) {
- const host = window.location.host;
- return this._computeURLHelper(host, path);
- }
-
- /**
- * Compute the URL for a link object.
- *
- * @param {!Object} link The object describing the link.
- * @return {!string} The URL.
- */
- _computeLinkURL(link) {
- if (typeof link.url === 'undefined') {
- return '';
- }
- if (link.target || !link.url.startsWith('/')) {
- return link.url;
- }
- return this._computeRelativeURL(link.url);
- }
-
- /**
- * Compute the value for the rel attribute of an anchor for the given link
- * object. If the link has a target value, then the rel must be "noopener"
- * for security reasons.
- *
- * @param {!Object} link The object describing the link.
- * @return {?string} The rel value for the link.
- */
- _computeLinkRel(link) {
- // Note: noopener takes precedence over external.
- if (link.target) { return REL_NOOPENER; }
- if (link.external) { return REL_EXTERNAL; }
- return null;
- }
-
- /**
- * Handle a click on an item of the dropdown.
- *
- * @param {!Event} e
- */
- _handleItemTap(e) {
- const id = e.target.getAttribute('data-id');
- const item = this.items.find(item => item.id === id);
- if (id && !this.disabledIds.includes(id)) {
- if (item) {
- this.dispatchEvent(new CustomEvent('tap-item',
- {detail: item, bubbles: true, composed: true}));
- }
- this.dispatchEvent(new CustomEvent('tap-item-' + id));
- }
- }
-
- /**
- * If a dropdown item is shown as a button, get the class for the button.
- *
- * @param {string} id
- * @param {!Object} disabledIdsRecord The change record for the disabled IDs
- * list.
- * @return {!string} The class for the item button.
- */
- _computeDisabledClass(id, disabledIdsRecord) {
- return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
- }
-
- /**
- * Recompute the stops for the dropdown item cursor.
- */
- _resetCursorStops() {
- if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
- flush();
- this._listElements = Array.from(
- dom(this.root).querySelectorAll('li'));
- }
- }
-
- _computeHasTooltip(tooltip) {
- return !!tooltip;
- }
-
- _computeIsDownload(link) {
- return !!link.download;
- }
-}
-
-customElements.define(GrDropdown.is, GrDropdown);
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
new file mode 100644
index 0000000..d64b1c0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -0,0 +1,345 @@
+/**
+ * @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 '@polymer/iron-dropdown/iron-dropdown';
+import '../gr-button/gr-button';
+import '../gr-cursor-manager/gr-cursor-manager';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import '../gr-tooltip-content/gr-tooltip-content';
+import '../../../styles/shared-styles';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-dropdown_html';
+import {getBaseUrl} from '../../../utils/url-util';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
+import {property, customElement, observe} from '@polymer/decorators';
+
+const REL_NOOPENER = 'noopener';
+const REL_EXTERNAL = 'external';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-dropdown': GrDropdown;
+ }
+}
+
+export interface GrDropdown {
+ $: {
+ dropdown: IronDropdownElement;
+ cursor: GrCursorManager;
+ };
+}
+
+export interface DropdownLink {
+ url?: string;
+ name?: string;
+ external?: boolean;
+ target?: string | null;
+ download?: boolean;
+ id?: string;
+ tooltip?: string;
+}
+
+interface DisableIdsRecord {
+ base: string[];
+}
+
+interface Content {
+ text: string;
+ bold?: boolean;
+}
+
+@customElement('gr-dropdown')
+export class GrDropdown extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when a non-link dropdown item with the given ID is tapped.
+ *
+ * @event tap-item-<id>
+ */
+
+ /**
+ * Fired when a non-link dropdown item is tapped.
+ *
+ * @event tap-item
+ */
+
+ @property({type: Array})
+ items?: DropdownLink[];
+
+ @property({type: Boolean})
+ downArrow?: boolean;
+
+ @property({type: Array})
+ topContent?: Content[];
+
+ @property({type: String})
+ horizontalAlign = 'left';
+
+ /**
+ * Style the dropdown trigger as a link (rather than a button).
+ */
+
+ @property({type: Boolean})
+ link = false;
+
+ @property({type: Number})
+ verticalOffset = 40;
+
+ /**
+ * List the IDs of dropdown buttons to be disabled. (Note this only
+ * disables buttons and not link entries.)
+ */
+ @property({type: Array})
+ disabledIds: string[] = [];
+
+ /**
+ * The elements of the list.
+ */
+ @property({type: Array})
+ _listElements: Element[] = [];
+
+ get keyBindings() {
+ return {
+ down: '_handleDown',
+ 'enter space': '_handleEnter',
+ tab: '_handleTab',
+ up: '_handleUp',
+ };
+ }
+
+ /**
+ * Handle the up key.
+ */
+ _handleUp(e: MouseEvent) {
+ if (this.$.dropdown.opened) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.$.cursor.previous();
+ } else {
+ this._open();
+ }
+ }
+
+ /**
+ * Handle the down key.
+ */
+ _handleDown(e: MouseEvent) {
+ if (this.$.dropdown.opened) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.$.cursor.next();
+ } else {
+ this._open();
+ }
+ }
+
+ /**
+ * Handle the tab key.
+ */
+ _handleTab(e: MouseEvent) {
+ if (this.$.dropdown.opened) {
+ // Tab in a native select is a no-op. Emulate this.
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+
+ /**
+ * Handle the enter key.
+ */
+ _handleEnter(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (this.$.dropdown.opened) {
+ // TODO(milutin): This solution is not particularly robust in general.
+ // Since gr-tooltip-content click on shadow dom is not propagated down,
+ // we have to target `a` inside it.
+ if (this.$.cursor.target !== null) {
+ const el = this.$.cursor.target.querySelector(':not([hidden]) a');
+ if (el) {
+ (el as HTMLElement).click();
+ }
+ }
+ } else {
+ this._open();
+ }
+ }
+
+ /**
+ * Handle a click on the iron-dropdown element.
+ */
+ _handleDropdownClick() {
+ this._close();
+ }
+
+ /**
+ * Handle a click on the button to open the dropdown.
+ */
+ _dropdownTriggerTapHandler(e: MouseEvent) {
+ e.preventDefault();
+ e.stopPropagation();
+ if (this.$.dropdown.opened) {
+ this._close();
+ } else {
+ this._open();
+ }
+ }
+
+ /**
+ * Open the dropdown and initialize the cursor.
+ */
+ _open() {
+ this.$.dropdown.open();
+ this._resetCursorStops();
+ this.$.cursor.setCursorAtIndex(0);
+ if (this.$.cursor.target !== null) this.$.cursor.target.focus();
+ }
+
+ _close() {
+ // async is needed so that that the click event is fired before the
+ // dropdown closes (This was a bug for touch devices).
+ this.async(() => {
+ this.$.dropdown.close();
+ }, 1);
+ }
+
+ /**
+ * Get the class for a top-content item based on the given boolean.
+ *
+ * @param bold Whether the item is bold.
+ * @return The class for the top-content item.
+ */
+ _getClassIfBold(bold: boolean) {
+ return bold ? 'bold-text' : '';
+ }
+
+ /**
+ * Build a URL for the given host and path. The base URL will be only added,
+ * if it is not already included in the path.
+ *
+ * @return The scheme-relative URL.
+ */
+ _computeURLHelper(host: string, path: string) {
+ const base = path.startsWith(getBaseUrl()) ? '' : getBaseUrl();
+ return '//' + host + base + path;
+ }
+
+ /**
+ * Build a scheme-relative URL for the current host. Will include the base
+ * URL if one is present. Note: the URL will be scheme-relative but absolute
+ * with regard to the host.
+ *
+ * @param path The path for the URL.
+ * @return The scheme-relative URL.
+ */
+ _computeRelativeURL(path: string) {
+ const host = window.location.host;
+ return this._computeURLHelper(host, path);
+ }
+
+ /**
+ * Compute the URL for a link object.
+ */
+ _computeLinkURL(link: DropdownLink) {
+ if (typeof link.url === 'undefined') {
+ return '';
+ }
+ if (link.target || !link.url.startsWith('/')) {
+ return link.url;
+ }
+ return this._computeRelativeURL(link.url);
+ }
+
+ /**
+ * Compute the value for the rel attribute of an anchor for the given link
+ * object. If the link has a target value, then the rel must be "noopener"
+ * for security reasons.
+ */
+ _computeLinkRel(link: DropdownLink) {
+ // Note: noopener takes precedence over external.
+ if (link.target) {
+ return REL_NOOPENER;
+ }
+ if (link.external) {
+ return REL_EXTERNAL;
+ }
+ return null;
+ }
+
+ /**
+ * Handle a click on an item of the dropdown.
+ */
+ _handleItemTap(e: MouseEvent) {
+ if (e.target === null || !this.items) {
+ return;
+ }
+ const id = (e.target as Element).getAttribute('data-id');
+ const item = this.items.find(item => item.id === id);
+ if (id && !this.disabledIds.includes(id)) {
+ if (item) {
+ this.dispatchEvent(
+ new CustomEvent('tap-item', {
+ detail: item,
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ this.dispatchEvent(new CustomEvent('tap-item-' + id));
+ }
+ }
+
+ /**
+ * If a dropdown item is shown as a button, get the class for the button.
+ *
+ * @param disabledIdsRecord The change record for the disabled IDs
+ * list.
+ * @return The class for the item button.
+ */
+ _computeDisabledClass(id: string, disabledIdsRecord: DisableIdsRecord) {
+ return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
+ }
+
+ /**
+ * Recompute the stops for the dropdown item cursor.
+ */
+ @observe('items')
+ _resetCursorStops() {
+ if (this.items && this.items.length > 0 && this.$.dropdown.opened) {
+ flush();
+ this._listElements =
+ this.root !== null ? Array.from(this.root.querySelectorAll('li')) : [];
+ }
+ }
+
+ _computeHasTooltip(tooltip?: string) {
+ return !!tooltip;
+ }
+
+ _computeIsDownload(link: DropdownLink) {
+ return !!link.download;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
index d1d9164..515644b 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
@@ -94,8 +94,8 @@
test('Top text exists and is bolded correctly', () => {
element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
- flushAsynchronousOperations();
- const topItems = dom(element.root).querySelectorAll('.top-item');
+ flush();
+ const topItems = element.root.querySelectorAll('.top-item');
assert.equal(topItems.length, 2);
assert.isTrue(topItems[0].classList.contains('bold-text'));
assert.isFalse(topItems[1].classList.contains('bold-text'));
@@ -108,7 +108,7 @@
const tapped = sinon.stub();
element.addEventListener('tap-item-foo', fooTapped);
element.addEventListener('tap-item', tapped);
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('.itemAction'));
assert.isTrue(fooTapped.called);
@@ -124,7 +124,7 @@
const tapped = sinon.stub();
element.addEventListener('tap-item-foo', stub);
element.addEventListener('tap-item', tapped);
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot
.querySelector('.itemAction'));
assert.isFalse(stub.called);
@@ -137,7 +137,7 @@
{name: 'item two', id: 'bar'},
];
element.disabledIds = [];
- flushAsynchronousOperations();
+ flush();
const tooltipContents = dom(element.root)
.querySelectorAll('iron-dropdown li gr-tooltip-content');
assert.equal(tooltipContents.length, 2);
@@ -152,7 +152,7 @@
{name: 'item one', id: 'foo'},
{name: 'item two', id: 'bar'},
];
- flushAsynchronousOperations();
+ flush();
});
test('down', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
deleted file mode 100644
index b2bcda9..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ /dev/null
@@ -1,182 +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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '../../../styles/shared-styles.js';
-import '../gr-storage/gr-storage.js';
-import '../gr-button/gr-button.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-editable-content_html.js';
-
-const RESTORED_MESSAGE = 'Content restored from a previous edit.';
-const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
-
-/**
- * @extends PolymerElement
- */
-class GrEditableContent extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-editable-content'; }
- /**
- * Fired when the save button is pressed.
- *
- * @event editable-content-save
- */
-
- /**
- * Fired when the cancel button is pressed.
- *
- * @event editable-content-cancel
- */
-
- /**
- * Fired when content is restored from storage.
- *
- * @event show-alert
- */
-
- static get properties() {
- return {
- content: {
- notify: true,
- type: String,
- observer: '_contentChanged',
- },
- disabled: {
- reflectToAttribute: true,
- type: Boolean,
- value: false,
- },
- editing: {
- observer: '_editingChanged',
- type: Boolean,
- value: false,
- },
- removeZeroWidthSpace: Boolean,
- // If no storage key is provided, content is not stored.
- storageKey: String,
- _saveDisabled: {
- computed: '_computeSaveDisabled(disabled, content, _newContent)',
- type: Boolean,
- value: true,
- },
- _newContent: {
- type: String,
- observer: '_newContentChanged',
- },
- };
- }
-
- _contentChanged(content) {
- /* A changed content means that either a different change has been loaded
- * or new content was saved. Either way, let's reset the component.
- */
- this.editing = false;
- this._newContent = '';
- }
-
- focusTextarea() {
- this.shadowRoot.querySelector('iron-autogrow-textarea').textarea.focus();
- }
-
- _newContentChanged(newContent, oldContent) {
- if (!this.storageKey) { return; }
-
- this.debounce('store', () => {
- if (newContent.length) {
- this.$.storage.setEditableContentItem(this.storageKey, newContent);
- } else {
- // This does not really happen, because we don't clear newContent
- // after saving (see below). So this only occurs when the user clears
- // all the content in the editable textarea. But <gr-storage> cleans
- // up itself after one day, so we are not so concerned about leaving
- // some garbage behind.
- this.$.storage.eraseEditableContentItem(this.storageKey);
- }
- }, STORAGE_DEBOUNCE_INTERVAL_MS);
- }
-
- _editingChanged(editing) {
- // This method is for initializing _newContent when you start editing.
- // Restoring content from local storage is not perfect and has
- // some issues:
- //
- // 1. When you start editing in multiple tabs, then we are vulnerable to
- // race conditions between the tabs.
- // 2. The stored content is keyed by revision, so when you upload a new
- // patchset and click "reload" and then click "cancel" on the content-
- // editable, then you won't be able to recover the content anymore.
- //
- // Because of these issues we believe that it is better to only recover
- // content from local storage when you enter editing mode for the first
- // time. Otherwise it is better to just keep the last editing state from
- // the same session.
- if (!editing || this._newContent) {
- return;
- }
-
- let content;
- if (this.storageKey) {
- const storedContent =
- this.$.storage.getEditableContentItem(this.storageKey);
- if (storedContent && storedContent.message) {
- content = storedContent.message;
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {message: RESTORED_MESSAGE},
- bubbles: true,
- composed: true,
- }));
- }
- }
- if (!content) {
- content = this.content || '';
- }
-
- // TODO(wyatta) switch linkify sequence, see issue 5526.
- this._newContent = this.removeZeroWidthSpace ?
- content.replace(/^R=\u200B/gm, 'R=') :
- content;
- }
-
- _computeSaveDisabled(disabled, content, newContent) {
- return disabled || !newContent || content === newContent;
- }
-
- _handleSave(e) {
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('editable-content-save', {
- detail: {content: this._newContent},
- composed: true, bubbles: true,
- }));
- // It would be nice, if we would set this._newContent = undefined here,
- // but we can only do that when we are sure that the save operation has
- // succeeded.
- }
-
- _handleCancel(e) {
- e.preventDefault();
- this.editing = false;
- this.dispatchEvent(new CustomEvent('editable-content-cancel', {
- composed: true, bubbles: true,
- }));
- }
-}
-
-customElements.define(GrEditableContent.is, GrEditableContent);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
new file mode 100644
index 0000000..90aaa9f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.ts
@@ -0,0 +1,203 @@
+/**
+ * @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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/shared-styles';
+import '../gr-storage/gr-storage';
+import '../gr-button/gr-button';
+import {GrStorage} from '../gr-storage/gr-storage';
+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 {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-editable-content_html';
+
+const RESTORED_MESSAGE = 'Content restored from a previous edit.';
+const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-editable-content': GrEditableContent;
+ }
+}
+
+export interface GrEditableContent {
+ $: {
+ storage: GrStorage;
+ };
+}
+
+@customElement('gr-editable-content')
+export class GrEditableContent extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the save button is pressed.
+ *
+ * @event editable-content-save
+ */
+
+ /**
+ * Fired when the cancel button is pressed.
+ *
+ * @event editable-content-cancel
+ */
+
+ /**
+ * Fired when content is restored from storage.
+ *
+ * @event show-alert
+ */
+
+ @property({type: String, notify: true, observer: '_contentChanged'})
+ content?: string;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ disabled = false;
+
+ @property({type: Boolean, observer: '_editingChanged'})
+ editing = false;
+
+ @property({type: Boolean})
+ removeZeroWidthSpace?: boolean;
+
+ // If no storage key is provided, content is not stored.
+ @property({type: String})
+ storageKey?: string;
+
+ @property({
+ type: Boolean,
+ computed: '_computeSaveDisabled(disabled, content, _newContent)',
+ })
+ _saveDisabled!: boolean;
+
+ @property({type: String, observer: '_newContentChanged'})
+ _newContent?: string;
+
+ _contentChanged() {
+ /* A changed content means that either a different change has been loaded
+ * or new content was saved. Either way, let's reset the component.
+ */
+ this.editing = false;
+ this._newContent = '';
+ }
+
+ focusTextarea() {
+ this.shadowRoot!.querySelector('iron-autogrow-textarea')!.textarea.focus();
+ }
+
+ _newContentChanged(newContent: string) {
+ if (!this.storageKey) return;
+ const storageKey = this.storageKey;
+
+ this.debounce(
+ 'store',
+ () => {
+ if (newContent.length) {
+ this.$.storage.setEditableContentItem(storageKey, newContent);
+ } else {
+ // This does not really happen, because we don't clear newContent
+ // after saving (see below). So this only occurs when the user clears
+ // all the content in the editable textarea. But <gr-storage> cleans
+ // up itself after one day, so we are not so concerned about leaving
+ // some garbage behind.
+ this.$.storage.eraseEditableContentItem(storageKey);
+ }
+ },
+ STORAGE_DEBOUNCE_INTERVAL_MS
+ );
+ }
+
+ _editingChanged(editing: boolean) {
+ // This method is for initializing _newContent when you start editing.
+ // Restoring content from local storage is not perfect and has
+ // some issues:
+ //
+ // 1. When you start editing in multiple tabs, then we are vulnerable to
+ // race conditions between the tabs.
+ // 2. The stored content is keyed by revision, so when you upload a new
+ // patchset and click "reload" and then click "cancel" on the content-
+ // editable, then you won't be able to recover the content anymore.
+ //
+ // Because of these issues we believe that it is better to only recover
+ // content from local storage when you enter editing mode for the first
+ // time. Otherwise it is better to just keep the last editing state from
+ // the same session.
+ if (!editing || this._newContent) return;
+
+ let content;
+ if (this.storageKey) {
+ const storedContent = this.$.storage.getEditableContentItem(
+ this.storageKey
+ );
+ if (storedContent?.message) {
+ content = storedContent.message;
+ this.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {message: RESTORED_MESSAGE},
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+ }
+ if (!content) {
+ content = this.content || '';
+ }
+
+ // TODO(wyatta) switch linkify sequence, see issue 5526.
+ this._newContent = this.removeZeroWidthSpace
+ ? content.replace(/^R=\u200B/gm, 'R=')
+ : content;
+ }
+
+ _computeSaveDisabled(
+ disabled?: boolean,
+ content?: string,
+ newContent?: string
+ ): boolean {
+ return disabled || !newContent || content === newContent;
+ }
+
+ _handleSave(e: Event) {
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('editable-content-save', {
+ detail: {content: this._newContent},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ // It would be nice, if we would set this._newContent = undefined here,
+ // but we can only do that when we are sure that the save operation has
+ // succeeded.
+ }
+
+ _handleCancel(e: Event) {
+ e.preventDefault();
+ this.editing = false;
+ this.dispatchEvent(
+ new CustomEvent('editable-content-cancel', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
index 0a9dd79..129fda8 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
@@ -27,23 +27,27 @@
element = basicFixture.instantiate();
});
- test('save event', done => {
+ test('save event', () => {
element.content = '';
element._newContent = 'foo';
- element.addEventListener('editable-content-save', e => {
- assert.equal(e.detail.content, 'foo');
- done();
- });
+ const handler = sinon.spy();
+ element.addEventListener('editable-content-save', handler);
+
MockInteractions.tap(element.shadowRoot
.querySelector('gr-button[primary]'));
+
+ assert.isTrue(handler.called);
+ assert.equal(handler.lastCall.args[0].detail.content, 'foo');
});
- test('cancel event', done => {
- element.addEventListener('editable-content-cancel', () => {
- done();
- });
+ test('cancel event', () => {
+ const handler = sinon.spy();
+ element.addEventListener('editable-content-cancel', handler);
+
MockInteractions.tap(element.shadowRoot
.querySelector('gr-button:not([primary])'));
+
+ assert.isTrue(handler.called);
});
test('enabling editing keeps old content', () => {
@@ -121,7 +125,7 @@
element.editing = true;
element._newContent = 'new content';
- flushAsynchronousOperations();
+ flush();
element.flushDebouncer('store');
assert.isTrue(storeStub.called);
@@ -130,7 +134,7 @@
storeStub.lastCall.args);
element._newContent = '';
- flushAsynchronousOperations();
+ flush();
element.flushDebouncer('store');
assert.isTrue(eraseStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
deleted file mode 100644
index a323528..0000000
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ /dev/null
@@ -1,213 +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 {IronOverlayBehaviorImpl} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.js';
-import '@polymer/iron-dropdown/iron-dropdown.js';
-import '@polymer/paper-input/paper-input.js';
-import '../../../styles/shared-styles.js';
-import '../gr-button/gr-button.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-editable-label_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-const AWAIT_MAX_ITERS = 10;
-const AWAIT_STEP = 5;
-
-/**
- * @extends PolymerElement
- */
-class GrEditableLabel extends KeyboardShortcutMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-editable-label'; }
- /**
- * Fired when the value is changed.
- *
- * @event changed
- */
-
- static get properties() {
- return {
- labelText: String,
- editing: {
- type: Boolean,
- value: false,
- },
- value: {
- type: String,
- notify: true,
- value: '',
- observer: '_updateTitle',
- },
- placeholder: {
- type: String,
- value: '',
- },
- readOnly: {
- type: Boolean,
- value: false,
- },
- uppercase: {
- type: Boolean,
- reflectToAttribute: true,
- value: false,
- },
- maxLength: Number,
- _inputText: String,
- // This is used to push the iron-input element up on the page, so
- // the input is placed in approximately the same position as the
- // trigger.
- _verticalOffset: {
- type: Number,
- readOnly: true,
- value: -30,
- },
- };
- }
-
- /** @override */
- ready() {
- super.ready();
- this._ensureAttribute('tabindex', '0');
- }
-
- get keyBindings() {
- return {
- enter: '_handleEnter',
- esc: '_handleEsc',
- };
- }
-
- _usePlaceholder(value, placeholder) {
- return (!value || !value.length) && placeholder;
- }
-
- _computeLabel(value, placeholder) {
- if (this._usePlaceholder(value, placeholder)) {
- return placeholder;
- }
- return value;
- }
-
- _showDropdown() {
- if (this.readOnly || this.editing) { return; }
- return this._open().then(() => {
- this._nativeInput.focus();
- if (!this.$.input.value) { return; }
- this._nativeInput.setSelectionRange(0, this.$.input.value.length);
- });
- }
-
- open() {
- return this._open().then(() => {
- this._nativeInput.focus();
- });
- }
-
- _open(...args) {
- this.$.dropdown.open();
- this._inputText = this.value;
- this.editing = true;
-
- return new Promise(resolve => {
- IronOverlayBehaviorImpl.open.apply(this.$.dropdown, args);
- this._awaitOpen(resolve);
- });
- }
-
- /**
- * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
- * opening. Eventually replace with a direct way to listen to the overlay.
- */
- _awaitOpen(fn) {
- let iters = 0;
- const step = () => {
- this.async(() => {
- if (this.$.dropdown.style.display !== 'none') {
- fn.call(this);
- } else if (iters++ < AWAIT_MAX_ITERS) {
- step.call(this);
- }
- }, AWAIT_STEP);
- };
- step.call(this);
- }
-
- _id() {
- return this.getAttribute('id') || 'global';
- }
-
- _save() {
- if (!this.editing) { return; }
- this.$.dropdown.close();
- this.value = this._inputText;
- this.editing = false;
- this.dispatchEvent(new CustomEvent('changed', {
- detail: this.value,
- composed: true, bubbles: true,
- }));
- }
-
- _cancel() {
- if (!this.editing) { return; }
- this.$.dropdown.close();
- this.editing = false;
- this._inputText = this.value;
- }
-
- get _nativeInput() {
- // In Polymer 2, the namespace of nativeInput
- // changed from input to nativeInput
- return this.$.input.$.nativeInput || this.$.input.$.input;
- }
-
- _handleEnter(e) {
- e = this.getKeyboardEvent(e);
- const target = dom(e).rootTarget;
- if (target === this._nativeInput) {
- e.preventDefault();
- this._save();
- }
- }
-
- _handleEsc(e) {
- e = this.getKeyboardEvent(e);
- const target = dom(e).rootTarget;
- if (target === this._nativeInput) {
- e.preventDefault();
- this._cancel();
- }
- }
-
- _computeLabelClass(readOnly, value, placeholder) {
- const classes = [];
- if (!readOnly) { classes.push('editable'); }
- if (this._usePlaceholder(value, placeholder)) {
- classes.push('placeholder');
- }
- return classes.join(' ');
- }
-
- _updateTitle(value) {
- this.setAttribute('title', this._computeLabel(value, this.placeholder));
- }
-}
-
-customElements.define(GrEditableLabel.is, GrEditableLabel);
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
new file mode 100644
index 0000000..9e1a5bf
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.ts
@@ -0,0 +1,226 @@
+/**
+ * @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 '@polymer/iron-dropdown/iron-dropdown';
+import '@polymer/paper-input/paper-input';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+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 {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-editable-label_html';
+import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {PaperInputElementExt} from '../../../types/types';
+import {CustomKeyboardEvent} from '../../../types/events';
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-editable-label': GrEditableLabel;
+ }
+}
+
+export interface GrEditableLabel {
+ $: {
+ input: PaperInputElementExt;
+ dropdown: IronDropdownElement;
+ };
+}
+
+@customElement('gr-editable-label')
+export class GrEditableLabel extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when the value is changed.
+ *
+ * @event changed
+ */
+
+ @property({type: String})
+ labelText?: string;
+
+ @property({type: Boolean})
+ editing = false;
+
+ @property({type: String, notify: true, observer: '_updateTitle'})
+ value = '';
+
+ @property({type: String})
+ placeholder = '';
+
+ @property({type: Boolean})
+ readOnly = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ uppercase = false;
+
+ @property({type: Number})
+ maxLength?: number;
+
+ @property({type: String})
+ _inputText?: string;
+
+ // This is used to push the iron-input element up on the page, so
+ // the input is placed in approximately the same position as the
+ // trigger.
+ @property({type: Number})
+ readonly _verticalOffset = -30;
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._ensureAttribute('tabindex', '0');
+ }
+
+ get keyBindings() {
+ return {
+ enter: '_handleEnter',
+ esc: '_handleEsc',
+ };
+ }
+
+ _usePlaceholder(value?: string, placeholder?: string) {
+ return (!value || !value.length) && placeholder;
+ }
+
+ _computeLabel(value?: string, placeholder?: string): string {
+ if (this._usePlaceholder(value, placeholder)) {
+ return placeholder!;
+ }
+ return value || '';
+ }
+
+ _showDropdown() {
+ if (this.readOnly || this.editing) return;
+ return this._open().then(() => {
+ this._nativeInput.focus();
+ if (!this.$.input.value) return;
+ this._nativeInput.setSelectionRange(0, this.$.input.value.length);
+ });
+ }
+
+ open() {
+ return this._open().then(() => {
+ this._nativeInput.focus();
+ });
+ }
+
+ _open() {
+ this.$.dropdown.open();
+ this._inputText = this.value;
+ this.editing = true;
+
+ return new Promise(resolve => {
+ this._awaitOpen(resolve);
+ });
+ }
+
+ /**
+ * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
+ * opening. Eventually replace with a direct way to listen to the overlay.
+ */
+ _awaitOpen(fn: () => void) {
+ let iters = 0;
+ const step = () => {
+ this.async(() => {
+ if (this.$.dropdown.style.display !== 'none') {
+ fn.call(this);
+ } else if (iters++ < AWAIT_MAX_ITERS) {
+ step.call(this);
+ }
+ }, AWAIT_STEP);
+ };
+ step.call(this);
+ }
+
+ _id() {
+ return this.getAttribute('id') || 'global';
+ }
+
+ _save() {
+ if (!this.editing) {
+ return;
+ }
+ this.$.dropdown.close();
+ this.value = this._inputText || '';
+ this.editing = false;
+ this.dispatchEvent(
+ new CustomEvent('changed', {
+ detail: this.value,
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _cancel() {
+ if (!this.editing) {
+ return;
+ }
+ this.$.dropdown.close();
+ this.editing = false;
+ this._inputText = this.value;
+ }
+
+ get _nativeInput(): HTMLInputElement {
+ // In Polymer 2 inputElement isn't nativeInput anymore
+ return (this.$.input.$.nativeInput ||
+ this.$.input.inputElement) as HTMLInputElement;
+ }
+
+ _handleEnter(e: CustomKeyboardEvent) {
+ e = this.getKeyboardEvent(e);
+ const target = (dom(e) as EventApi).rootTarget;
+ if (target === this._nativeInput) {
+ e.preventDefault();
+ this._save();
+ }
+ }
+
+ _handleEsc(e: CustomKeyboardEvent) {
+ e = this.getKeyboardEvent(e);
+ const target = (dom(e) as EventApi).rootTarget;
+ if (target === this._nativeInput) {
+ e.preventDefault();
+ this._cancel();
+ }
+ }
+
+ _computeLabelClass(readOnly?: boolean, value?: string, placeholder?: string) {
+ const classes = [];
+ if (!readOnly) {
+ classes.push('editable');
+ }
+ if (this._usePlaceholder(value, placeholder)) {
+ classes.push('placeholder');
+ }
+ return classes.join(' ');
+ }
+
+ _updateTitle(value?: string) {
+ this.setAttribute('title', this._computeLabel(value, this.placeholder));
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
index 8c04aed..d8f085e 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-editable-label.js';
-import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
const basicFixture = fixtureFromTemplate(html`
@@ -43,18 +42,14 @@
let input;
let label;
- setup(done => {
+ setup(async () => {
element = basicFixture.instantiate();
elementNoPlaceholder = noPlaceholderFixture.instantiate();
+ label = element.shadowRoot.querySelector('label');
- label = element.shadowRoot
- .querySelector('label');
-
- flush(() => {
- // In Polymer 2 inputElement isn't nativeInput anymore
- input = element.$.input.$.nativeInput || element.$.input.inputElement;
- done();
- });
+ await flush();
+ // In Polymer 2 inputElement isn't nativeInput anymore
+ input = element.$.input.$.nativeInput || element.$.input.inputElement;
});
test('element render', () => {
@@ -75,126 +70,106 @@
});
});
- test('title with placeholder', done => {
+ test('title with placeholder', () => {
assert.equal(element.title, 'value text');
element.value = '';
- element.async(() => {
- assert.equal(element.title, 'label text');
- done();
- });
+ flush();
+ assert.equal(element.title, 'label text');
});
- test('title without placeholder', done => {
+ test('title without placeholder', () => {
assert.equal(elementNoPlaceholder.title, '');
element.value = 'value text';
- element.async(() => {
- assert.equal(element.title, 'value text');
- done();
- });
+ flush();
+ assert.equal(element.title, 'value text');
});
- test('edit value', done => {
- const editedStub = sinon.stub();
- element.addEventListener('changed', editedStub);
+ test('edit value', async () => {
+ const editedSpy = sinon.spy();
+ element.addEventListener('changed', editedSpy);
assert.isFalse(element.editing);
MockInteractions.tap(label);
-
- flush$0();
+ flush();
assert.isTrue(element.editing);
+ assert.isFalse(editedSpy.called);
+
element._inputText = 'new text';
-
- assert.isFalse(editedStub.called);
-
- element.async(() => {
- assert.isTrue(editedStub.called);
- assert.equal(input.value, 'new text');
- assert.isFalse(element.editing);
- done();
- });
-
// Press enter:
MockInteractions.keyDownOn(input, 13);
+ flush();
+
+ assert.isTrue(editedSpy.called);
+ assert.equal(input.value, 'new text');
+ assert.isFalse(element.editing);
});
- test('save button', done => {
- const editedStub = sinon.stub();
- element.addEventListener('changed', editedStub);
+ test('save button', () => {
+ const editedSpy = sinon.spy();
+ element.addEventListener('changed', editedSpy);
assert.isFalse(element.editing);
MockInteractions.tap(label);
-
- flush$0();
+ flush();
assert.isTrue(element.editing);
+ assert.isFalse(editedSpy.called);
+
element._inputText = 'new text';
-
- assert.isFalse(editedStub.called);
-
- element.async(() => {
- assert.isTrue(editedStub.called);
- assert.equal(input.value, 'new text');
- assert.isFalse(element.editing);
- done();
- });
-
// Press enter:
MockInteractions.tap(element.$.saveBtn, 13);
+ flush();
+
+ assert.isTrue(editedSpy.called);
+ assert.equal(input.value, 'new text');
+ assert.isFalse(element.editing);
});
- test('edit and then escape key', done => {
- const editedStub = sinon.stub();
- element.addEventListener('changed', editedStub);
+ test('edit and then escape key', () => {
+ const editedSpy = sinon.spy();
+ element.addEventListener('changed', editedSpy);
assert.isFalse(element.editing);
MockInteractions.tap(label);
-
- flush$0();
+ flush();
assert.isTrue(element.editing);
+ assert.isFalse(editedSpy.called);
+
element._inputText = 'new text';
-
- assert.isFalse(editedStub.called);
-
- element.async(() => {
- assert.isFalse(editedStub.called);
- // Text changes should be discarded.
- assert.equal(input.value, 'value text');
- assert.isFalse(element.editing);
- done();
- });
-
// Press escape:
MockInteractions.keyDownOn(input, 27);
+ flush();
+
+ assert.isFalse(editedSpy.called);
+ // Text changes should be discarded.
+ assert.equal(input.value, 'value text');
+ assert.isFalse(element.editing);
});
- test('cancel button', done => {
- const editedStub = sinon.stub();
- element.addEventListener('changed', editedStub);
+ test('cancel button', () => {
+ const editedSpy = sinon.spy();
+ element.addEventListener('changed', editedSpy);
assert.isFalse(element.editing);
MockInteractions.tap(label);
-
- flush$0();
+ flush();
assert.isTrue(element.editing);
+ assert.isFalse(editedSpy.called);
+
element._inputText = 'new text';
-
- assert.isFalse(editedStub.called);
-
- element.async(() => {
- assert.isFalse(editedStub.called);
- // Text changes should be discarded.
- assert.equal(input.value, 'value text');
- assert.isFalse(element.editing);
- done();
- });
-
// Press escape:
MockInteractions.tap(element.$.cancelBtn);
+ flush();
+
+ assert.isFalse(editedSpy.called);
+ // Text changes should be discarded.
+ assert.equal(input.value, 'value text');
+ assert.isFalse(element.editing);
});
suite('gr-editable-label read-only tests', () => {
@@ -212,7 +187,7 @@
assert.isFalse(element.$.dropdown.opened);
MockInteractions.tap(label);
- flush$0();
+ flush();
// The dropdown is still closed.
assert.isFalse(element.$.dropdown.opened);
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
deleted file mode 100644
index bc79737..0000000
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
+++ /dev/null
@@ -1,242 +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/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-fixed-panel_html.js';
-
-/** @extends PolymerElement */
-class GrFixedPanel extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-fixed-panel'; }
-
- static get properties() {
- return {
- floatingDisabled: {
- type: Boolean,
- value: false,
- },
- readyForMeasure: {
- type: Boolean,
- observer: '_readyForMeasureObserver',
- },
- keepOnScroll: {
- type: Boolean,
- value: false,
- },
- _isMeasured: {
- type: Boolean,
- value: false,
- },
-
- /**
- * Initial offset from the top of the document, in pixels.
- */
- _topInitial: Number,
-
- /**
- * Current offset from the top of the window, in pixels.
- */
- _topLast: Number,
-
- _headerHeight: Number,
- _headerFloating: {
- type: Boolean,
- value: false,
- },
- _observer: {
- type: Object,
- value: null,
- },
- /**
- * If place before any other content defines how much
- * of the content below it is covered by this panel
- */
- floatingHeight: {
- type: Number,
- value: 0,
- notify: true,
- },
-
- _webComponentsReady: Boolean,
- };
- }
-
- static get observers() {
- return [
- '_updateFloatingHeight(floatingDisabled, _isMeasured, _headerHeight)',
- ];
- }
-
- _updateFloatingHeight(floatingDisabled, isMeasured, headerHeight) {
- if ([
- floatingDisabled,
- isMeasured,
- headerHeight,
- ].some(arg => arg === undefined)) {
- return;
- }
- this.floatingHeight =
- (!floatingDisabled && isMeasured) ? headerHeight : 0;
- }
-
- /** @override */
- attached() {
- super.attached();
- if (this.floatingDisabled) {
- return;
- }
- // Enable content measure unless blocked by param.
- if (this.readyForMeasure !== false) {
- this.readyForMeasure = true;
- }
- this.listen(window, 'resize', 'update');
- this.listen(window, 'scroll', '_updateOnScroll');
- this._observer = new MutationObserver(this.update.bind(this));
- this._observer.observe(this.$.header, {childList: true, subtree: true});
- }
-
- /** @override */
- detached() {
- super.detached();
- this.unlisten(window, 'scroll', '_updateOnScroll');
- this.unlisten(window, 'resize', 'update');
- if (this._observer) {
- this._observer.disconnect();
- }
- }
-
- _readyForMeasureObserver(readyForMeasure) {
- if (readyForMeasure) {
- this.update();
- }
- }
-
- _computeHeaderClass(headerFloating, topLast) {
- const fixedAtTop = this.keepOnScroll && topLast === 0;
- return [
- headerFloating ? 'floating' : '',
- fixedAtTop ? 'fixedAtTop' : '',
- ].join(' ');
- }
-
- unfloat() {
- if (this.floatingDisabled) {
- return;
- }
- this.$.header.style.top = '';
- this._headerFloating = false;
- this.updateStyles({'--header-height': ''});
- }
-
- update() {
- this.debounce('update', () => {
- this._updateDebounced();
- }, 100);
- }
-
- _updateOnScroll() {
- this.debounce('update', () => {
- this._updateDebounced();
- });
- }
-
- _updateDebounced() {
- if (this.floatingDisabled) {
- return;
- }
- this._isMeasured = false;
- this._maybeFloatHeader();
- this._reposition();
- }
-
- _getElementTop() {
- return this.getBoundingClientRect().top;
- }
-
- _reposition() {
- if (!this._headerFloating) {
- return;
- }
- const header = this.$.header;
- // Since the outer element is relative positioned, can use its top
- // to determine how to position the inner header element.
- const elemTop = this._getElementTop();
- let newTop;
- if (this.keepOnScroll && elemTop < 0) {
- // Should stick to the top.
- newTop = 0;
- } else {
- // Keep in line with the outer element.
- newTop = elemTop;
- }
- // Initialize top style if it doesn't exist yet.
- if (!header.style.top && this._topLast === newTop) {
- header.style.top = newTop;
- }
- if (this._topLast !== newTop) {
- if (newTop === undefined) {
- header.style.top = '';
- } else {
- header.style.top = newTop + 'px';
- }
- this._topLast = newTop;
- }
- }
-
- _measure() {
- if (this._isMeasured) {
- return; // Already measured.
- }
- const rect = this.$.header.getBoundingClientRect();
- if (rect.height === 0 && rect.width === 0) {
- return; // Not ready for measurement yet.
- }
- const top = document.body.scrollTop + rect.top;
- this._topLast = top;
- this._headerHeight = rect.height;
- this._topInitial =
- this.getBoundingClientRect().top + document.body.scrollTop;
- this._isMeasured = true;
- }
-
- _isFloatingNeeded() {
- return this.keepOnScroll ||
- document.body.scrollWidth > document.body.clientWidth;
- }
-
- _maybeFloatHeader() {
- if (!this._isFloatingNeeded()) {
- return;
- }
- this._measure();
- if (this._isMeasured) {
- this._floatHeader();
- }
- }
-
- _floatHeader() {
- this.updateStyles({'--header-height': this._headerHeight + 'px'});
- this._headerFloating = true;
- }
-}
-
-customElements.define(GrFixedPanel.is, GrFixedPanel);
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.ts b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.ts
deleted file mode 100644
index ce475c6..0000000
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_html.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * @license
- * 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.
- */
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
- <style include="shared-styles">
- :host {
- box-sizing: border-box;
- display: block;
- min-height: var(--header-height);
- position: relative;
- }
- header {
- background: inherit;
- border: inherit;
- display: inline;
- height: inherit;
- }
- .floating {
- left: 0;
- position: fixed;
- width: 100%;
- will-change: top;
- }
- .fixedAtTop {
- border-bottom: 1px solid #a4a4a4;
- box-shadow: var(--elevation-level-2);
- }
- </style>
- <header
- id="header"
- class$="[[_computeHeaderClass(_headerFloating, _topLast)]]"
- >
- <slot></slot>
- </header>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.js
deleted file mode 100644
index b9378ba..0000000
--- a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.js
+++ /dev/null
@@ -1,99 +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 '../../../test/common-test-setup-karma.js';
-import './gr-fixed-panel.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-fixed-panel>
- <div style="height: 100px"></div>
- </gr-fixed-panel>
-`);
-
-suite('gr-fixed-panel', () => {
- let element;
-
- setup(() => {
- element = basicFixture.instantiate();
- element.readyForMeasure = true;
- });
-
- test('can be disabled with floatingDisabled', () => {
- element.floatingDisabled = true;
- sinon.stub(element, '_reposition');
- window.dispatchEvent(new CustomEvent('resize'));
- element.flushDebouncer('update');
- assert.isFalse(element._reposition.called);
- });
-
- test('header is the height of the content', () => {
- assert.equal(element.getBoundingClientRect().height, 100);
- });
-
- test('scroll triggers _reposition', () => {
- sinon.stub(element, '_reposition');
- window.dispatchEvent(new CustomEvent('scroll'));
- element.flushDebouncer('update');
- assert.isTrue(element._reposition.called);
- });
-
- suite('_reposition', () => {
- const getHeaderTop = function() {
- return element.$.header.style.top;
- };
-
- const emulateScrollY = function(distance) {
- element._getElementTop.returns(element._headerTopInitial - distance);
- element._updateDebounced();
- element.flushDebouncer('scroll');
- };
-
- setup(() => {
- element._headerTopInitial = 10;
- sinon.stub(element, '_getElementTop')
- .returns(element._headerTopInitial);
- });
-
- test('scrolls header along with document', () => {
- emulateScrollY(20);
- // No top property is set when !_headerFloating.
- assert.equal(getHeaderTop(), '');
- });
-
- test('does not stick to the top by default', () => {
- emulateScrollY(150);
- // No top property is set when !_headerFloating.
- assert.equal(getHeaderTop(), '');
- });
-
- test('sticks to the top if enabled', () => {
- element.keepOnScroll = true;
- emulateScrollY(120);
- assert.equal(getHeaderTop(), '0px');
- });
-
- test('drops a shadow when fixed to the top', () => {
- element.keepOnScroll = true;
- emulateScrollY(5);
- assert.isFalse(element.$.header.classList.contains('fixedAtTop'));
- emulateScrollY(120);
- assert.isTrue(element.$.header.classList.contains('fixedAtTop'));
- });
- });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
deleted file mode 100644
index 039b95d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
+++ /dev/null
@@ -1,307 +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 '../gr-linked-text/gr-linked-text.js';
-import '../../../styles/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-formatted-text_html.js';
-
-// eslint-disable-next-line no-unused-vars
-const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
-const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
-
-/** @extends PolymerElement */
-class GrFormattedText extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-formatted-text'; }
-
- static get properties() {
- return {
- content: {
- type: String,
- observer: '_contentChanged',
- },
- config: Object,
- noTrailingMargin: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_contentOrConfigChanged(content, config)',
- ];
- }
-
- /** @override */
- ready() {
- super.ready();
- if (this.noTrailingMargin) {
- this.classList.add('noTrailingMargin');
- }
- }
-
- _contentChanged(content) {
- // In the case where the config may not be set (perhaps due to the
- // request for it still being in flight), set the content anyway to
- // prevent waiting on the config to display the text.
- if (this.config) { return; }
- this._contentOrConfigChanged(content);
- }
-
- /**
- * Given a source string, update the DOM inside #container.
- */
- _contentOrConfigChanged(content) {
- const container = dom(this.$.container);
-
- // Remove existing content.
- while (container.firstChild) {
- container.removeChild(container.firstChild);
- }
-
- // Add new content.
- for (const node of this._computeNodes(this._computeBlocks(content))) {
- container.appendChild(node);
- }
- }
-
- /**
- * Given a source string, parse into an array of block objects. Each block
- * has a `type` property which takes any of the following values.
- * * 'paragraph'
- * * 'quote' (Block quote.)
- * * 'pre' (Pre-formatted text.)
- * * 'list' (Unordered list.)
- * * 'code' (code blocks.)
- *
- * For blocks of type 'paragraph', 'pre' and 'code' there is a `text`
- * property that maps to a string of the block's content.
- *
- * For blocks of type 'list', there is an `items` property that maps to a
- * list of strings representing the list items.
- *
- * For blocks of type 'quote', there is a `blocks` property that maps to a
- * list of blocks contained in the quote.
- *
- * NOTE: Strings appearing in all block objects are NOT escaped.
- *
- * @param {string} content
- * @return {!Array<!Object>}
- */
- _computeBlocks(content) {
- if (!content) { return []; }
-
- const result = [];
- const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
-
- for (let i = 0; i < lines.length; i++) {
- if (!lines[i].length) {
- continue;
- }
-
- if (this._isCodeMarkLine(lines[i])) {
- // handle multi-line code
- let nextI = i+1;
- while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) {
- nextI++;
- }
-
- if (this._isCodeMarkLine(lines[nextI])) {
- result.push({
- type: 'code',
- text: lines.slice(i+1, nextI).join('\n'),
- });
- i = nextI;
- continue;
- }
-
- // otherwise treat it as regular line and continue
- // check for other cases
- }
-
- if (this._isSingleLineCode(lines[i])) {
- // no guard check as _isSingleLineCode tested on the pattern
- const codeContent = lines[i].match(CODE_MARKER_PATTERN)[2];
- result.push({type: 'code', text: codeContent});
- } else if (this._isList(lines[i])) {
- let nextI = i + 1;
- while (this._isList(lines[nextI])) {
- nextI++;
- }
- result.push(this._makeList(lines.slice(i, nextI)));
- i = nextI - 1;
- } else if (this._isQuote(lines[i])) {
- let nextI = i + 1;
- while (this._isQuote(lines[nextI])) {
- nextI++;
- }
- const blockLines = lines.slice(i, nextI)
- .map(l => l.replace(/^[ ]?>[ ]?/, ''));
- result.push({
- type: 'quote',
- blocks: this._computeBlocks(blockLines.join('\n')),
- });
- i = nextI - 1;
- } else if (this._isPreFormat(lines[i])) {
- let nextI = i + 1;
- // include pre or all regular lines but stop at next new line
- while (this._isPreFormat(lines[nextI])
- || (this._isRegularLine(lines[nextI]) && lines[nextI].length)) {
- nextI++;
- }
- result.push({
- type: 'pre',
- text: lines.slice(i, nextI).join('\n'),
- });
- i = nextI - 1;
- } else {
- let nextI = i + 1;
- while (this._isRegularLine(lines[nextI])) {
- nextI++;
- }
- result.push({
- type: 'paragraph',
- text: lines.slice(i, nextI).join('\n'),
- });
- i = nextI - 1;
- }
- }
-
- return result;
- }
-
- /**
- * Take a block of comment text that contains a list, generate appropriate
- * block objects and append them to the output list.
- *
- * * Item one.
- * * Item two.
- * * item three.
- *
- * TODO(taoalpha): maybe we should also support nested list
- *
- * @param {!Array<string>} lines The block containing the list.
- */
- _makeList(lines) {
- const block = {type: 'list', items: []};
- let line;
-
- for (let i = 0; i < lines.length; i++) {
- line = lines[i];
- line = line.substring(1).trim();
- block.items.push(line);
- }
- return block;
- }
-
- _isRegularLine(line) {
- // line can not be recognized by existing patterns
- if (line === undefined) return false;
- return !this._isQuote(line) && !this._isCodeMarkLine(line)
- && !this._isSingleLineCode(line) && !this._isList(line) &&
- !this._isPreFormat(line);
- }
-
- _isQuote(line) {
- return line && (line.startsWith('> ') || line.startsWith(' > '));
- }
-
- _isCodeMarkLine(line) {
- return line && line.trim() === '```';
- }
-
- _isSingleLineCode(line) {
- return line && CODE_MARKER_PATTERN.test(line);
- }
-
- _isPreFormat(line) {
- return line && /^[ \t]/.test(line);
- }
-
- _isList(line) {
- return line && /^[-*] /.test(line);
- }
-
- /**
- * @param {string} content
- * @param {boolean=} opt_isPre
- */
- _makeLinkedText(content, opt_isPre) {
- const text = document.createElement('gr-linked-text');
- text.config = this.config;
- text.content = content;
- text.pre = true;
- if (opt_isPre) {
- text.classList.add('pre');
- }
- return text;
- }
-
- /**
- * Map an array of block objects to an array of DOM nodes.
- *
- * @param {!Array<!Object>} blocks
- * @return {!Array<!HTMLElement>}
- */
- _computeNodes(blocks) {
- return blocks.map(block => {
- if (block.type === 'paragraph') {
- const p = document.createElement('p');
- p.appendChild(this._makeLinkedText(block.text));
- return p;
- }
-
- if (block.type === 'quote') {
- const bq = document.createElement('blockquote');
- for (const node of this._computeNodes(block.blocks)) {
- bq.appendChild(node);
- }
- return bq;
- }
-
- if (block.type === 'code') {
- const code = document.createElement('code');
- code.textContent = block.text;
- return code;
- }
-
- if (block.type === 'pre') {
- return this._makeLinkedText(block.text, true);
- }
-
- if (block.type === 'list') {
- const ul = document.createElement('ul');
- for (const item of block.items) {
- const li = document.createElement('li');
- li.appendChild(this._makeLinkedText(item));
- ul.appendChild(li);
- }
- return ul;
- }
- });
- }
-}
-
-customElements.define(GrFormattedText.is, GrFormattedText);
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
new file mode 100644
index 0000000..a6c7b92
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -0,0 +1,315 @@
+/**
+ * @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 '../gr-linked-text/gr-linked-text';
+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 {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-formatted-text_html';
+import {CommentLinks} from '../../../types/common';
+
+const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
+
+interface Block {
+ type: string;
+ text?: string;
+ blocks?: Block[];
+ items?: string[];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-formatted-text': GrFormattedText;
+ }
+}
+
+export interface GrFormattedText {
+ $: {
+ container: HTMLElement;
+ };
+}
+
+@customElement('gr-formatted-text')
+export class GrFormattedText extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, observer: '_contentChanged'})
+ content?: string;
+
+ @property({type: Object})
+ config?: CommentLinks;
+
+ @property({type: Boolean})
+ noTrailingMargin = false;
+
+ static get observers() {
+ return ['_contentOrConfigChanged(content, config)'];
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ if (this.noTrailingMargin) {
+ this.classList.add('noTrailingMargin');
+ }
+ }
+
+ _contentChanged(content: string) {
+ // In the case where the config may not be set (perhaps due to the
+ // request for it still being in flight), set the content anyway to
+ // prevent waiting on the config to display the text.
+ if (this.config) return;
+ this._contentOrConfigChanged(content);
+ }
+
+ /**
+ * Given a source string, update the DOM inside #container.
+ */
+ _contentOrConfigChanged(content?: string) {
+ const container = this.$.container;
+
+ // Remove existing content.
+ while (container.firstChild) {
+ container.removeChild(container.firstChild);
+ }
+
+ // Add new content.
+ for (const node of this._computeNodes(this._computeBlocks(content))) {
+ if (node) container.appendChild(node);
+ }
+ }
+
+ /**
+ * Given a source string, parse into an array of block objects. Each block
+ * has a `type` property which takes any of the following values.
+ * * 'paragraph'
+ * * 'quote' (Block quote.)
+ * * 'pre' (Pre-formatted text.)
+ * * 'list' (Unordered list.)
+ * * 'code' (code blocks.)
+ *
+ * For blocks of type 'paragraph', 'pre' and 'code' there is a `text`
+ * property that maps to a string of the block's content.
+ *
+ * For blocks of type 'list', there is an `items` property that maps to a
+ * list of strings representing the list items.
+ *
+ * For blocks of type 'quote', there is a `blocks` property that maps to a
+ * list of blocks contained in the quote.
+ *
+ * NOTE: Strings appearing in all block objects are NOT escaped.
+ */
+ _computeBlocks(content?: string): Block[] {
+ if (!content) return [];
+
+ const result = [];
+ const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
+
+ for (let i = 0; i < lines.length; i++) {
+ if (!lines[i].length) {
+ continue;
+ }
+
+ if (this._isCodeMarkLine(lines[i])) {
+ // handle multi-line code
+ let nextI = i + 1;
+ while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) {
+ nextI++;
+ }
+
+ if (this._isCodeMarkLine(lines[nextI])) {
+ result.push({
+ type: 'code',
+ text: lines.slice(i + 1, nextI).join('\n'),
+ });
+ i = nextI;
+ continue;
+ }
+
+ // otherwise treat it as regular line and continue
+ // check for other cases
+ }
+
+ if (this._isSingleLineCode(lines[i])) {
+ // no guard check as _isSingleLineCode tested on the pattern
+ const codeContent = lines[i].match(CODE_MARKER_PATTERN)![2];
+ result.push({type: 'code', text: codeContent});
+ } else if (this._isList(lines[i])) {
+ let nextI = i + 1;
+ while (this._isList(lines[nextI])) {
+ nextI++;
+ }
+ result.push(this._makeList(lines.slice(i, nextI)));
+ i = nextI - 1;
+ } else if (this._isQuote(lines[i])) {
+ let nextI = i + 1;
+ while (this._isQuote(lines[nextI])) {
+ nextI++;
+ }
+ const blockLines = lines
+ .slice(i, nextI)
+ .map(l => l.replace(/^[ ]?>[ ]?/, ''));
+ result.push({
+ type: 'quote',
+ blocks: this._computeBlocks(blockLines.join('\n')),
+ });
+ i = nextI - 1;
+ } else if (this._isPreFormat(lines[i])) {
+ let nextI = i + 1;
+ // include pre or all regular lines but stop at next new line
+ while (
+ this._isPreFormat(lines[nextI]) ||
+ (this._isRegularLine(lines[nextI]) && lines[nextI].length)
+ ) {
+ nextI++;
+ }
+ result.push({
+ type: 'pre',
+ text: lines.slice(i, nextI).join('\n'),
+ });
+ i = nextI - 1;
+ } else {
+ let nextI = i + 1;
+ while (this._isRegularLine(lines[nextI])) {
+ nextI++;
+ }
+ result.push({
+ type: 'paragraph',
+ text: lines.slice(i, nextI).join('\n'),
+ });
+ i = nextI - 1;
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Take a block of comment text that contains a list, generate appropriate
+ * block objects and append them to the output list.
+ *
+ * * Item one.
+ * * Item two.
+ * * item three.
+ *
+ * TODO(taoalpha): maybe we should also support nested list
+ *
+ * @param lines The block containing the list.
+ */
+ _makeList(lines: string[]) {
+ const items = [];
+ for (let i = 0; i < lines.length; i++) {
+ let line = lines[i];
+ line = line.substring(1).trim();
+ items.push(line);
+ }
+ return {type: 'list', items};
+ }
+
+ _isRegularLine(line: string) {
+ // line can not be recognized by existing patterns
+ if (line === undefined) return false;
+ return (
+ !this._isQuote(line) &&
+ !this._isCodeMarkLine(line) &&
+ !this._isSingleLineCode(line) &&
+ !this._isList(line) &&
+ !this._isPreFormat(line)
+ );
+ }
+
+ _isQuote(line: string) {
+ return line && (line.startsWith('> ') || line.startsWith(' > '));
+ }
+
+ _isCodeMarkLine(line: string) {
+ return line && line.trim() === '```';
+ }
+
+ _isSingleLineCode(line: string) {
+ return line && CODE_MARKER_PATTERN.test(line);
+ }
+
+ _isPreFormat(line: string) {
+ return line && /^[ \t]/.test(line);
+ }
+
+ _isList(line: string) {
+ return line && /^[-*] /.test(line);
+ }
+
+ _makeLinkedText(content = '', isPre?: boolean) {
+ const text = document.createElement('gr-linked-text');
+ text.config = this.config;
+ text.content = content;
+ text.pre = true;
+ if (isPre) {
+ text.classList.add('pre');
+ }
+ return text;
+ }
+
+ /**
+ * Map an array of block objects to an array of DOM nodes.
+ */
+ _computeNodes(blocks: Block[]): HTMLElement[] {
+ return blocks.map(block => {
+ if (block.type === 'paragraph') {
+ const p = document.createElement('p');
+ p.appendChild(this._makeLinkedText(block.text));
+ return p;
+ }
+
+ if (block.type === 'quote') {
+ const bq = document.createElement('blockquote');
+ for (const node of this._computeNodes(block.blocks || [])) {
+ if (node) bq.appendChild(node);
+ }
+ return bq;
+ }
+
+ if (block.type === 'code') {
+ const code = document.createElement('code');
+ code.textContent = block.text || '';
+ return code;
+ }
+
+ if (block.type === 'pre') {
+ return this._makeLinkedText(block.text, true);
+ }
+
+ if (block.type === 'list') {
+ const ul = document.createElement('ul');
+ const items = block.items || [];
+ for (const item of items) {
+ const li = document.createElement('li');
+ li.appendChild(this._makeLinkedText(item));
+ ul.appendChild(li);
+ }
+ return ul;
+ }
+
+ console.warn('Unrecognized type.');
+ return document.createElement('span');
+ });
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
index 468bbee..bc1dfe0 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
@@ -55,6 +55,7 @@
list-style-type: disc;
margin-left: var(--spacing-xl);
}
+ code,
gr-linked-text.pre {
font-family: var(--monospace-font-family);
font-size: var(--font-size-code);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
deleted file mode 100644
index 717a28e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.js
+++ /dev/null
@@ -1,177 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import '@polymer/iron-icon/iron-icon.js';
-import '../../../styles/shared-styles.js';
-import '../gr-avatar/gr-avatar.js';
-import '../gr-button/gr-button.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.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-hovercard-account_html.js';
-import {appContext} from '../../../services/app-context.js';
-
-/** @extends PolymerElement */
-class GrHovercardAccount extends GestureEventListeners(
- hovercardBehaviorMixin(LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-hovercard-account'; }
-
- static get properties() {
- return {
- /**
- * This is an AccountInfo response object.
- */
- account: Object,
- _selfAccount: Object,
- /**
- * Optional ChangeInfo object, typically comes from the change page or
- * from a row in a list of search results. This is needed for some change
- * related features like adding the user as a reviewer.
- */
- change: Object,
- /**
- * Explains which labels the user can vote on and which score they can
- * give.
- */
- voteableText: String,
- /**
- * Should attention set related features be shown in the component? Note
- * that the information whether the user is in the attention set or not is
- * part of the ChangeInfo object in the change property.
- */
- highlightAttention: {
- type: Boolean,
- value: false,
- },
- /**
- * This is a ServerInfo response object.
- */
- _config: {
- type: Object,
- value: null,
- },
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- attached() {
- super.attached();
- this.$.restAPI.getConfig().then(config => {
- this._config = config;
- });
- this.$.restAPI.getAccount().then(account => {
- this._selfAccount = account;
- });
- }
-
- _computeText(account, selfAccount) {
- if (!account || !selfAccount) return '';
- return account._account_id === selfAccount._account_id ? 'Your' : 'Their';
- }
-
- get isAttentionSetEnabled() {
- return !!this._config && !!this._config.change
- && !!this._config.change.enable_attention_set
- && !!this.highlightAttention && !!this.change && !!this.account;
- }
-
- get hasAttention() {
- if (!this.isAttentionSetEnabled || !this.change.attention_set) return false;
- return this.change.attention_set.hasOwnProperty(this.account._account_id);
- }
-
- _computeShowLabelNeedsAttention(config, highlightAttention, account, change) {
- return this.isAttentionSetEnabled && this.hasAttention;
- }
-
- _computeShowActionAddToAttentionSet(config, highlightAttn, account, change) {
- return this.isAttentionSetEnabled && !this.hasAttention;
- }
-
- _computeShowActionRemoveFromAttentionSet(config, highlightAttention, account,
- change) {
- return this.isAttentionSetEnabled && this.hasAttention;
- }
-
- _handleClickAddToAttentionSet(e) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Adding user to attention set. Will be reloading ...',
- dismissOnNavigation: true,
- },
- composed: true,
- bubbles: true,
- }));
- this.reporting.reportInteraction('attention-hovercard-add',
- this._reportingDetails());
- this.$.restAPI.addToAttentionSet(this.change._number,
- this.account._account_id, 'manually added').then(obj => {
- GerritNav.navigateToChange(this.change);
- });
- this.hide();
- }
-
- _handleClickRemoveFromAttentionSet(e) {
- this.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: 'Removing user from attention set. Will be reloading ...',
- dismissOnNavigation: true,
- },
- composed: true,
- bubbles: true,
- }));
- this.reporting.reportInteraction('attention-hovercard-remove',
- this._reportingDetails());
- this.$.restAPI.removeFromAttentionSet(this.change._number,
- this.account._account_id, 'manually removed').then(obj => {
- GerritNav.navigateToChange(this.change);
- });
- this.hide();
- }
-
- _reportingDetails() {
- const targetId = this.account._account_id;
- const ownerId = (this.change && this.change.owner
- && this.change.owner._account_id) || -1;
- const selfId = (this._selfAccount && this._selfAccount._account_id) || -1;
- const reviewers = (
- this.change && this.change.reviewers && this.change.reviewers.REVIEWER ?
- [...this.change.reviewers.REVIEWER] : []);
- const reviewerIds = reviewers
- .map(r => r._account_id)
- .filter(rId => rId !== ownerId);
- return {
- actionByOwner: selfId === ownerId,
- actionByReviewer: reviewerIds.includes(selfId),
- targetIsOwner: targetId === ownerId,
- targetIsReviewer: reviewerIds.includes(targetId),
- targetIsSelf: targetId === selfId,
- };
- }
-}
-
-customElements.define(GrHovercardAccount.is, GrHovercardAccount);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
new file mode 100644
index 0000000..da2881e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account.ts
@@ -0,0 +1,324 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '@polymer/iron-icon/iron-icon';
+import '../../../styles/shared-styles';
+import '../gr-avatar/gr-avatar';
+import '../gr-button/gr-button';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior';
+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-hovercard-account_html';
+import {appContext} from '../../../services/app-context';
+import {accountKey} from '../../../utils/account-util';
+import {getDisplayName} from '../../../utils/display-name-util';
+import {customElement, property} from '@polymer/decorators';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ AccountInfo,
+ ChangeInfo,
+ ServerInfo,
+ ReviewInput,
+} from '../../../types/common';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {
+ canHaveAttention,
+ getLastUpdate,
+ getReason,
+ hasAttention,
+ isAttentionSetEnabled,
+} from '../../../utils/attention-set-util';
+import {ReviewerState} from '../../../constants/constants';
+import {isRemovableReviewer} from '../../../utils/change-util';
+
+export interface GrHovercardAccount {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-hovercard-account')
+export class GrHovercardAccount extends GestureEventListeners(
+ hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ account!: AccountInfo;
+
+ @property({type: Object})
+ _selfAccount?: AccountInfo;
+
+ /**
+ * Optional ChangeInfo object, typically comes from the change page or
+ * from a row in a list of search results. This is needed for some change
+ * related features like adding the user as a reviewer.
+ */
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ /**
+ * Explains which labels the user can vote on and which score they can
+ * give.
+ */
+ @property({type: String})
+ voteableText?: string;
+
+ /**
+ * Should attention set related features be shown in the component? Note
+ * that the information whether the user is in the attention set or not is
+ * part of the ChangeInfo object in the change property.
+ */
+ @property({type: Boolean})
+ highlightAttention = false;
+
+ @property({type: Object})
+ _config?: ServerInfo;
+
+ reporting: ReportingService;
+
+ constructor() {
+ super();
+ this.reporting = appContext.reportingService;
+ }
+
+ attached() {
+ super.attached();
+ this.$.restAPI.getConfig().then(config => {
+ this._config = config;
+ });
+ this.$.restAPI.getAccount().then(account => {
+ this._selfAccount = account;
+ });
+ }
+
+ _computeText(account?: AccountInfo, selfAccount?: AccountInfo) {
+ if (!account || !selfAccount) return '';
+ return account._account_id === selfAccount._account_id ? 'Your' : 'Their';
+ }
+
+ get isAttentionEnabled() {
+ return (
+ isAttentionSetEnabled(this._config) &&
+ !!this.highlightAttention &&
+ !!this.change &&
+ canHaveAttention(this.account)
+ );
+ }
+
+ get hasUserAttention() {
+ return hasAttention(this._config, this.account, this.change);
+ }
+
+ _computeReason(change?: ChangeInfo) {
+ return getReason(this.account, change);
+ }
+
+ _computeLastUpdate(change?: ChangeInfo) {
+ return getLastUpdate(this.account, change);
+ }
+
+ _showReviewerOrCCActions(account?: AccountInfo, change?: ChangeInfo) {
+ return !!this._selfAccount && isRemovableReviewer(change, account);
+ }
+
+ _getReviewerState(account: AccountInfo, change: ChangeInfo) {
+ if (
+ change.reviewers[ReviewerState.REVIEWER]?.some(
+ (reviewer: AccountInfo) => {
+ return reviewer._account_id === account._account_id;
+ }
+ )
+ ) {
+ return ReviewerState.REVIEWER;
+ }
+ return ReviewerState.CC;
+ }
+
+ _computeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
+ if (!change || !account) return '';
+ return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+ ? 'Reviewer'
+ : 'CC';
+ }
+
+ _computeChangeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
+ if (!change || !account) return '';
+ return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+ ? 'Move Reviewer to CC'
+ : 'Move CC to Reviewer';
+ }
+
+ _handleChangeReviewerOrCCStatus() {
+ if (!this.change) throw new Error('expected change object to be present');
+ // accountKey() throws an error if _account_id & email is not found, which
+ // we want to check before showing reloading toast
+ const _accountKey = accountKey(this.account);
+ this.dispatchEventThroughTarget('show-alert', {
+ message: 'Reloading page...',
+ });
+ const reviewInput: Partial<ReviewInput> = {};
+ reviewInput.reviewers = [
+ {
+ reviewer: _accountKey,
+ state:
+ this._getReviewerState(this.account, this.change) === ReviewerState.CC
+ ? ReviewerState.REVIEWER
+ : ReviewerState.CC,
+ },
+ ];
+
+ this.$.restAPI
+ .saveChangeReview(this.change._number, 'current', reviewInput)
+ .then(response => {
+ if (!response || !response.ok) {
+ throw new Error(
+ 'something went wrong when toggling' +
+ this._getReviewerState(this.account, this.change!)
+ );
+ }
+ this.dispatchEventThroughTarget('reload', {clearPatchset: true});
+ });
+ }
+
+ _handleRemoveReviewerOrCC() {
+ if (!this.change || !(this.account?._account_id || this.account?.email))
+ throw new Error('Missing change or account.');
+ this.dispatchEventThroughTarget('show-alert', {
+ message: 'Reloading page...',
+ });
+ this.$.restAPI
+ .removeChangeReviewer(
+ this.change._number,
+ (this.account?._account_id || this.account?.email)!
+ )
+ .then((response: Response | undefined) => {
+ if (!response || !response.ok) {
+ throw new Error('something went wrong when removing user');
+ }
+ this.dispatchEventThroughTarget('reload', {clearPatchset: true});
+ return response;
+ });
+ }
+
+ _computeShowLabelNeedsAttention() {
+ return this.isAttentionEnabled && this.hasUserAttention;
+ }
+
+ _computeShowActionAddToAttentionSet() {
+ return (
+ this._selfAccount && this.isAttentionEnabled && !this.hasUserAttention
+ );
+ }
+
+ _computeShowActionRemoveFromAttentionSet() {
+ return (
+ this._selfAccount && this.isAttentionEnabled && this.hasUserAttention
+ );
+ }
+
+ _handleClickAddToAttentionSet() {
+ if (!this.change || !this.account._account_id) return;
+ this.dispatchEventThroughTarget('show-alert', {
+ message: 'Saving attention set update ...',
+ dismissOnNavigation: true,
+ });
+
+ // We are deliberately updating the UI before making the API call. It is a
+ // risk that we are taking to achieve a better UX for 99.9% of the cases.
+ const selfName = getDisplayName(this._config, this._selfAccount);
+ const reason = `Added by ${selfName} using the hovercard menu`;
+ if (!this.change.attention_set) this.change.attention_set = {};
+ this.change.attention_set[this.account._account_id] = {
+ account: this.account,
+ reason,
+ };
+ this.dispatchEventThroughTarget('attention-set-updated');
+
+ this.reporting.reportInteraction(
+ 'attention-hovercard-add',
+ this._reportingDetails()
+ );
+ this.$.restAPI
+ .addToAttentionSet(this.change._number, this.account._account_id, reason)
+ .then(() => {
+ this.dispatchEventThroughTarget('hide-alert');
+ });
+ this.hide();
+ }
+
+ _handleClickRemoveFromAttentionSet() {
+ if (!this.change || !this.account._account_id) return;
+ this.dispatchEventThroughTarget('show-alert', {
+ message: 'Saving attention set update ...',
+ dismissOnNavigation: true,
+ });
+
+ // We are deliberately updating the UI before making the API call. It is a
+ // risk that we are taking to achieve a better UX for 99.9% of the cases.
+ const selfName = getDisplayName(this._config, this._selfAccount);
+ const reason = `Removed by ${selfName} using the hovercard menu`;
+ if (this.change.attention_set)
+ delete this.change.attention_set[this.account._account_id];
+ this.dispatchEventThroughTarget('attention-set-updated');
+
+ this.reporting.reportInteraction(
+ 'attention-hovercard-remove',
+ this._reportingDetails()
+ );
+ this.$.restAPI
+ .removeFromAttentionSet(
+ this.change._number,
+ this.account._account_id,
+ reason
+ )
+ .then(() => {
+ this.dispatchEventThroughTarget('hide-alert');
+ });
+ this.hide();
+ }
+
+ _reportingDetails() {
+ const targetId = this.account._account_id;
+ const ownerId =
+ (this.change && this.change.owner && this.change.owner._account_id) || -1;
+ const selfId = (this._selfAccount && this._selfAccount._account_id) || -1;
+ const reviewers =
+ this.change && this.change.reviewers && this.change.reviewers.REVIEWER
+ ? [...this.change.reviewers.REVIEWER]
+ : [];
+ const reviewerIds = reviewers
+ .map(r => r._account_id)
+ .filter(rId => rId !== ownerId);
+ return {
+ actionByOwner: selfId === ownerId,
+ actionByReviewer: selfId !== -1 && reviewerIds.includes(selfId),
+ targetIsOwner: targetId === ownerId,
+ targetIsReviewer: reviewerIds.includes(targetId),
+ targetIsSelf: targetId === selfId,
+ };
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-hovercard-account': GrHovercardAccount;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
index 18138c3..1d437fb 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
@@ -39,13 +39,6 @@
.email {
color: var(--deemphasized-text-color);
}
- .status iron-icon {
- width: 14px;
- height: 14px;
- vertical-align: top;
- position: relative;
- top: 2px;
- }
.action {
border-top: 1px solid var(--border-color);
padding: var(--spacing-s) var(--spacing-l);
@@ -56,13 +49,27 @@
.attention {
background-color: var(--emphasis-color);
}
- .attention iron-icon {
+ .attention a {
+ text-decoration: none;
+ }
+ iron-icon {
+ vertical-align: top;
+ }
+ .status iron-icon {
width: 14px;
height: 14px;
- vertical-align: top;
+ position: relative;
+ top: 2px;
+ }
+ iron-icon.attentionIcon {
+ width: 14px;
+ height: 14px;
position: relative;
top: 3px;
}
+ .reason {
+ padding-top: var(--spacing-s);
+ }
</style>
<div id="container" role="tooltip" tabindex="-1">
<template is="dom-if" if="[[_isShowing]]">
@@ -95,10 +102,44 @@
if="[[_computeShowLabelNeedsAttention(_config, highlightAttention, account, change)]]"
>
<div class="attention">
- <iron-icon icon="gr-icons:attention"></iron-icon>
- <span>
- [[_computeText(account, _selfAccount)]] turn to take action.
- </span>
+ <div>
+ <iron-icon
+ class="attentionIcon"
+ icon="gr-icons:attention"
+ ></iron-icon>
+ <span>
+ [[_computeText(account, _selfAccount)]] turn to take action.
+ </span>
+ <a
+ href="https://bugs.chromium.org/p/gerrit/issues/entry?template=Attention+Set"
+ target="_blank"
+ >
+ <iron-icon
+ icon="gr-icons:bug"
+ title="report a problem"
+ ></iron-icon>
+ </a>
+ <a
+ href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+ target="_blank"
+ >
+ <iron-icon
+ icon="gr-icons:help-outline"
+ title="read documentation"
+ ></iron-icon>
+ </a>
+ </div>
+ <div class="reason">
+ <span class="title">Reason:</span>
+ <span class="value">[[_computeReason(change)]]</span>
+ <template is="dom-if" if="[[_computeLastUpdate(change)]]">
+ (<gr-date-formatter
+ has-tooltip
+ date-str="[[_computeLastUpdate(change)]]"
+ ></gr-date-formatter
+ >)
+ </template>
+ </div>
</div>
</template>
<template
@@ -107,6 +148,7 @@
>
<div class="action">
<gr-button
+ class="addToAttentionSet"
link=""
no-uppercase=""
on-click="_handleClickAddToAttentionSet"
@@ -121,6 +163,7 @@
>
<div class="action">
<gr-button
+ class="removeFromAttentionSet"
link=""
no-uppercase=""
on-click="_handleClickRemoveFromAttentionSet"
@@ -129,6 +172,28 @@
</gr-button>
</div>
</template>
+ <template is="dom-if" if="[[_showReviewerOrCCActions(account, change)]]">
+ <div class="action">
+ <gr-button
+ class="removeReviewerOrCC"
+ link=""
+ no-uppercase=""
+ on-click="_handleRemoveReviewerOrCC"
+ >
+ Remove [[_computeReviewerOrCCText(account, change)]]
+ </gr-button>
+ </div>
+ <div class="action">
+ <gr-button
+ class="changeReviewerOrCC"
+ link=""
+ no-uppercase=""
+ on-click="_handleChangeReviewerOrCCStatus"
+ >
+ [[_computeChangeReviewerOrCCText(account, change)]]
+ </gr-button>
+ </div>
+ </template>
</template>
</div>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
index ea7eb87..b09f0ce 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_test.js
@@ -18,6 +18,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-hovercard-account.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import {ReviewerState} from '../../../constants/constants.js';
const basicFixture = fixtureFromTemplate(html`
<gr-hovercard-account class="hovered"></gr-hovercard-account>
@@ -39,9 +40,16 @@
new Promise(resolve => { '2'; })
);
- element.account = Object.assign({}, ACCOUNT);
+ element._selfAccount = {...ACCOUNT};
+ element.account = {...ACCOUNT};
+ element._config = {
+ change: {enable_attention_set: true},
+ };
+ element.change = {
+ attention_set: {},
+ };
element.show({});
- flushAsynchronousOperations();
+ flush();
});
teardown(() => {
@@ -53,6 +61,18 @@
'Kermit The Frog');
});
+ test('_computeLastUpdate', () => {
+ const last_update = '2019-07-17 19:39:02.000000000';
+ const change = {
+ attention_set: {
+ 31415926535: {
+ last_update,
+ },
+ },
+ };
+ assert.equal(element._computeLastUpdate(change), last_update);
+ });
+
test('_computeText', () => {
let account = {_account_id: '1'};
const selfAccount = {_account_id: '1'};
@@ -66,8 +86,8 @@
});
test('account status is displayed', () => {
- element.account = Object.assign({status: 'OOO'}, ACCOUNT);
- flushAsynchronousOperations();
+ element.account = {status: 'OOO', ...ACCOUNT};
+ flush();
assert.equal(element.shadowRoot.querySelector('.status .value').innerText,
'OOO');
});
@@ -78,9 +98,174 @@
test('voteable div is displayed', () => {
element.voteableText = 'CodeReview: +2';
- flushAsynchronousOperations();
+ flush();
assert.equal(element.shadowRoot.querySelector('.voteable .value').innerText,
element.voteableText);
});
+
+ test('remove reviewer', async () => {
+ element.change = {
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [ACCOUNT],
+ },
+ };
+ sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+ Promise.resolve({ok: true}));
+ const reloadListener = sinon.spy();
+ element._target.addEventListener('reload', reloadListener);
+ flush();
+ const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
+ assert.isOk(button);
+ assert.equal(button.innerText, 'Remove Reviewer');
+ MockInteractions.tap(button);
+ await flush();
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('move reviewer to cc', async () => {
+ element.change = {
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [ACCOUNT],
+ },
+ };
+ const saveReviewStub = sinon.stub(element.$.restAPI,
+ 'saveChangeReview').returns(
+ Promise.resolve({ok: true}));
+ sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+ Promise.resolve({ok: true}));
+ const reloadListener = sinon.spy();
+ element._target.addEventListener('reload', reloadListener);
+
+ flush();
+ const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
+
+ assert.isOk(button);
+ assert.equal(button.innerText, 'Move Reviewer to CC');
+ MockInteractions.tap(button);
+ await flush();
+
+ assert.isTrue(saveReviewStub.called);
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('move reviewer to cc', async () => {
+ element.change = {
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [],
+ },
+ };
+ const saveReviewStub = sinon.stub(element.$.restAPI,
+ 'saveChangeReview').returns(
+ Promise.resolve({ok: true}));
+ sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+ Promise.resolve({ok: true}));
+ const reloadListener = sinon.spy();
+ element._target.addEventListener('reload', reloadListener);
+ flush();
+
+ const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
+ assert.isOk(button);
+ assert.equal(button.innerText, 'Move CC to Reviewer');
+
+ MockInteractions.tap(button);
+ await flush();
+
+ assert.isTrue(saveReviewStub.called);
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('remove cc', async () => {
+ element.change = {
+ removable_reviewers: [ACCOUNT],
+ reviewers: {
+ [ReviewerState.REVIEWER]: [],
+ },
+ };
+ sinon.stub(element.$.restAPI, 'removeChangeReviewer').returns(
+ Promise.resolve({ok: true}));
+ const reloadListener = sinon.spy();
+ element._target.addEventListener('reload', reloadListener);
+
+ flush();
+ const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
+
+ assert.equal(button.innerText, 'Remove CC');
+ assert.isOk(button);
+ MockInteractions.tap(button);
+
+ await flush();
+
+ assert.isTrue(reloadListener.called);
+ });
+
+ test('add to attention set', async () => {
+ let apiResolve;
+ const apiPromise = new Promise(r => {
+ apiResolve = r;
+ });
+ sinon.stub(element.$.restAPI, 'addToAttentionSet')
+ .callsFake(() => apiPromise);
+ element.highlightAttention = true;
+ element._target = document.createElement('div');
+ flush();
+ const showAlertListener = sinon.spy();
+ const hideAlertListener = sinon.spy();
+ const updatedListener = sinon.spy();
+ element._target.addEventListener('show-alert', showAlertListener);
+ element._target.addEventListener('hide-alert', hideAlertListener);
+ element._target.addEventListener('attention-set-updated', updatedListener);
+
+ const button = element.shadowRoot.querySelector('.addToAttentionSet');
+ assert.isOk(button);
+ assert.isTrue(element._isShowing, 'hovercard is showing');
+ MockInteractions.tap(button);
+
+ assert.equal(Object.keys(element.change.attention_set).length, 1);
+ assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+ assert.isTrue(updatedListener.called, 'updatedListener was called');
+ assert.isFalse(element._isShowing, 'hovercard is hidden');
+
+ apiResolve({});
+ await flush();
+
+ assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+ });
+
+ test('remove from attention set', async () => {
+ let apiResolve;
+ const apiPromise = new Promise(r => {
+ apiResolve = r;
+ });
+ sinon.stub(element.$.restAPI, 'removeFromAttentionSet')
+ .callsFake(() => apiPromise);
+ element.highlightAttention = true;
+ element.change = {attention_set: {31415926535: {}}};
+ element._target = document.createElement('div');
+ flush();
+ const showAlertListener = sinon.spy();
+ const hideAlertListener = sinon.spy();
+ const updatedListener = sinon.spy();
+ element._target.addEventListener('show-alert', showAlertListener);
+ element._target.addEventListener('hide-alert', hideAlertListener);
+ element._target.addEventListener('attention-set-updated', updatedListener);
+
+ const button = element.shadowRoot.querySelector('.removeFromAttentionSet');
+ assert.isOk(button);
+ assert.isTrue(element._isShowing, 'hovercard is showing');
+ MockInteractions.tap(button);
+
+ assert.equal(Object.keys(element.change.attention_set).length, 0);
+ assert.isTrue(showAlertListener.called, 'showAlertListener was called');
+ assert.isTrue(updatedListener.called, 'updatedListener was called');
+ assert.isFalse(element._isShowing, 'hovercard is hidden');
+
+ apiResolve({});
+ await flush();
+
+ assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
+ });
});
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
deleted file mode 100644
index 0d351f6..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.js
+++ /dev/null
@@ -1,396 +0,0 @@
-/**
- * @license
- * 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.
- */
-import '../../../styles/shared-styles.js';
-import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {getRootElement} from '../../../scripts/rootElement.js';
-
-const HOVER_CLASS = 'hovered';
-const HIDE_CLASS = 'hide';
-
-/**
- * When the hovercard is positioned diagonally (bottom-left, bottom-right,
- * top-left, or top-right), we add additional (invisible) padding so that the
- * area that a user can hover over to access the hovercard is larger.
- */
-const DIAGONAL_OVERFLOW = 15;
-
-/**
- * How long should be wait before showing the hovercard when the user hovers
- * over the element?
- */
-const SHOW_DELAY_MS = 500;
-
-/**
- * The mixin for gr-hovercard-behavior.
- *
- * @example
- *
- * // LegacyElementMixin is still needed to support the old lifecycles
- * // TODO: Replace old life cycles with new ones.
- *
- * class YourComponent extends hovercardBehaviorMixin(
- * LegacyElementMixin(PolymerElement)
- * ) {
- * static get is() { return ''; }
- * static get template() { return html``; }
- * }
- *
- * customElements.define(GrHovercard.is, GrHovercard);
- *
- * @see gr-hovercard.js
- *
- * // following annotations are required for polylint
- * @polymer
- * @mixinFunction
- */
-export const hovercardBehaviorMixin = superClass => class extends superClass {
- static get properties() {
- return {
- /**
- * @type {?}
- */
- _target: Object,
-
- /**
- * Determines whether or not the hovercard is visible.
- *
- * @type {boolean}
- */
- _isShowing: {
- type: Boolean,
- value: false,
- },
- /**
- * The `id` of the element that the hovercard is anchored to.
- *
- * @type {string}
- */
- for: {
- type: String,
- observer: '_forChanged',
- },
-
- /**
- * The spacing between the top of the hovercard and the element it is
- * anchored to.
- *
- * @type {number}
- */
- offset: {
- type: Number,
- value: 14,
- },
-
- /**
- * Positions the hovercard to the top, right, bottom, left, bottom-left,
- * bottom-right, top-left, or top-right of its content.
- *
- * @type {string}
- */
- position: {
- type: String,
- value: 'right',
- },
-
- container: Object,
- /**
- * ID for the container element.
- *
- * @type {string}
- */
- containerId: {
- type: String,
- value: 'gr-hovercard-container',
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- if (!this._target) { this._target = this.target; }
- this.listen(this._target, 'mouseenter', 'showDelayed');
- this.listen(this._target, 'focus', 'showDelayed');
- this.listen(this._target, 'mouseleave', 'hide');
- this.listen(this._target, 'blur', 'hide');
- this.listen(this._target, 'click', 'hide');
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('mouseleave',
- e => this.hide(e));
- }
-
- /** @override */
- ready() {
- super.ready();
- // First, check to see if the container has already been created.
- this.container = getRootElement()
- .querySelector('#' + this.containerId);
-
- if (this.container) { return; }
-
- // If it does not exist, create and initialize the hovercard container.
- this.container = document.createElement('div');
- this.container.setAttribute('id', this.containerId);
- getRootElement().appendChild(this.container);
- }
-
- removeListeners() {
- this.unlisten(this._target, 'mouseenter', 'show');
- this.unlisten(this._target, 'focus', 'show');
- this.unlisten(this._target, 'mouseleave', 'hide');
- this.unlisten(this._target, 'blur', 'hide');
- this.unlisten(this._target, 'click', 'hide');
- }
-
- /**
- * Returns the target element that the hovercard is anchored to (the `id` of
- * the `for` property).
- *
- * @type {HTMLElement}
- */
- get target() {
- const parentNode = dom(this).parentNode;
- // If the parentNode is a document fragment, then we need to use the host.
- const ownerRoot = dom(this).getOwnerRoot();
- let target;
- if (this.for) {
- target = dom(ownerRoot).querySelector('#' + this.for);
- } else {
- target = parentNode.nodeType == Node.DOCUMENT_FRAGMENT_NODE ?
- ownerRoot.host :
- parentNode;
- }
- return target;
- }
-
- /**
- * 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).
- *
- * @param {Event} opt_e DOM Event (e.g. `mouseleave` event)
- */
- hide(opt_e) {
- this._isScheduledToShow = false;
- if (!this._isShowing) {
- return;
- }
-
- // If the user is now hovering over the hovercard or the user is returning
- // from the hovercard but now hovering over the target (to stop an annoying
- // flicker effect), just return.
- if (opt_e) {
- if (opt_e.toElement === this ||
- (opt_e.fromElement === this && opt_e.toElement === this._target)) {
- return;
- }
- }
-
- // Mark that the hovercard is not visible and do not allow focusing
- this._isShowing = false;
-
- // Clear styles in preparation for the next time we need to show the card
- this.classList.remove(HOVER_CLASS);
-
- // Reset and remove the hovercard from the DOM
- this.style.cssText = '';
- this.$.container.setAttribute('tabindex', -1);
-
- // Remove the hovercard from the container, given that it is still a child
- // of the container.
- if (this.container.contains(this)) {
- this.container.removeChild(this);
- }
- }
-
- /**
- * Shows/opens the hovercard with a fixed delay.
- */
- showDelayed() {
- this.showDelayedBy(SHOW_DELAY_MS);
- }
-
- /**
- * Shows/opens the hovercard with the given delay.
- */
- showDelayedBy(delayMs) {
- if (this._isShowing || this._isScheduledToShow) return;
- this._isScheduledToShow = true;
- setTimeout(() => {
- // This happens when the mouse leaves the target before the delay is over.
- if (!this._isScheduledToShow) return;
- this._isScheduledToShow = false;
- this.show();
- }, delayMs);
- }
-
- /**
- * Shows/opens the hovercard. This occurs when the user triggers the
- * `mousenter` event on the hovercard's `target` element.
- */
- show() {
- if (this._isShowing) {
- return;
- }
-
- // Mark that the hovercard is now visible
- this._isShowing = true;
- this.setAttribute('tabindex', 0);
-
- // Add it to the DOM and calculate its position
- this.container.appendChild(this);
- // We temporarily hide the hovercard until we have found the correct
- // position for it.
- this.classList.add(HIDE_CLASS);
- this.classList.add(HOVER_CLASS);
- // Make sure that the hovercard actually rendered and all dom-if
- // statements processed, so that we can measure the (invisible)
- // hovercard properly in updatePosition().
- flush();
- this.updatePosition();
- this.classList.remove(HIDE_CLASS);
- }
-
- updatePosition() {
- const positionsToTry = new Set(
- [this.position, 'right', 'bottom-right', 'top-right',
- 'bottom', 'top', 'bottom-left', 'top-left', 'left']);
- for (const position of positionsToTry) {
- this.updatePositionTo(position);
- if (this._isInsideViewport()) return;
- }
- console.warn('Could not find a visible position for the hovercard.');
- }
-
- _isInsideViewport() {
- const thisRect = this.getBoundingClientRect();
- if (thisRect.top < 0) return false;
- if (thisRect.left < 0) return false;
- const docuRect = document.documentElement.getBoundingClientRect();
- if (thisRect.bottom > docuRect.height) return false;
- if (thisRect.right > docuRect.width) return false;
- return true;
- }
-
- /**
- * Updates the hovercard's position based the current position of the `target`
- * element.
- *
- * The hovercard is supposed to stay open if the user hovers over it.
- * To keep it open when the user moves away from the target, the bounding
- * rects of the target and hovercard must touch or overlap.
- *
- * NOTE: You do not need to directly call this method unless you need to
- * update the position of the tooltip while it is already visible (the
- * target element has moved and the tooltip is still open).
- */
- updatePositionTo(position) {
- if (!this._target) { return; }
-
- // Make sure that thisRect will not get any paddings and such included
- // in the width and height of the bounding client rect.
- this.style.cssText = '';
-
- const docuRect = document.documentElement.getBoundingClientRect();
- const targetRect = this._target.getBoundingClientRect();
- const thisRect = this.getBoundingClientRect();
-
- const targetLeft = targetRect.left - docuRect.left;
- const targetTop = targetRect.top - docuRect.top;
-
- let hovercardLeft;
- let hovercardTop;
- const diagonalPadding = this.offset + DIAGONAL_OVERFLOW;
- let cssText = '';
-
- switch (position) {
- case 'top':
- hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
- hovercardTop = targetTop - thisRect.height - this.offset;
- cssText += `padding-bottom:${this.offset
- }px; margin-bottom:-${this.offset}px;`;
- break;
- case 'bottom':
- hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
- hovercardTop = targetTop + targetRect.height + this.offset;
- cssText +=
- `padding-top:${this.offset}px; margin-top:-${this.offset}px;`;
- break;
- case 'left':
- hovercardLeft = targetLeft - thisRect.width - this.offset;
- hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
- cssText +=
- `padding-right:${this.offset}px; margin-right:-${this.offset}px;`;
- break;
- case 'right':
- hovercardLeft = targetLeft + targetRect.width + this.offset;
- hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
- cssText +=
- `padding-left:${this.offset}px; margin-left:-${this.offset}px;`;
- break;
- case 'bottom-right':
- hovercardLeft = targetLeft + targetRect.width + this.offset;
- hovercardTop = targetTop + targetRect.height + this.offset;
- cssText += `padding-top:${diagonalPadding}px;`;
- cssText += `padding-left:${diagonalPadding}px;`;
- cssText += `margin-left:-${diagonalPadding}px;`;
- cssText += `margin-top:-${diagonalPadding}px;`;
- break;
- case 'bottom-left':
- hovercardLeft = targetLeft - thisRect.width - this.offset;
- hovercardTop = targetTop + targetRect.height + this.offset;
- cssText += `padding-top:${diagonalPadding}px;`;
- cssText += `padding-right:${diagonalPadding}px;`;
- cssText += `margin-right:-${diagonalPadding}px;`;
- cssText += `margin-top:-${diagonalPadding}px;`;
- break;
- case 'top-left':
- hovercardLeft = targetLeft - thisRect.width - this.offset;
- hovercardTop = targetTop - thisRect.height - this.offset;
- cssText += `padding-bottom:${diagonalPadding}px;`;
- cssText += `padding-right:${diagonalPadding}px;`;
- cssText += `margin-bottom:-${diagonalPadding}px;`;
- cssText += `margin-right:-${diagonalPadding}px;`;
- break;
- case 'top-right':
- hovercardLeft = targetLeft + targetRect.width + this.offset;
- hovercardTop = targetTop - thisRect.height - this.offset;
- cssText += `padding-bottom:${diagonalPadding}px;`;
- cssText += `padding-left:${diagonalPadding}px;`;
- cssText += `margin-bottom:-${diagonalPadding}px;`;
- cssText += `margin-left:-${diagonalPadding}px;`;
- break;
- }
-
- cssText += `left:${hovercardLeft}px; top:${hovercardTop}px;`;
- this.style.cssText = cssText;
- }
-
- /**
- * Responds to a change in the `for` value and gets the updated `target`
- * element for the hovercard.
- *
- * @private
- */
- _forChanged() {
- this._target = this.target;
- }
-};
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
new file mode 100644
index 0000000..78b6cda
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
@@ -0,0 +1,498 @@
+/**
+ * @license
+ * 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.
+ */
+import '../../../styles/shared-styles';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {Debouncer} from '@polymer/polymer/lib/utils/debounce';
+import {timeOut} from '@polymer/polymer/lib/utils/async';
+import {getRootElement} from '../../../scripts/rootElement';
+import {Constructor} from '../../../utils/common-util';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+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';
+
+interface ShowAlertEventDetail {
+ message: string;
+ dismissOnNavigation?: boolean;
+}
+
+interface ReloadEventDetail {
+ clearPatchset?: boolean;
+}
+
+const HOVER_CLASS = 'hovered';
+const HIDE_CLASS = 'hide';
+
+/**
+ * How long should we wait before showing the hovercard when the user hovers
+ * over the element?
+ */
+const SHOW_DELAY_MS = 550;
+
+/**
+ * How long should we wait before hiding the hovercard when the user moves from
+ * target to the hovercard.
+ *
+ * Note: this should be lower than SHOW_DELAY_MS to avoid flickering.
+ */
+const HIDE_DELAY_MS = 500;
+
+/**
+ * The mixin for gr-hovercard-behavior.
+ *
+ * @example
+ *
+ * // LegacyElementMixin is still needed to support the old lifecycles
+ * // TODO: Replace old life cycles with new ones.
+ *
+ * class YourComponent extends hovercardBehaviorMixin(
+ * LegacyElementMixin(PolymerElement)
+ *
+ * @see gr-hovercard.ts
+ *
+ * // following annotations are required for polylint
+ * @polymer
+ * @mixinFunction
+ */
+export const hovercardBehaviorMixin = dedupingMixin(
+ <T extends Constructor<PolymerElement & LegacyElementMixin>>(
+ superClass: T
+ ): T & Constructor<GrHovercardBehaviorInterface> => {
+ /**
+ * @polymer
+ * @mixinClass
+ */
+ class Mixin extends superClass {
+ @property({type: Object})
+ _target: Element | null = null;
+
+ // Determines whether or not the hovercard is visible.
+ @property({type: Boolean})
+ _isShowing = false;
+
+ // The `id` of the element that the hovercard is anchored to.
+ @property({type: String})
+ for?: string;
+
+ /**
+ * The spacing between the top of the hovercard and the element it is
+ * anchored to.
+ */
+ @property({type: Number})
+ offset = 14;
+
+ /**
+ * Positions the hovercard to the top, right, bottom, left, bottom-left,
+ * bottom-right, top-left, or top-right of its content.
+ */
+ @property({type: String})
+ position = 'right';
+
+ @property({type: Object})
+ container: HTMLElement | null = null;
+
+ /**
+ * ID for the container element.
+ */
+ @property({type: String})
+ containerId = 'gr-hovercard-container';
+
+ private _hideDebouncer: Debouncer | null = null;
+
+ private _showDebouncer: Debouncer | null = null;
+
+ private _isScheduledToShow?: boolean;
+
+ private _isScheduledToHide?: boolean;
+
+ /** @override */
+ attached() {
+ super.attached();
+ if (!this._target) {
+ this._target = this.target;
+ }
+ this.listen(this._target, 'mouseenter', 'debounceShow');
+ this.listen(this._target, 'focus', 'debounceShow');
+ this.listen(this._target, 'mouseleave', 'debounceHide');
+ this.listen(this._target, 'blur', 'debounceHide');
+
+ // when click, dismiss immediately
+ this.listen(this._target, 'click', 'hide');
+
+ // 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');
+ }
+
+ detached() {
+ super.detached();
+ this.unlock();
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ // First, check to see if the container has already been created.
+ this.container = getRootElement().querySelector('#' + this.containerId);
+
+ if (this.container) {
+ return;
+ }
+
+ // If it does not exist, create and initialize the hovercard container.
+ this.container = document.createElement('div');
+ this.container.setAttribute('id', this.containerId);
+ getRootElement().appendChild(this.container);
+ }
+
+ removeListeners() {
+ this.unlisten(this._target, 'mouseenter', 'debounceShow');
+ this.unlisten(this._target, 'focus', 'debounceShow');
+ this.unlisten(this._target, 'mouseleave', 'debounceHide');
+ this.unlisten(this._target, 'blur', 'debounceHide');
+ this.unlisten(this._target, 'click', 'hide');
+ }
+
+ debounceHide() {
+ this.cancelShowDebouncer();
+ if (!this._isShowing || this._isScheduledToHide) return;
+ this._isScheduledToHide = true;
+ this._hideDebouncer = Debouncer.debounce(
+ this._hideDebouncer,
+ timeOut.after(HIDE_DELAY_MS),
+ () => {
+ // This happens when hide immediately through click or mouse leave
+ // on the hovercard
+ if (!this._isScheduledToHide) return;
+ this.hide();
+ }
+ );
+ }
+
+ cancelHideDebouncer() {
+ if (this._hideDebouncer) {
+ this._hideDebouncer.cancel();
+ this._isScheduledToHide = false;
+ }
+ }
+
+ /**
+ * Hovercard elements are created outside of <gr-app>, so if you want to fire
+ * events, then you probably want to do that through the target element.
+ */
+
+ dispatchEventThroughTarget(eventName: string): void;
+
+ dispatchEventThroughTarget(
+ eventName: 'show-alert',
+ detail: ShowAlertEventDetail
+ ): void;
+
+ dispatchEventThroughTarget(
+ eventName: 'reload',
+ detail: ReloadEventDetail
+ ): void;
+
+ dispatchEventThroughTarget(eventName: string, detail?: unknown) {
+ if (!detail) detail = {};
+ if (this._target)
+ this._target.dispatchEvent(
+ new CustomEvent(eventName, {
+ detail,
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ /**
+ * Returns the target element that the hovercard is anchored to (the `id` of
+ * the `for` property).
+ */
+ get target(): Element {
+ const parentNode = this.parentNode;
+ // If the parentNode is a document fragment, then we need to use the host.
+ const ownerRoot = this.getRootNode() as ShadowRoot;
+ let target;
+ if (this.for) {
+ target = ownerRoot.querySelector('#' + this.for);
+ } else {
+ target =
+ !parentNode || parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
+ ? ownerRoot.host
+ : parentNode;
+ }
+ return target as Element;
+ }
+
+ /**
+ * 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).
+ *
+ */
+ hide(e?: MouseEvent) {
+ this.cancelHideDebouncer();
+ this.cancelShowDebouncer();
+ if (!this._isShowing) {
+ return;
+ }
+
+ // If the user is now hovering over the hovercard or the user is returning
+ // from the hovercard but now hovering over the target (to stop an annoying
+ // flicker effect), just return.
+ if (e) {
+ if (
+ e.relatedTarget === this ||
+ (e.target === this && e.relatedTarget === this._target)
+ ) {
+ return;
+ }
+ }
+
+ // Mark that the hovercard is not visible and do not allow focusing
+ this._isShowing = false;
+
+ // Clear styles in preparation for the next time we need to show the card
+ this.classList.remove(HOVER_CLASS);
+
+ // Reset and remove the hovercard from the DOM
+ this.style.cssText = '';
+ this.$['container'].setAttribute('tabindex', '-1');
+
+ // Remove the hovercard from the container, given that it is still a child
+ // of the container.
+ if (this.container?.contains(this)) {
+ this.container.removeChild(this);
+ }
+ }
+
+ /**
+ * Shows/opens the hovercard with a fixed delay.
+ */
+ debounceShow() {
+ this.debounceShowBy(SHOW_DELAY_MS);
+ }
+
+ /**
+ * Shows/opens the hovercard with the given delay.
+ */
+ debounceShowBy(delayMs: number) {
+ this.cancelHideDebouncer();
+ if (this._isShowing || this._isScheduledToShow) return;
+ this._isScheduledToShow = true;
+ this._showDebouncer = Debouncer.debounce(
+ this._showDebouncer,
+ timeOut.after(delayMs),
+ () => {
+ // This happens when the mouse leaves the target before the delay is over.
+ if (!this._isScheduledToShow) return;
+ this.show();
+ }
+ );
+ }
+
+ cancelShowDebouncer() {
+ if (this._showDebouncer) {
+ this._showDebouncer.cancel();
+ this._isScheduledToShow = false;
+ }
+ }
+
+ /**
+ * 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.
+ */
+ show() {
+ this.cancelHideDebouncer();
+ this.cancelShowDebouncer();
+ if (this._isShowing || !this.container) {
+ return;
+ }
+
+ // Mark that the hovercard is now visible
+ this._isShowing = true;
+ this.setAttribute('tabindex', '0');
+
+ // Add it to the DOM and calculate its position
+ this.container.appendChild(this);
+ // We temporarily hide the hovercard until we have found the correct
+ // position for it.
+ this.classList.add(HIDE_CLASS);
+ this.classList.add(HOVER_CLASS);
+ // Make sure that the hovercard actually rendered and all dom-if
+ // statements processed, so that we can measure the (invisible)
+ // hovercard properly in updatePosition().
+ flush();
+ this.updatePosition();
+ this.classList.remove(HIDE_CLASS);
+ }
+
+ updatePosition() {
+ const positionsToTry = new Set([
+ this.position,
+ 'right',
+ 'bottom-right',
+ 'top-right',
+ 'bottom',
+ 'top',
+ 'bottom-left',
+ 'top-left',
+ 'left',
+ ]);
+ for (const position of positionsToTry) {
+ this.updatePositionTo(position);
+ if (this._isInsideViewport()) return;
+ }
+ console.warn('Could not find a visible position for the hovercard.');
+ }
+
+ _isInsideViewport() {
+ const thisRect = this.getBoundingClientRect();
+ if (thisRect.top < 0) return false;
+ if (thisRect.left < 0) return false;
+ const docuRect = document.documentElement.getBoundingClientRect();
+ if (thisRect.bottom > docuRect.height) return false;
+ if (thisRect.right > docuRect.width) return false;
+ return true;
+ }
+
+ /**
+ * Updates the hovercard's position based the current position of the `target`
+ * element.
+ *
+ * The hovercard is supposed to stay open if the user hovers over it.
+ * To keep it open when the user moves away from the target, the bounding
+ * rects of the target and hovercard must touch or overlap.
+ *
+ * NOTE: You do not need to directly call this method unless you need to
+ * update the position of the tooltip while it is already visible (the
+ * target element has moved and the tooltip is still open).
+ */
+ updatePositionTo(position: string) {
+ if (!this._target) {
+ return;
+ }
+
+ // Make sure that thisRect will not get any paddings and such included
+ // in the width and height of the bounding client rect.
+ this.style.cssText = '';
+
+ const docuRect = document.documentElement.getBoundingClientRect();
+ const targetRect = this._target.getBoundingClientRect();
+ const thisRect = this.getBoundingClientRect();
+
+ const targetLeft = targetRect.left - docuRect.left;
+ const targetTop = targetRect.top - docuRect.top;
+
+ let hovercardLeft;
+ let hovercardTop;
+
+ switch (position) {
+ case 'top':
+ hovercardLeft =
+ targetLeft + (targetRect.width - thisRect.width) / 2;
+ hovercardTop = targetTop - thisRect.height - this.offset;
+ break;
+ case 'bottom':
+ hovercardLeft =
+ targetLeft + (targetRect.width - thisRect.width) / 2;
+ hovercardTop = targetTop + targetRect.height + this.offset;
+ break;
+ case 'left':
+ hovercardLeft = targetLeft - thisRect.width - this.offset;
+ hovercardTop =
+ targetTop + (targetRect.height - thisRect.height) / 2;
+ break;
+ case 'right':
+ hovercardLeft = targetLeft + targetRect.width + this.offset;
+ hovercardTop =
+ targetTop + (targetRect.height - thisRect.height) / 2;
+ break;
+ case 'bottom-right':
+ hovercardLeft = targetLeft + targetRect.width + this.offset;
+ hovercardTop = targetTop;
+ break;
+ case 'bottom-left':
+ hovercardLeft = targetLeft - thisRect.width - this.offset;
+ hovercardTop = targetTop;
+ break;
+ case 'top-left':
+ hovercardLeft = targetLeft - thisRect.width - this.offset;
+ hovercardTop = targetTop + targetRect.height - thisRect.height;
+ break;
+ case 'top-right':
+ hovercardLeft = targetLeft + targetRect.width + this.offset;
+ hovercardTop = targetTop + targetRect.height - thisRect.height;
+ break;
+ }
+
+ this.style.left = `${hovercardLeft}px`;
+ this.style.top = `${hovercardTop}px`;
+ }
+
+ /**
+ * Responds to a change in the `for` value and gets the updated `target`
+ * element for the hovercard.
+ */
+ @observe('for')
+ _forChanged() {
+ this._target = this.target;
+ }
+ }
+
+ return Mixin;
+ }
+);
+
+export interface GrHovercardBehaviorInterface {
+ attached(): void;
+ ready(): void;
+ removeListeners(): void;
+ debounceHide(): void;
+ cancelHideDebouncer(): void;
+ dispatchEventThroughTarget(eventName: string, detail?: unknown): void;
+ hide(e?: MouseEvent): void;
+ debounceShow(): void;
+ debounceShowBy(delayMs: number): void;
+ cancelShowDebouncer(): void;
+ show(): void;
+ updatePosition(): void;
+ updatePositionTo(position: string): void;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
index 4133fd1..aa92654 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
@@ -28,6 +28,8 @@
position: absolute;
display: none;
z-index: 200;
+ max-width: 600px;
+ outline: none;
}
:host(.hovered) {
display: block;
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
deleted file mode 100644
index e334064..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.js
+++ /dev/null
@@ -1,35 +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 '../../../styles/shared-styles.js';
-import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {htmlTemplate} from './gr-hovercard_html.js';
-import {hovercardBehaviorMixin} from './gr-hovercard-behavior.js';
-import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
-import './gr-hovercard-shared-style.js';
-
-/** @extends PolymerElement */
-class GrHovercard extends GestureEventListeners(
- hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
-) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-hovercard'; }
-}
-
-customElements.define(GrHovercard.is, GrHovercard);
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
new file mode 100644
index 0000000..c56bc8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
@@ -0,0 +1,40 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {htmlTemplate} from './gr-hovercard_html';
+import {hovercardBehaviorMixin} from './gr-hovercard-behavior';
+import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
+import './gr-hovercard-shared-style';
+import {customElement} from '@polymer/decorators';
+
+@customElement('gr-hovercard')
+export class GrHovercard extends GestureEventListeners(
+ hovercardBehaviorMixin(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-hovercard': GrHovercard;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
index a7a2c0e..6b2e620 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
@@ -17,11 +17,8 @@
import '../../../test/common-test-setup-karma.js';
import './gr-hovercard.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-//
-
const basicFixture = fixtureFromTemplate(html`
<gr-hovercard for="foo" id="bar"></gr-hovercard>
`);
@@ -30,8 +27,12 @@
let element;
let button;
+ let testResolve;
+ let testPromise;
setup(() => {
+ testResolve = undefined;
+ testPromise = new Promise(r => testResolve = r);
button = document.createElement('button');
button.innerHTML = 'Hello';
button.setAttribute('id', 'foo');
@@ -80,7 +81,7 @@
assert.isFalse(element._isShowing);
assert.isFalse(element.classList.contains('hovered'));
assert.equal(style.display, 'none');
- assert.notEqual(element.container, dom(element).parentNode);
+ assert.notEqual(element.container, element.parentNode);
});
test('show', () => {
@@ -92,59 +93,74 @@
assert.equal(style.visibility, 'visible');
});
- test('showDelayed does not show immediately', done => {
- element.showDelayedBy(100);
- setTimeout(() => {
- assert.isFalse(element._isShowing);
- done();
- }, 0);
- });
-
- test('showDelayed shows after delay', done => {
- element.showDelayedBy(1);
- setTimeout(() => {
- assert.isTrue(element._isShowing);
- done();
- }, 10);
- });
-
- test('card is scheduled to show on enter and hides on leave', done => {
- const button = dom(document).querySelector('button');
+ test('debounceShow does not show immediately', async () => {
+ element.debounceShowBy(100);
+ setTimeout(testResolve, 0);
+ await testPromise;
assert.isFalse(element._isShowing);
- const enterHandler = event => {
- assert.isTrue(element._isScheduledToShow);
- button.dispatchEvent(new CustomEvent('mouseleave'));
- };
- const leaveHandler = event => {
- assert.isFalse(element._isScheduledToShow);
- assert.isFalse(element._isShowing);
- button.removeEventListener('mouseenter', enterHandler);
- button.removeEventListener('mouseleave', leaveHandler);
- done();
- };
- button.addEventListener('mouseenter', enterHandler);
- button.addEventListener('mouseleave', leaveHandler);
- button.dispatchEvent(new CustomEvent('mouseenter'));
});
- test('card should disappear on click', done => {
- const button = dom(document).querySelector('button');
+ test('debounceShow shows after delay', async () => {
+ element.debounceShowBy(1);
+ setTimeout(testResolve, 10);
+ await testPromise;
+ assert.isTrue(element._isShowing);
+ });
+
+ test('card is scheduled to show on enter and hides on leave', async () => {
+ const button = document.querySelector('button');
+ let enterResolve = undefined;
+ const enterPromise = new Promise(r => enterResolve = r);
+ button.addEventListener('mouseenter', enterResolve);
+ let leaveResolve = undefined;
+ const leavePromise = new Promise(r => leaveResolve = r);
+ button.addEventListener('mouseleave', leaveResolve);
+
assert.isFalse(element._isShowing);
- const enterHandler = event => {
- assert.isTrue(element._isScheduledToShow);
- // click to hide
- MockInteractions.tap(button);
- };
- const leaveHandler = event => {
- assert.isFalse(element._isScheduledToShow);
- assert.isFalse(element._isShowing);
- button.removeEventListener('mouseenter', enterHandler);
- button.removeEventListener('click', leaveHandler);
- done();
- };
- button.addEventListener('mouseenter', enterHandler);
- button.addEventListener('click', leaveHandler);
button.dispatchEvent(new CustomEvent('mouseenter'));
+
+ await enterPromise;
+ assert.isTrue(element._isScheduledToShow);
+ element._showDebouncer.flush();
+ assert.isTrue(element._isShowing);
+ assert.isFalse(element._isScheduledToShow);
+
+ button.dispatchEvent(new CustomEvent('mouseleave'));
+
+ await leavePromise;
+ assert.isTrue(element._isScheduledToHide);
+ assert.isTrue(element._isShowing);
+ element._hideDebouncer.flush();
+ assert.isFalse(element._isScheduledToShow);
+ assert.isFalse(element._isShowing);
+
+ button.removeEventListener('mouseenter', enterResolve);
+ button.removeEventListener('mouseleave', leaveResolve);
+ });
+
+ test('card should disappear on click', async () => {
+ const button = document.querySelector('button');
+ let enterResolve = undefined;
+ const enterPromise = new Promise(r => enterResolve = r);
+ button.addEventListener('mouseenter', enterResolve);
+ let clickResolve = undefined;
+ const clickPromise = new Promise(r => clickResolve = r);
+ button.addEventListener('click', clickResolve);
+
+ assert.isFalse(element._isShowing);
+
+ button.dispatchEvent(new CustomEvent('mouseenter'));
+
+ await enterPromise;
+ assert.isTrue(element._isScheduledToShow);
+ MockInteractions.tap(button);
+
+ await clickPromise;
+ assert.isFalse(element._isScheduledToShow);
+ assert.isFalse(element._isShowing);
+
+ button.removeEventListener('mouseenter', enterResolve);
+ button.removeEventListener('click', clickResolve);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
index ccaf40e..7112e3b 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -53,6 +53,8 @@
<g id="delete"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
<!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
<g id="help"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"></path></g>
+ <!-- This SVG is a copy from material.io https://material.io/resources/icons/?icon=help_outline -->
+ <g id="help-outline"><path d="M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></g>
<!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
<g id="info"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"></path></g>
<!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
@@ -75,6 +77,8 @@
<g id="content-copy"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"></path></g>
<!-- This is a custom PolyGerrit SVG -->
<g id="check"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"></path></g>
+ <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+ <g id="check-circle"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path></g>
<!-- This is a custom PolyGerrit SVG -->
<g id="robot"><path d="M4.137453,5.61015591 L4.54835569,1.5340419 C4.5717665,1.30180904 4.76724872,1.12504213 5.00065859,1.12504213 C5.23327176,1.12504213 5.42730868,1.30282046 5.44761309,1.53454578 L5.76084628,5.10933916 C6.16304484,5.03749412 6.57714381,5 7,5 L17,5 C20.8659932,5 24,8.13400675 24,12 L24,15.1250421 C24,18.9910354 20.8659932,22.1250421 17,22.1250421 L7,22.1250421 C3.13400675,22.1250421 2.19029351e-15,18.9910354 0,15.1250421 L0,12 C-3.48556243e-16,9.15382228 1.69864167,6.70438358 4.137453,5.61015591 Z M5.77553049,6.12504213 C3.04904264,6.69038358 1,9.10590202 1,12 L1,15.1250421 C1,18.4387506 3.6862915,21.1250421 7,21.1250421 L17,21.1250421 C20.3137085,21.1250421 23,18.4387506 23,15.1250421 L23,12 C23,8.6862915 20.3137085,6 17,6 L7,6 C6.60617231,6 6.2212068,6.03794347 5.84855971,6.11037415 L5.84984496,6.12504213 L5.77553049,6.12504213 Z M6.93003717,6.95027711 L17.1232083,6.95027711 C19.8638332,6.95027711 22.0855486,9.17199258 22.0855486,11.9126175 C22.0855486,14.6532424 19.8638332,16.8749579 17.1232083,16.8749579 L6.93003717,16.8749579 C4.18941226,16.8749579 1.9676968,14.6532424 1.9676968,11.9126175 C1.9676968,9.17199258 4.18941226,6.95027711 6.93003717,6.95027711 Z M7.60124392,14.0779303 C9.03787127,14.0779303 10.2024878,12.9691885 10.2024878,11.6014862 C10.2024878,10.2337839 9.03787127,9.12504213 7.60124392,9.12504213 C6.16461657,9.12504213 5,10.2337839 5,11.6014862 C5,12.9691885 6.16461657,14.0779303 7.60124392,14.0779303 Z M16.617997,14.1098288 C18.0638768,14.1098288 19.2359939,12.9939463 19.2359939,11.6174355 C19.2359939,10.2409246 18.0638768,9.12504213 16.617997,9.12504213 C15.1721172,9.12504213 14,10.2409246 14,11.6174355 C14,12.9939463 15.1721172,14.1098288 16.617997,14.1098288 Z M9.79751216,18.1250421 L15,18.1250421 L15,19.1250421 C15,19.6773269 14.5522847,20.1250421 14,20.1250421 L10.7975122,20.1250421 C10.2452274,20.1250421 9.79751216,19.6773269 9.79751216,19.1250421 L9.79751216,18.1250421 Z"></path></g>
<!-- This is a custom PolyGerrit SVG -->
@@ -106,6 +110,8 @@
<g id="ready"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></g>
<!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons -->
<g id="schedule"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"></path></g>
+ <!-- This SVG is a copy from material.io https://material.io/icons/#bug_report-->
+ <g id="bug"><path d="M0 0h24v24H0z" fill="none"/><path d="M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></g>
</defs>
</svg>
</iron-iconset-svg>`;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
deleted file mode 100644
index 6514aa2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.js
+++ /dev/null
@@ -1,71 +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 {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation.js';
-
-/**
- * Used to create a context for GrAnnotationActionsInterface.
- *
- * @param {HTMLElement} contentEl The DIV.contentText element of the line
- * content to apply the annotation to using annotateRange.
- * @param {HTMLElement} lineNumberEl The TD element of the line number to
- * apply the annotation to using annotateLineNumber.
- * @param {GrDiffLine} line The line object.
- * @param {string} path The file path (eg: /COMMIT_MSG').
- * @param {string} changeNum The Gerrit change number.
- * @param {string} patchNum The Gerrit patch number.
- */
-export function GrAnnotationActionsContext(
- contentEl, lineNumberEl, line, path, changeNum, patchNum) {
- this._contentEl = contentEl;
- this._lineNumberEl = lineNumberEl;
-
- this.line = line;
- this.path = path;
- this.changeNum = parseInt(changeNum);
- this.patchNum = parseInt(patchNum);
-}
-
-/**
- * Method to add annotations to a content line.
- *
- * @param {number} offset The char offset where the update starts.
- * @param {number} length The number of chars that the update covers.
- * @param {GrStyleObject} styleObject The style object for the range.
- * @param {string} side The side of the update. ('left' or 'right')
- */
-GrAnnotationActionsContext.prototype.annotateRange = function(
- offset, length, styleObject, side) {
- if (this._contentEl && this._contentEl.getAttribute('data-side') == side) {
- GrAnnotation.annotateElement(this._contentEl, offset, length,
- styleObject.getClassName(this._contentEl));
- }
-};
-
-/**
- * Method to add a CSS class to the line number TD element.
- *
- * @param {GrStyleObject} styleObject The style object for the range.
- * @param {string} side The side of the update. ('left' or 'right')
- */
-GrAnnotationActionsContext.prototype.annotateLineNumber = function(
- styleObject, side) {
- if (this._lineNumberEl && this._lineNumberEl.classList.contains(side)) {
- styleObject.apply(this._lineNumberEl);
- }
-};
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
new file mode 100644
index 0000000..0cb628c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-context.ts
@@ -0,0 +1,100 @@
+/**
+ * @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 {GrAnnotation} from '../../diff/gr-diff-highlight/gr-annotation';
+import {GrStyleObject} from '../../plugins/gr-styles-api/gr-styles-api';
+import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
+
+/**
+ * Used to create a context for GrAnnotationActionsInterface.
+ *
+ * @param contentEl The DIV.contentText element of the line
+ * content to apply the annotation to using annotateRange.
+ * @param lineNumberEl The TD element of the line number to
+ * apply the annotation to using annotateLineNumber.
+ * @param line The line object.
+ * @param path The file path (eg: /COMMIT_MSG').
+ * @param changeNum The Gerrit change number.
+ * @param patchNum The Gerrit patch number.
+ */
+export class GrAnnotationActionsContext {
+ private _contentEl: HTMLElement;
+
+ private _lineNumberEl: HTMLElement;
+
+ line: GrDiffLine;
+
+ path: string;
+
+ changeNum: number;
+
+ constructor(
+ contentEl: HTMLElement,
+ lineNumberEl: HTMLElement,
+ line: GrDiffLine,
+ path: string,
+ changeNum: string | number
+ ) {
+ this._contentEl = contentEl;
+ this._lineNumberEl = lineNumberEl;
+
+ this.line = line;
+ this.path = path;
+ this.changeNum = Number(changeNum);
+ if (isNaN(this.changeNum)) {
+ console.error(
+ `GrAnnotationActionsContext: Invalid changeNum: ${changeNum}`
+ );
+ }
+ }
+
+ /**
+ * Method to add annotations to a content line.
+ *
+ * @param offset The char offset where the update starts.
+ * @param length The number of chars that the update covers.
+ * @param styleObject The style object for the range.
+ * @param side The side of the update. ('left' or 'right')
+ */
+ annotateRange(
+ offset: number,
+ length: number,
+ styleObject: GrStyleObject,
+ side: string
+ ) {
+ if (this._contentEl?.getAttribute('data-side') === side) {
+ GrAnnotation.annotateElement(
+ this._contentEl,
+ offset,
+ length,
+ styleObject.getClassName(this._contentEl)
+ );
+ }
+ }
+
+ /**
+ * Method to add a CSS class to the line number TD element.
+ *
+ * @param styleObject The style object for the range.
+ * @param side The side of the update. ('left' or 'right')
+ */
+ annotateLineNumber(styleObject: GrStyleObject, side: string) {
+ if (this._lineNumberEl?.classList.contains(side)) {
+ styleObject.apply(this._lineNumberEl);
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
deleted file mode 100644
index 6f73c36..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.js
+++ /dev/null
@@ -1,237 +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 {GrAnnotationActionsContext} from './gr-annotation-actions-context.js';
-
-/** @constructor */
-export function GrAnnotationActionsInterface(plugin) {
- this.plugin = plugin;
- // Return this instance when there is an annotatediff event.
- plugin.on('annotatediff', this);
-
- // Collect all annotation layers instantiated by getLayer. Will be used when
- // notifying their listeners in the notify function.
- this._annotationLayers = [];
-
- this._coverageProvider = null;
-
- // Default impl is a no-op.
- this._addLayerFunc = annotationActionsContext => {};
-}
-
-/**
- * Register a function to call to apply annotations. Plugins should use
- * GrAnnotationActionsContext.annotateRange and
- * GrAnnotationActionsContext.annotateLineNumber to apply a CSS class to the
- * line content or the line number.
- *
- * @param {function(GrAnnotationActionsContext)} addLayerFunc The function
- * that will be called when the AnnotationLayer is ready to annotate.
- */
-GrAnnotationActionsInterface.prototype.addLayer = function(addLayerFunc) {
- this._addLayerFunc = addLayerFunc;
- return this;
-};
-
-/**
- * The specified function will be called with a notify function for the plugin
- * to call when it has all required data for annotation. Optional.
- *
- * @param {function(function(String, Number, Number, String))} notifyFunc See
- * doc of the notify function below to see what it does.
- */
-GrAnnotationActionsInterface.prototype.addNotifier = function(notifyFunc) {
- // Register the notify function with the plugin's function.
- notifyFunc(this.notify.bind(this));
- return this;
-};
-
-/**
- * The specified function will be called when a gr-diff component is built,
- * and feeds the returned coverage data into the diff. Optional.
- *
- * Be sure to call this only once and only from one plugin. Multiple coverage
- * providers are not supported. A second call will just overwrite the
- * provider of the first call.
- *
- * @param {function(changeNum, path, basePatchNum, patchNum):
- * !Promise<!Array<!Gerrit.CoverageRange>>} coverageProvider
- * @return {GrAnnotationActionsInterface}
- */
-GrAnnotationActionsInterface.prototype.setCoverageProvider = function(
- coverageProvider) {
- if (this._coverageProvider) {
- console.warn('Overwriting an existing coverage provider.');
- }
- this._coverageProvider = coverageProvider;
- return this;
-};
-
-/**
- * Used by Gerrit to look up the coverage provider. Not intended to be called
- * by plugins.
- */
-GrAnnotationActionsInterface.prototype.getCoverageProvider = function() {
- return this._coverageProvider;
-};
-
-/**
- * Returns a checkbox HTMLElement that can be used to toggle annotations
- * on/off. The checkbox will be initially disabled. Plugins should enable it
- * when data is ready and should add a click handler to toggle CSS on/off.
- *
- * Note1: Calling this method from multiple plugins will only work for the
- * 1st call. It will print an error message for all subsequent calls
- * and will not invoke their onAttached functions.
- * Note2: This method will be deprecated and eventually removed when
- * https://bugs.chromium.org/p/gerrit/issues/detail?id=8077 is
- * implemented.
- *
- * @param {string} checkboxLabel Will be used as the label for the checkbox.
- * Optional. "Enable" is used if this is not specified.
- * @param {function(HTMLElement)} onAttached The function that will be called
- * when the checkbox is attached to the page.
- */
-GrAnnotationActionsInterface.prototype.enableToggleCheckbox = function(
- checkboxLabel, onAttached) {
- this.plugin.hook('annotation-toggler').onAttached(element => {
- if (!element.content.hidden) {
- console.error(
- element.content.id + ' is already enabled. Cannot re-enable.');
- return;
- }
- element.content.removeAttribute('hidden');
-
- const label = element.content.querySelector('#annotation-label');
- if (checkboxLabel) {
- label.textContent = checkboxLabel;
- } else {
- label.textContent = 'Enable';
- }
- const checkbox = element.content.querySelector('#annotation-checkbox');
- onAttached(checkbox);
- });
- return this;
-};
-
-/**
- * The notify function will call the listeners of all required annotation
- * layers. Intended to be called by the plugin when all required data for
- * annotation is available.
- *
- * @param {string} path The file path whose listeners should be notified.
- * @param {number} start The line where the update starts.
- * @param {number} end The line where the update ends.
- * @param {string} side The side of the update ('left' or 'right').
- */
-GrAnnotationActionsInterface.prototype.notify = function(
- path, startRange, endRange, side) {
- for (const annotationLayer of this._annotationLayers) {
- // Notify only the annotation layer that is associated with the specified
- // path.
- if (annotationLayer._path === path) {
- annotationLayer.notifyListeners(startRange, endRange, side);
- }
- }
-};
-
-/**
- * Should be called to register annotation layers by the framework. Not
- * intended to be called by plugins.
- *
- * Don't forget to dispose layer.
- *
- * @param {string} path The file path (eg: /COMMIT_MSG').
- * @param {string} changeNum The Gerrit change number.
- * @param {string} patchNum The Gerrit patch number.
- */
-GrAnnotationActionsInterface.prototype.getLayer = function(
- path, changeNum, patchNum) {
- const annotationLayer = new AnnotationLayer(path, changeNum, patchNum,
- this._addLayerFunc);
- this._annotationLayers.push(annotationLayer);
- return annotationLayer;
-};
-
-GrAnnotationActionsInterface.prototype.disposeLayer = function(path) {
- this._annotationLayers = this._annotationLayers
- .filter(annotationLayer => annotationLayer._path !== path);
-};
-
-/**
- * Used to create an instance of the Annotation Layer interface.
- *
- * @constructor
- * @param {string} path The file path (eg: /COMMIT_MSG').
- * @param {string} changeNum The Gerrit change number.
- * @param {string} patchNum The Gerrit patch number.
- * @param {function(GrAnnotationActionsContext)} addLayerFunc The function
- * that will be called when the AnnotationLayer is ready to annotate.
- */
-function AnnotationLayer(path, changeNum, patchNum, addLayerFunc) {
- this._path = path;
- this._changeNum = changeNum;
- this._patchNum = patchNum;
- this._addLayerFunc = addLayerFunc;
-
- this._listeners = [];
-}
-
-/**
- * Register a listener for layer updates.
- * Don't forget to removeListener when you stop using layer.
- *
- * @param {Function} fn The update handler function.
- * Should accept as arguments the line numbers for the start and end of
- * the update and the side as a string.
- */
-AnnotationLayer.prototype.addListener = function(fn) {
- this._listeners.push(fn);
-};
-
-AnnotationLayer.prototype.removeListener = function(fn) {
- this._listeners = this._listeners.filter(f => f != fn);
-};
-
-/**
- * Layer method to add annotations to a line.
- *
- * @param {HTMLElement} contentEl The DIV.contentText element of the line
- * content to apply the annotation to using annotateRange.
- * @param {HTMLElement} lineNumberEl The TD element of the line number to
- * apply the annotation to using annotateLineNumber.
- * @param {GrDiffLine} line The line object.
- */
-AnnotationLayer.prototype.annotate = function(contentEl, lineNumberEl, line) {
- const annotationActionsContext = new GrAnnotationActionsContext(
- contentEl, lineNumberEl, line, this._path, this._changeNum,
- this._patchNum);
- this._addLayerFunc(annotationActionsContext);
-};
-
-/**
- * Notify Layer listeners of changes to annotations.
- *
- * @param {number} start The line where the update starts.
- * @param {number} end The line where the update ends.
- * @param {string} side The side of the update. ('left' or 'right')
- */
-AnnotationLayer.prototype.notifyListeners = function(
- startRange, endRange, side) {
- for (const listener of this._listeners) {
- listener(startRange, endRange, side);
- }
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
new file mode 100644
index 0000000..e069f8b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api.ts
@@ -0,0 +1,279 @@
+/**
+ * @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 {GrAnnotationActionsContext} from './gr-annotation-actions-context';
+import {GrDiffLine} from '../../diff/gr-diff/gr-diff-line';
+import {
+ CoverageRange,
+ DiffLayer,
+ DiffLayerListener,
+} from '../../../types/types';
+import {Side} from '../../../constants/constants';
+import {PluginApi} from '../../plugins/gr-plugin-types';
+import {ChangeInfo, NumericChangeId} from '../../../types/common';
+
+type AddLayerFunc = (ctx: GrAnnotationActionsContext) => void;
+
+type NotifyFunc = (
+ path: string,
+ start: number,
+ end: number,
+ side: Side
+) => void;
+
+export type CoverageProvider = (
+ changeNum: NumericChangeId,
+ path: string,
+ basePatchNum?: number,
+ patchNum?: number,
+ change?: ChangeInfo
+) => Promise<Array<CoverageRange>>;
+
+export class GrAnnotationActionsInterface {
+ // Collect all annotation layers instantiated by getLayer. Will be used when
+ // notifying their listeners in the notify function.
+ private annotationLayers: AnnotationLayer[] = [];
+
+ private coverageProvider: CoverageProvider | null = null;
+
+ // Default impl is a no-op.
+ private addLayerFunc: AddLayerFunc = () => {};
+
+ constructor(private readonly plugin: PluginApi) {
+ // Return this instance when there is an annotatediff event.
+ plugin.on('annotatediff', this);
+ }
+
+ /**
+ * Register a function to call to apply annotations. Plugins should use
+ * GrAnnotationActionsContext.annotateRange and
+ * GrAnnotationActionsContext.annotateLineNumber to apply a CSS class to the
+ * line content or the line number.
+ *
+ * @param addLayerFunc The function
+ * that will be called when the AnnotationLayer is ready to annotate.
+ */
+ addLayer(addLayerFunc: AddLayerFunc) {
+ this.addLayerFunc = addLayerFunc;
+ return this;
+ }
+
+ /**
+ * The specified function will be called with a notify function for the plugin
+ * to call when it has all required data for annotation. Optional.
+ *
+ * @param notifyFunc See doc of the notify function below to see what it does.
+ */
+ addNotifier(notifyFunc: (n: NotifyFunc) => void) {
+ notifyFunc(
+ (path: string, startRange: number, endRange: number, side: Side) =>
+ this.notify(path, startRange, endRange, side)
+ );
+ return this;
+ }
+
+ /**
+ * The specified function will be called when a gr-diff component is built,
+ * and feeds the returned coverage data into the diff. Optional.
+ *
+ * Be sure to call this only once and only from one plugin. Multiple coverage
+ * providers are not supported. A second call will just overwrite the
+ * provider of the first call.
+ */
+ setCoverageProvider(
+ coverageProvider: CoverageProvider
+ ): GrAnnotationActionsInterface {
+ if (this.coverageProvider) {
+ console.warn('Overwriting an existing coverage provider.');
+ }
+ this.coverageProvider = coverageProvider;
+ return this;
+ }
+
+ /**
+ * Used by Gerrit to look up the coverage provider. Not intended to be called
+ * by plugins.
+ */
+ getCoverageProvider() {
+ return this.coverageProvider;
+ }
+
+ /**
+ * Returns a checkbox HTMLElement that can be used to toggle annotations
+ * on/off. The checkbox will be initially disabled. Plugins should enable it
+ * when data is ready and should add a click handler to toggle CSS on/off.
+ *
+ * Note1: Calling this method from multiple plugins will only work for the
+ * 1st call. It will print an error message for all subsequent calls
+ * and will not invoke their onAttached functions.
+ * Note2: This method will be deprecated and eventually removed when
+ * https://bugs.chromium.org/p/gerrit/issues/detail?id=8077 is
+ * implemented.
+ *
+ * @param checkboxLabel Will be used as the label for the checkbox.
+ * Optional. "Enable" is used if this is not specified.
+ * @param onAttached The function that will be called
+ * when the checkbox is attached to the page.
+ */
+ enableToggleCheckbox(
+ checkboxLabel: string,
+ onAttached: (checkboxEl: Element | null) => void
+ ) {
+ this.plugin.hook('annotation-toggler').onAttached(element => {
+ if (!element.content) {
+ console.error('plugin endpoint without content.');
+ return;
+ }
+ if (!element.content.hidden) {
+ console.error(
+ element.content.id + ' is already enabled. Cannot re-enable.'
+ );
+ return;
+ }
+ element.content.removeAttribute('hidden');
+
+ const label = element.content.querySelector('#annotation-label');
+ if (label) {
+ if (checkboxLabel) {
+ label.textContent = checkboxLabel;
+ } else {
+ label.textContent = 'Enable';
+ }
+ }
+ const checkbox = element.content.querySelector('#annotation-checkbox');
+ onAttached(checkbox);
+ });
+ return this;
+ }
+
+ /**
+ * The notify function will call the listeners of all required annotation
+ * layers. Intended to be called by the plugin when all required data for
+ * annotation is available.
+ *
+ * @param path The file path whose listeners should be notified.
+ * @param start The line where the update starts.
+ * @param end The line where the update ends.
+ * @param side The side of the update ('left' or 'right').
+ */
+ notify(path: string, start: number, end: number, side: Side) {
+ for (const annotationLayer of this.annotationLayers) {
+ // Notify only the annotation layer that is associated with the specified
+ // path.
+ if (annotationLayer.path === path) {
+ annotationLayer.notifyListeners(start, end, side);
+ }
+ }
+ }
+
+ /**
+ * Should be called to register annotation layers by the framework. Not
+ * intended to be called by plugins.
+ *
+ * Don't forget to dispose layer.
+ *
+ * @param path The file path (eg: /COMMIT_MSG').
+ * @param changeNum The Gerrit change number.
+ */
+ getLayer(path: string, changeNum: number) {
+ const annotationLayer = new AnnotationLayer(
+ path,
+ changeNum,
+ this.addLayerFunc
+ );
+ this.annotationLayers.push(annotationLayer);
+ return annotationLayer;
+ }
+
+ disposeLayer(path: string) {
+ this.annotationLayers = this.annotationLayers.filter(
+ annotationLayer => annotationLayer.path !== path
+ );
+ }
+}
+
+export class AnnotationLayer implements DiffLayer {
+ private listeners: DiffLayerListener[] = [];
+
+ /**
+ * Used to create an instance of the Annotation Layer interface.
+ *
+ * @param path The file path (eg: /COMMIT_MSG').
+ * @param changeNum The Gerrit change number.
+ * @param addLayerFunc The function
+ * that will be called when the AnnotationLayer is ready to annotate.
+ */
+ constructor(
+ readonly path: string,
+ private readonly changeNum: number,
+ private readonly addLayerFunc: AddLayerFunc
+ ) {
+ this.listeners = [];
+ }
+
+ /**
+ * Register a listener for layer updates.
+ * Don't forget to removeListener when you stop using layer.
+ *
+ * @param fn The update handler function.
+ * Should accept as arguments the line numbers for the start and end of
+ * the update and the side as a string.
+ */
+ addListener(listener: DiffLayerListener) {
+ this.listeners.push(listener);
+ }
+
+ removeListener(listener: DiffLayerListener) {
+ this.listeners = this.listeners.filter(f => f !== listener);
+ }
+
+ /**
+ * Layer method to add annotations to a line.
+ *
+ * @param contentEl The DIV.contentText element of the line
+ * content to apply the annotation to using annotateRange.
+ * @param lineNumberEl The TD element of the line number to
+ * apply the annotation to using annotateLineNumber.
+ * @param line The line object.
+ */
+ annotate(
+ contentEl: HTMLElement,
+ lineNumberEl: HTMLElement,
+ line: GrDiffLine
+ ) {
+ const annotationActionsContext = new GrAnnotationActionsContext(
+ contentEl,
+ lineNumberEl,
+ line,
+ this.path,
+ this.changeNum
+ );
+ this.addLayerFunc(annotationActionsContext);
+ }
+
+ /**
+ * Notify Layer listeners of changes to annotations.
+ *
+ * @param start The line where the update starts.
+ * @param end The line where the update ends.
+ * @param side The side of the update. ('left' or 'right')
+ */
+ notifyListeners(start: number, end: number, side: Side) {
+ for (const listener of this.listeners) {
+ listener(start, end, side);
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
index e819529..8b3f501 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-annotation-actions-js-api_test.js
@@ -52,19 +52,17 @@
const el = document.createElement('div');
el.textContent = str;
const changeNum = 1234;
- const patchNum = 2;
let testLayerFuncCalled = false;
const testLayerFunc = context => {
testLayerFuncCalled = true;
assert.equal(context.line, line);
assert.equal(context.changeNum, changeNum);
- assert.equal(context.patchNum, 2);
};
annotationActions.addLayer(testLayerFunc);
const annotationLayer = annotationActions.getLayer(
- '/dummy/path', changeNum, patchNum);
+ '/dummy/path', changeNum);
const lineNumberEl = document.createElement('td');
annotationLayer.annotate(el, lineNumberEl, line);
@@ -74,8 +72,8 @@
test('add notifier', () => {
const path1 = '/dummy/path1';
const path2 = '/dummy/path2';
- const annotationLayer1 = annotationActions.getLayer(path1, 1, 2);
- const annotationLayer2 = annotationActions.getLayer(path2, 1, 2);
+ const annotationLayer1 = annotationActions.getLayer(path1, 1);
+ const annotationLayer2 = annotationActions.getLayer(path2, 1);
const layer1Spy = sinon.spy(annotationLayer1, 'notifyListeners');
const layer2Spy = sinon.spy(annotationLayer2, 'notifyListeners');
@@ -148,8 +146,7 @@
});
test('layer notify listeners', () => {
- const annotationLayer = annotationActions.getLayer(
- '/dummy/path', 1, 2);
+ const annotationLayer = annotationActions.getLayer('/dummy/path', 1);
let listenerCalledTimes = 0;
const startRange = 10;
const endRange = 20;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
deleted file mode 100644
index 9bef3a2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 {getBaseUrl} from '../../../utils/url-util.js';
-
-export const PRELOADED_PROTOCOL = 'preloaded:';
-export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
-
-let _restAPI;
-export function getRestAPI() {
- if (!_restAPI) {
- _restAPI = document.createElement('gr-rest-api-interface');
- }
- return _restAPI;
-}
-
-/**
- * Retrieves the name of the plugin base on the url.
- *
- * @param {string|URL} url
- */
-export function getPluginNameFromUrl(url) {
- if (!(url instanceof URL)) {
- try {
- url = new URL(url);
- } catch (e) {
- console.warn(e);
- return null;
- }
- }
- if (url.protocol === PRELOADED_PROTOCOL) {
- return url.pathname;
- }
- const base = getBaseUrl();
- let pathname = url.pathname.replace(base, '');
- // Load from ASSETS_PATH
- if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
- pathname = url.href.replace(window.ASSETS_PATH, '');
- }
- // Site theme is server from predefined path.
- if ([
- '/static/gerrit-theme.html',
- '/static/gerrit-theme.js',
- ].includes(pathname)) {
- return 'gerrit-theme';
- } else if (!pathname.startsWith('/plugins')) {
- console.warn('Plugin not being loaded from /plugins base path:',
- url.href, '— Unable to determine name.');
- return null;
- }
-
- // Pathname should normally look like this:
- // /plugins/PLUGINNAME/static/SCRIPTNAME.html
- // Or, for app/samples:
- // /plugins/PLUGINNAME.html
- // TODO(taoalpha): guard with a regex
- return pathname.split('/')[2].split('.')[0];
-}
-
-// TODO(taoalpha): to be deprecated.
-export function send(method, url, opt_callback, opt_payload) {
- return getRestAPI().send(method, url, opt_payload)
- .then(response => {
- if (response.status < 200 || response.status >= 300) {
- return response.text().then(text => {
- if (text) {
- return Promise.reject(new Error(text));
- } else {
- return Promise.reject(new Error(response.status));
- }
- });
- } else {
- return getRestAPI().getResponseObject(response);
- }
- })
- .then(response => {
- if (opt_callback) {
- opt_callback(response);
- }
- return response;
- });
-}
-
-// TEST only methods / properties
-
-export function testOnly_resetInternalState() {
- _restAPI = undefined;
-}
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
new file mode 100644
index 0000000..8f743e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils.ts
@@ -0,0 +1,113 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {getBaseUrl} from '../../../utils/url-util';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {HttpMethod} from '../../../constants/constants';
+import {RequestPayload} from '../../../types/common';
+
+export const PRELOADED_PROTOCOL = 'preloaded:';
+export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
+
+let _restAPI: RestApiService | undefined;
+export function getRestAPI() {
+ if (!_restAPI) {
+ _restAPI = (document.createElement(
+ 'gr-rest-api-interface'
+ ) as unknown) as RestApiService;
+ }
+ return _restAPI;
+}
+
+/**
+ * Retrieves the name of the plugin base on the url.
+ */
+export function getPluginNameFromUrl(url: URL | string) {
+ if (!(url instanceof URL)) {
+ try {
+ url = new URL(url);
+ } catch (e) {
+ console.warn(e);
+ return null;
+ }
+ }
+ if (url.protocol === PRELOADED_PROTOCOL) {
+ return url.pathname;
+ }
+ const base = getBaseUrl();
+ let pathname = url.pathname.replace(base, '');
+ // Load from ASSETS_PATH
+ if (window.ASSETS_PATH && url.href.includes(window.ASSETS_PATH)) {
+ pathname = url.href.replace(window.ASSETS_PATH, '');
+ }
+ // Site theme is server from predefined path.
+ if (
+ ['/static/gerrit-theme.html', '/static/gerrit-theme.js'].includes(pathname)
+ ) {
+ return 'gerrit-theme';
+ } else if (!pathname.startsWith('/plugins')) {
+ console.warn(
+ 'Plugin not being loaded from /plugins base path:',
+ url.href,
+ '— Unable to determine name.'
+ );
+ return null;
+ }
+
+ // Pathname should normally look like this:
+ // /plugins/PLUGINNAME/static/SCRIPTNAME.html
+ // Or, for app/samples:
+ // /plugins/PLUGINNAME.html
+ // TODO(taoalpha): guard with a regex
+ return pathname.split('/')[2].split('.')[0];
+}
+
+// TODO(taoalpha): to be deprecated.
+export function send(
+ method: HttpMethod,
+ url: string,
+ opt_callback?: (response: unknown) => void,
+ opt_payload?: RequestPayload
+) {
+ return getRestAPI()
+ .send(method, url, opt_payload)
+ .then(response => {
+ if (response.status < 200 || response.status >= 300) {
+ return response.text().then((text: string | undefined) => {
+ if (text) {
+ return Promise.reject(new Error(text));
+ } else {
+ return Promise.reject(new Error(`${response.status}`));
+ }
+ });
+ } else {
+ return getRestAPI().getResponseObject(response);
+ }
+ })
+ .then(response => {
+ if (opt_callback) {
+ opt_callback(response);
+ }
+ return response;
+ });
+}
+
+// TEST only methods / properties
+
+export function testOnly_resetInternalState() {
+ _restAPI = undefined;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
deleted file mode 100644
index 8ab97f8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ /dev/null
@@ -1,135 +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.
- */
-
-/**
- * Ensure GrChangeActionsInterface instance has access to gr-change-actions
- * element and retrieve if the interface was created before element.
- *
- * @param {!GrChangeActionsInterface} api
- */
-function ensureEl(api) {
- if (!api._el) {
- const sharedApiElement = document.createElement('gr-js-api-interface');
- setEl(api, sharedApiElement.getElement(
- sharedApiElement.Element.CHANGE_ACTIONS));
- }
-}
-
-/**
- * Set gr-change-actions element to a GrChangeActionsInterface instance.
- *
- * @param {!GrChangeActionsInterface} api
- * @param {!Element} el gr-change-actions
- */
-function setEl(api, el) {
- if (!el) {
- console.warn('changeActions() is not ready');
- return;
- }
- api._el = el;
- api.RevisionActions = el.RevisionActions;
- api.ChangeActions = el.ChangeActions;
- api.ActionType = el.ActionType;
-}
-
-export function GrChangeActionsInterface(plugin, el) {
- this.plugin = plugin;
- setEl(this, el);
-}
-
-GrChangeActionsInterface.prototype.addPrimaryActionKey = function(key) {
- ensureEl(this);
- if (this._el.primaryActionKeys.includes(key)) { return; }
-
- this._el.push('primaryActionKeys', key);
-};
-
-GrChangeActionsInterface.prototype.removePrimaryActionKey = function(key) {
- ensureEl(this);
- this._el.primaryActionKeys = this._el.primaryActionKeys
- .filter(k => k !== key);
-};
-
-GrChangeActionsInterface.prototype.hideQuickApproveAction = function() {
- ensureEl(this);
- this._el.hideQuickApproveAction();
-};
-
-GrChangeActionsInterface.prototype.setActionOverflow = function(type, key,
- overflow) {
- ensureEl(this);
- return this._el.setActionOverflow(type, key, overflow);
-};
-
-GrChangeActionsInterface.prototype.setActionPriority = function(type, key,
- priority) {
- ensureEl(this);
- return this._el.setActionPriority(type, key, priority);
-};
-
-GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
- hidden) {
- ensureEl(this);
- return this._el.setActionHidden(type, key, hidden);
-};
-
-GrChangeActionsInterface.prototype.add = function(type, label) {
- ensureEl(this);
- return this._el.addActionButton(type, label);
-};
-
-GrChangeActionsInterface.prototype.remove = function(key) {
- ensureEl(this);
- return this._el.removeActionButton(key);
-};
-
-GrChangeActionsInterface.prototype.addTapListener = function(key, handler) {
- ensureEl(this);
- this._el.addEventListener(key + '-tap', handler);
-};
-
-GrChangeActionsInterface.prototype.removeTapListener = function(key,
- handler) {
- ensureEl(this);
- this._el.removeEventListener(key + '-tap', handler);
-};
-
-GrChangeActionsInterface.prototype.setLabel = function(key, text) {
- ensureEl(this);
- this._el.setActionButtonProp(key, 'label', text);
-};
-
-GrChangeActionsInterface.prototype.setTitle = function(key, text) {
- ensureEl(this);
- this._el.setActionButtonProp(key, 'title', text);
-};
-
-GrChangeActionsInterface.prototype.setEnabled = function(key, enabled) {
- ensureEl(this);
- this._el.setActionButtonProp(key, 'enabled', enabled);
-};
-
-GrChangeActionsInterface.prototype.setIcon = function(key, icon) {
- ensureEl(this);
- this._el.setActionButtonProp(key, 'icon', icon);
-};
-
-GrChangeActionsInterface.prototype.getActionDetails = function(action) {
- ensureEl(this);
- return this._el.getActionDetails(action) ||
- this._el.getActionDetails(this.plugin.getPluginName() + '~' + action);
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
new file mode 100644
index 0000000..7e8c1f8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.ts
@@ -0,0 +1,207 @@
+/**
+ * @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 {
+ ActionType,
+ ActionPriority,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {JsApiService} from './gr-js-api-types';
+import {PluginApi, TargetElement} from '../../plugins/gr-plugin-types';
+import {ActionInfo, RequireProperties} from '../../../types/common';
+
+export enum ChangeActions {
+ ABANDON = 'abandon',
+ DELETE = '/',
+ DELETE_EDIT = 'deleteEdit',
+ EDIT = 'edit',
+ FOLLOW_UP = 'followup',
+ IGNORE = 'ignore',
+ MOVE = 'move',
+ PRIVATE = 'private',
+ PRIVATE_DELETE = 'private.delete',
+ PUBLISH_EDIT = 'publishEdit',
+ REBASE = 'rebase',
+ REBASE_EDIT = 'rebaseEdit',
+ READY = 'ready',
+ RESTORE = 'restore',
+ REVERT = 'revert',
+ REVERT_SUBMISSION = 'revert_submission',
+ REVIEWED = 'reviewed',
+ STOP_EDIT = 'stopEdit',
+ SUBMIT = 'submit',
+ UNIGNORE = 'unignore',
+ UNREVIEWED = 'unreviewed',
+ WIP = 'wip',
+}
+
+export enum RevisionActions {
+ CHERRYPICK = 'cherrypick',
+ REBASE = 'rebase',
+ SUBMIT = 'submit',
+ DOWNLOAD = 'download',
+}
+
+export type PrimaryActionKey = ChangeActions | RevisionActions;
+
+export interface UIActionInfo extends RequireProperties<ActionInfo, 'label'> {
+ __key: string;
+ __url?: string;
+ __primary?: boolean;
+ __type: ActionType;
+ icon?: string;
+}
+
+// This interface is required to avoid circular dependencies between files;
+export interface GrChangeActionsElement extends Element {
+ RevisionActions?: Record<string, string>;
+ ChangeActions: Record<string, string>;
+ ActionType: Record<string, string>;
+ primaryActionKeys: string[];
+ push(propName: 'primaryActionKeys', value: string): void;
+ hideQuickApproveAction(): void;
+ setActionOverflow(type: ActionType, key: string, overflow: boolean): void;
+ setActionPriority(
+ type: ActionType,
+ key: string,
+ overflow: ActionPriority
+ ): void;
+ setActionHidden(type: ActionType, key: string, hidden: boolean): void;
+ addActionButton(type: ActionType, label: string): string;
+ removeActionButton(key: string): void;
+ setActionButtonProp<T extends keyof UIActionInfo>(
+ key: string,
+ prop: T,
+ value: UIActionInfo[T]
+ ): void;
+ getActionDetails(actionName: string): ActionInfo | undefined;
+}
+
+export class GrChangeActionsInterface {
+ private _el?: GrChangeActionsElement;
+
+ RevisionActions = RevisionActions;
+
+ ChangeActions = ChangeActions;
+
+ ActionType = ActionType;
+
+ constructor(public plugin: PluginApi, el?: GrChangeActionsElement) {
+ this.setEl(el);
+ }
+
+ /**
+ * Set gr-change-actions element to a GrChangeActionsInterface instance.
+ */
+ private setEl(el?: GrChangeActionsElement) {
+ if (!el) {
+ console.warn('changeActions() is not ready');
+ return;
+ }
+ this._el = el;
+ }
+
+ /**
+ * Ensure GrChangeActionsInterface instance has access to gr-change-actions
+ * element and retrieve if the interface was created before element.
+ */
+ private ensureEl(): GrChangeActionsElement {
+ if (!this._el) {
+ const sharedApiElement = (document.createElement(
+ 'gr-js-api-interface'
+ ) as unknown) as JsApiService;
+ this.setEl(
+ (sharedApiElement.getElement(
+ TargetElement.CHANGE_ACTIONS
+ ) as unknown) as GrChangeActionsElement
+ );
+ }
+ return this._el!;
+ }
+
+ addPrimaryActionKey(key: PrimaryActionKey) {
+ const el = this.ensureEl();
+ if (el.primaryActionKeys.includes(key)) {
+ return;
+ }
+
+ el.push('primaryActionKeys', key);
+ }
+
+ removePrimaryActionKey(key: string) {
+ const el = this.ensureEl();
+ el.primaryActionKeys = el.primaryActionKeys.filter(k => k !== key);
+ }
+
+ hideQuickApproveAction() {
+ this.ensureEl().hideQuickApproveAction();
+ }
+
+ setActionOverflow(type: ActionType, key: string, overflow: boolean) {
+ // TODO(TS): remove return, unclear why it was written
+ return this.ensureEl().setActionOverflow(type, key, overflow);
+ }
+
+ setActionPriority(type: ActionType, key: string, priority: ActionPriority) {
+ // TODO(TS): remove return, unclear why it was written
+ return this.ensureEl().setActionPriority(type, key, priority);
+ }
+
+ setActionHidden(type: ActionType, key: string, hidden: boolean) {
+ // TODO(TS): remove return, unclear why it was written
+ return this.ensureEl().setActionHidden(type, key, hidden);
+ }
+
+ add(type: ActionType, label: string): string {
+ return this.ensureEl().addActionButton(type, label);
+ }
+
+ remove(key: string) {
+ // TODO(TS): remove return, unclear why it was written
+ return this.ensureEl().removeActionButton(key);
+ }
+
+ addTapListener(key: string, handler: EventListenerOrEventListenerObject) {
+ this.ensureEl().addEventListener(key + '-tap', handler);
+ }
+
+ removeTapListener(key: string, handler: EventListenerOrEventListenerObject) {
+ this.ensureEl().removeEventListener(key + '-tap', handler);
+ }
+
+ setLabel(key: string, text: string) {
+ this.ensureEl().setActionButtonProp(key, 'label', text);
+ }
+
+ setTitle(key: string, text: string) {
+ this.ensureEl().setActionButtonProp(key, 'title', text);
+ }
+
+ setEnabled(key: string, enabled: boolean) {
+ this.ensureEl().setActionButtonProp(key, 'enabled', enabled);
+ }
+
+ setIcon(key: string, icon: string) {
+ this.ensureEl().setActionButtonProp(key, 'icon', icon);
+ }
+
+ getActionDetails(action: string) {
+ const el = this.ensureEl();
+ return (
+ el.getActionDetails(action) ||
+ el.getActionDetails(this.plugin.getPluginName() + '~' + action)
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
index 231830bd..203784d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
@@ -17,9 +17,8 @@
import '../../../test/common-test-setup-karma.js';
import '../../change/gr-change-actions/gr-change-actions.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {resetPlugins} from '../../../test/test-utils.js';
-import {pluginLoader} from './gr-plugin-loader.js';
+import {getPluginLoader} from './gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
const basicFixture = fixtureFromElement('gr-change-actions');
@@ -45,7 +44,7 @@
pluginApi.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
// Mimic all plugins loaded.
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
changeActions = plugin.changeActions();
element = basicFixture.instantiate();
});
@@ -73,7 +72,7 @@
'http://test.com/plugins/testplugin/static/test.js');
changeActions = plugin.changeActions();
// Mimic all plugins loaded.
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
});
teardown(() => {
@@ -108,106 +107,91 @@
assertArraysEqual(element.primaryActionKeys, []);
});
- test('action buttons', done => {
+ test('action buttons', () => {
const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
const handler = sinon.spy();
changeActions.addTapListener(key, handler);
- flush(() => {
- MockInteractions.tap(element.shadowRoot
- .querySelector('[data-action-key="' + key + '"]'));
- assert(handler.calledOnce);
- changeActions.removeTapListener(key, handler);
- MockInteractions.tap(element.shadowRoot
- .querySelector('[data-action-key="' + key + '"]'));
- assert(handler.calledOnce);
- changeActions.remove(key);
- flush(() => {
- assert.isNull(element.shadowRoot
- .querySelector('[data-action-key="' + key + '"]'));
- done();
- });
- });
+ flush();
+ MockInteractions.tap(element.shadowRoot
+ .querySelector('[data-action-key="' + key + '"]'));
+ assert(handler.calledOnce);
+ changeActions.removeTapListener(key, handler);
+ MockInteractions.tap(element.shadowRoot
+ .querySelector('[data-action-key="' + key + '"]'));
+ assert(handler.calledOnce);
+ changeActions.remove(key);
+ flush();
+ assert.isNull(element.shadowRoot
+ .querySelector('[data-action-key="' + key + '"]'));
});
- test('action button properties', done => {
+ test('action button properties', () => {
const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
- flush(() => {
- const button = element.shadowRoot
- .querySelector('[data-action-key="' + key + '"]');
- assert.isOk(button);
- assert.equal(button.getAttribute('data-label'), 'Bork!');
- assert.isNotOk(button.disabled);
- changeActions.setLabel(key, 'Yo');
- changeActions.setTitle(key, 'Yo hint');
- changeActions.setEnabled(key, false);
- changeActions.setIcon(key, 'pupper');
- flush(() => {
- assert.equal(button.getAttribute('data-label'), 'Yo');
- assert.equal(button.getAttribute('title'), 'Yo hint');
- assert.isTrue(button.disabled);
- assert.equal(dom(button).querySelector('iron-icon').icon,
- 'gr-icons:pupper');
- done();
- });
- });
+ flush();
+ const button = element.shadowRoot
+ .querySelector('[data-action-key="' + key + '"]');
+ assert.isOk(button);
+ assert.equal(button.getAttribute('data-label'), 'Bork!');
+ assert.isNotOk(button.disabled);
+ changeActions.setLabel(key, 'Yo');
+ changeActions.setTitle(key, 'Yo hint');
+ changeActions.setEnabled(key, false);
+ changeActions.setIcon(key, 'pupper');
+ flush();
+ assert.equal(button.getAttribute('data-label'), 'Yo');
+ assert.equal(button.getAttribute('title'), 'Yo hint');
+ assert.isTrue(button.disabled);
+ assert.equal(button.querySelector('iron-icon').icon,
+ 'gr-icons:pupper');
});
- test('hide action buttons', done => {
+ test('hide action buttons', () => {
const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
- flush(() => {
- const button = element.shadowRoot
- .querySelector('[data-action-key="' + key + '"]');
- assert.isOk(button);
- assert.isFalse(button.hasAttribute('hidden'));
- changeActions.setActionHidden(
- changeActions.ActionType.REVISION, key, true);
- flush(() => {
- const button = element.shadowRoot
- .querySelector('[data-action-key="' + key + '"]');
- assert.isNotOk(button);
- done();
- });
- });
+ flush();
+ let button = element.shadowRoot
+ .querySelector('[data-action-key="' + key + '"]');
+ assert.isOk(button);
+ assert.isFalse(button.hasAttribute('hidden'));
+ changeActions.setActionHidden(
+ changeActions.ActionType.REVISION, key, true);
+ flush();
+ button = element.shadowRoot
+ .querySelector('[data-action-key="' + key + '"]');
+ assert.isNotOk(button);
});
- test('move action button to overflow', done => {
+ test('move action button to overflow', async () => {
const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
- flush(() => {
- assert.isTrue(element.$.moreActions.hidden);
- assert.isOk(element.shadowRoot
- .querySelector('[data-action-key="' + key + '"]'));
- changeActions.setActionOverflow(
- changeActions.ActionType.REVISION, key, true);
- flush(() => {
- assert.isNotOk(element.shadowRoot
- .querySelector('[data-action-key="' + key + '"]'));
- assert.isFalse(element.$.moreActions.hidden);
- assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
- done();
- });
- });
+ await flush();
+ assert.isTrue(element.$.moreActions.hidden);
+ assert.isOk(element.shadowRoot
+ .querySelector('[data-action-key="' + key + '"]'));
+ changeActions.setActionOverflow(
+ changeActions.ActionType.REVISION, key, true);
+ flush();
+ assert.isNotOk(element.shadowRoot
+ .querySelector('[data-action-key="' + key + '"]'));
+ assert.isFalse(element.$.moreActions.hidden);
+ assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
});
- test('change actions priority', done => {
+ test('change actions priority', () => {
const key1 =
changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
const key2 =
changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
- flush(() => {
- let buttons =
- dom(element.root).querySelectorAll('[data-action-key]');
- assert.equal(buttons[0].getAttribute('data-action-key'), key1);
- assert.equal(buttons[1].getAttribute('data-action-key'), key2);
- changeActions.setActionPriority(
- changeActions.ActionType.REVISION, key1, 10);
- flush(() => {
- buttons =
- dom(element.root).querySelectorAll('[data-action-key]');
- assert.equal(buttons[0].getAttribute('data-action-key'), key2);
- assert.equal(buttons[1].getAttribute('data-action-key'), key1);
- done();
- });
- });
+ flush();
+ let buttons =
+ element.root.querySelectorAll('[data-action-key]');
+ assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+ assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+ changeActions.setActionPriority(
+ changeActions.ActionType.REVISION, key1, 10);
+ flush();
+ buttons =
+ element.root.querySelectorAll('[data-action-key]');
+ assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+ assert.equal(buttons[1].getAttribute('data-action-key'), key1);
});
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
deleted file mode 100644
index 2fa9acc..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ /dev/null
@@ -1,74 +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.
- */
-
-/**
- * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
- */
-export class GrChangeReplyInterface {
- constructor(plugin, sharedApiElement) {
- this.plugin = plugin;
- this.sharedApiElement = sharedApiElement;
- }
-
- get _el() {
- return this.sharedApiElement.getElement(
- this.sharedApiElement.Element.REPLY_DIALOG);
- }
-
- getLabelValue(label) {
- return this._el.getLabelValue(label);
- }
-
- setLabelValue(label, value) {
- this._el.setLabelValue(label, value);
- }
-
- send(opt_includeComments) {
- this._el.send(opt_includeComments);
- }
-
- addReplyTextChangedCallback(handler) {
- const hookApi = this.plugin.hook('reply-text');
- const registeredHandler = e => handler(e.detail.value);
- hookApi.onAttached(el => {
- if (!el.content) { return; }
- el.content.addEventListener('value-changed', registeredHandler);
- });
- hookApi.onDetached(el => {
- if (!el.content) { return; }
- el.content.removeEventListener('value-changed', registeredHandler);
- });
- }
-
- addLabelValuesChangedCallback(handler) {
- const hookApi = this.plugin.hook('reply-label-scores');
- const registeredHandler = e => handler(e.detail);
- hookApi.onAttached(el => {
- if (!el.content) { return; }
- el.content.addEventListener('labels-changed', registeredHandler);
- });
-
- hookApi.onDetached(el => {
- if (!el.content) { return; }
- el.content.removeEventListener('labels-changed', registeredHandler);
- });
- }
-
- showMessage(message) {
- return this._el.setPluginMessage(message);
- }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
new file mode 100644
index 0000000..7069304
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.ts
@@ -0,0 +1,105 @@
+/**
+ * @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 {GrReplyDialog} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {PluginApi, TargetElement} from '../../plugins/gr-plugin-types';
+import {JsApiService} from './gr-js-api-types';
+
+// TODO(TS): maybe move interfaces\types to other files when convertion complete
+interface LabelsChangedDetail {
+ name: string;
+ value: string;
+}
+interface ValueChangedDetail {
+ value: string;
+}
+
+type ReplyChangedCallback = (text: string) => void;
+type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
+
+/**
+ * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
+ */
+export class GrChangeReplyInterface {
+ constructor(
+ readonly plugin: PluginApi,
+ readonly sharedApiElement: JsApiService
+ ) {}
+
+ get _el(): GrReplyDialog {
+ return (this.sharedApiElement.getElement(
+ TargetElement.REPLY_DIALOG
+ ) as unknown) as GrReplyDialog;
+ }
+
+ getLabelValue(label: string) {
+ return this._el.getLabelValue(label);
+ }
+
+ setLabelValue(label: string, value: string) {
+ this._el.setLabelValue(label, value);
+ }
+
+ send(includeComments?: boolean) {
+ this._el.send(includeComments);
+ }
+
+ addReplyTextChangedCallback(handler: ReplyChangedCallback) {
+ const hookApi = this.plugin.hook('reply-text');
+ const registeredHandler = (e: Event) => {
+ const ce = e as CustomEvent<ValueChangedDetail>;
+ handler(ce.detail.value);
+ };
+ hookApi.onAttached(el => {
+ if (!el.content) {
+ return;
+ }
+ el.content.addEventListener('value-changed', registeredHandler);
+ });
+ hookApi.onDetached(el => {
+ if (!el.content) {
+ return;
+ }
+ el.content.removeEventListener('value-changed', registeredHandler);
+ });
+ }
+
+ addLabelValuesChangedCallback(handler: LabelsChangedCallback) {
+ const hookApi = this.plugin.hook('reply-label-scores');
+ const registeredHandler = (e: Event) => {
+ const ce = e as CustomEvent<LabelsChangedDetail>;
+ handler(ce.detail);
+ };
+ hookApi.onAttached(el => {
+ if (!el.content) {
+ return;
+ }
+ el.content.addEventListener('labels-changed', registeredHandler);
+ });
+
+ hookApi.onDetached(el => {
+ if (!el.content) {
+ return;
+ }
+ el.content.removeEventListener('labels-changed', registeredHandler);
+ });
+ }
+
+ showMessage(message: string) {
+ return this._el.setPluginMessage(message);
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
deleted file mode 100644
index ce65755..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.js
+++ /dev/null
@@ -1,188 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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.
- */
-
-/**
- * This defines the Gerrit instance. All methods directly attached to Gerrit
- * should be defined or linked here.
- */
-
-import {pluginLoader} from './gr-plugin-loader.js';
-import {getRestAPI, send} from './gr-api-utils.js';
-import {appContext} from '../../../services/app-context.js';
-
-/**
- * Trigger the preinstalls for bundled plugins.
- * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
- */
-function flushPreinstalls() {
- if (window.Gerrit.flushPreinstalls) {
- window.Gerrit.flushPreinstalls();
- }
-}
-export const _testOnly_flushPreinstalls = flushPreinstalls;
-
-export function initGerritPluginApi() {
- window.Gerrit = window.Gerrit || {};
- flushPreinstalls();
- initGerritPluginsMethods(window.Gerrit);
- // Preloaded plugins should be installed after Gerrit.install() is set,
- // since plugin preloader substitutes Gerrit.install() temporarily.
- // (Gerrit.install() is set in initGerritPluginsMethods)
- pluginLoader.installPreloadedPlugins();
-}
-
-export function _testOnly_initGerritPluginApi() {
- initGerritPluginApi();
- return window.Gerrit;
-}
-
-export function deprecatedDelete(url, opt_callback) {
- console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
- return getRestAPI().send('DELETE', url)
- .then(response => {
- if (response.status !== 204) {
- return response.text().then(text => {
- if (text) {
- return Promise.reject(new Error(text));
- } else {
- return Promise.reject(new Error(response.status));
- }
- });
- }
- if (opt_callback) {
- opt_callback(response);
- }
- return response;
- });
-}
-
-function initGerritPluginsMethods(globalGerritObj) {
- /**
- * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
- * the documentation how to replace it accordingly.
- */
- globalGerritObj.css = function(rulesStr) {
- console.warn('Gerrit.css(rulesStr) is deprecated!',
- 'Use plugin.styles().css(rulesStr)');
- if (!globalGerritObj._customStyleSheet) {
- const styleEl = document.createElement('style');
- document.head.appendChild(styleEl);
- globalGerritObj._customStyleSheet = styleEl.sheet;
- }
-
- const name = '__pg_js_api_class_' +
- globalGerritObj._customStyleSheet.cssRules.length;
- globalGerritObj._customStyleSheet
- .insertRule('.' + name + '{' + rulesStr + '}', 0);
- return name;
- };
-
- globalGerritObj.install = function(callback, opt_version, opt_src) {
- pluginLoader.install(callback, opt_version, opt_src);
- };
-
- globalGerritObj.getLoggedIn = function() {
- console.warn('Gerrit.getLoggedIn() is deprecated! ' +
- 'Use plugin.restApi().getLoggedIn()');
- return document.createElement('gr-rest-api-interface').getLoggedIn();
- };
-
- globalGerritObj.get = function(url, callback) {
- console.warn('.get() is deprecated! Use plugin.restApi().get()');
- send('GET', url, callback);
- };
-
- globalGerritObj.post = function(url, payload, callback) {
- console.warn('.post() is deprecated! Use plugin.restApi().post()');
- send('POST', url, callback, payload);
- };
-
- globalGerritObj.put = function(url, payload, callback) {
- console.warn('.put() is deprecated! Use plugin.restApi().put()');
- send('PUT', url, callback, payload);
- };
-
- globalGerritObj.delete = function(url, opt_callback) {
- deprecatedDelete(url, opt_callback);
- };
-
- globalGerritObj.awaitPluginsLoaded = function() {
- return pluginLoader.awaitPluginsLoaded();
- };
-
- // TODO(taoalpha): consider removing these proxy methods
- // and using pluginLoader directly
- globalGerritObj._loadPlugins = function(plugins, opt_option) {
- pluginLoader.loadPlugins(plugins, opt_option);
- };
-
- globalGerritObj._arePluginsLoaded = function() {
- return pluginLoader.arePluginsLoaded();
- };
-
- globalGerritObj._isPluginPreloaded = function(url) {
- return pluginLoader.isPluginPreloaded(url);
- };
-
- globalGerritObj._isPluginEnabled = function(pathOrUrl) {
- return pluginLoader.isPluginEnabled(pathOrUrl);
- };
-
- globalGerritObj._isPluginLoaded = function(pathOrUrl) {
- return pluginLoader.isPluginLoaded(pathOrUrl);
- };
-
- const eventEmitter = appContext.eventEmitter;
-
- // TODO(taoalpha): List all internal supported event names.
- // Also convert this to inherited class once we move Gerrit to class.
- globalGerritObj._eventEmitter = eventEmitter;
- ['addListener',
- 'dispatch',
- 'emit',
- 'off',
- 'on',
- 'once',
- 'removeAllListeners',
- 'removeListener',
- ].forEach(method => {
- /**
- * Enabling EventEmitter interface on Gerrit.
- *
- * This will enable to signal across different parts of js code without relying on DOM,
- * including core to core, plugin to plugin and also core to plugin.
- *
- * @example
- *
- * // Emit this event from pluginA
- * Gerrit.install(pluginA => {
- * fetch("some-api").then(() => {
- * Gerrit.on("your-special-event", {plugin: pluginA});
- * });
- * });
- *
- * // Listen on your-special-event from pluignB
- * Gerrit.install(pluginB => {
- * Gerrit.on("your-special-event", ({plugin}) => {
- * // do something, plugin is pluginA
- * });
- * });
- */
- globalGerritObj[method] = eventEmitter[method]
- .bind(eventEmitter);
- });
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
new file mode 100644
index 0000000..37ac354
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit.ts
@@ -0,0 +1,276 @@
+/**
+ * @license
+ * Copyright (C) 2019 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.
+ */
+
+/**
+ * This defines the Gerrit instance. All methods directly attached to Gerrit
+ * should be defined or linked here.
+ */
+import {
+ getPluginLoader,
+ PluginOptionMap,
+ PluginLoader,
+} from './gr-plugin-loader';
+import {getRestAPI, send} from './gr-api-utils';
+import {appContext} from '../../../services/app-context';
+import {PluginApi} from '../../plugins/gr-plugin-types';
+import {HttpMethod} from '../../../constants/constants';
+import {RequestPayload} from '../../../types/common';
+import {
+ EventCallback,
+ EventEmitterService,
+} from '../../../services/gr-event-interface/gr-event-interface';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getRootElement} from '../../../scripts/rootElement';
+import {GrPluginEndpoints} from './gr-plugin-endpoints';
+import {rangesEqual} from '../../diff/gr-diff/gr-diff-utils';
+import {SUGGESTIONS_PROVIDERS_USERS_TYPES} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import {CoverageType} from '../../../types/types';
+import {RevisionInfo} from '../revision-info/revision-info';
+
+export interface GerritGlobal extends EventEmitterService {
+ flushPreinstalls?(): void;
+ css(rule: string): string;
+ install(
+ callback: (plugin: PluginApi) => void,
+ opt_version?: string,
+ src?: string
+ ): void;
+ getLoggedIn(): Promise<boolean>;
+ get(url: string, callback?: (response: unknown) => void): void;
+ post(
+ url: string,
+ payload?: RequestPayload,
+ callback?: (response: unknown) => void
+ ): void;
+ put(
+ url: string,
+ payload?: RequestPayload,
+ callback?: (response: unknown) => void
+ ): void;
+ delete(url: string, callback?: (response: unknown) => void): void;
+ isPluginLoaded(pathOrUrl: string): boolean;
+ awaitPluginsLoaded(): Promise<unknown>;
+ _loadPlugins(plugins: string[], opts: PluginOptionMap): void;
+ _arePluginsLoaded(): boolean;
+ _isPluginPreloaded(pathOrUrl: string): boolean;
+ _isPluginEnabled(pathOrUrl: string): boolean;
+ _isPluginLoaded(pathOrUrl: string): boolean;
+ _eventEmitter: EventEmitterService;
+ _customStyleSheet: CSSStyleSheet;
+
+ // exposed methods
+ Nav: typeof GerritNav;
+ Auth: typeof appContext.authService;
+ getRootElement: typeof getRootElement;
+ _pluginLoader: PluginLoader;
+ _endpoints: GrPluginEndpoints;
+ slotToContent(slot: unknown): unknown;
+ rangesEqual: typeof rangesEqual;
+ SUGGESTIONS_PROVIDERS_USERS_TYPES: typeof SUGGESTIONS_PROVIDERS_USERS_TYPES;
+ CoverageType: typeof CoverageType;
+ RevisionInfo: typeof RevisionInfo;
+}
+
+/**
+ * Trigger the preinstalls for bundled plugins.
+ * This needs to happen before Gerrit as plugin bundle overrides the Gerrit.
+ */
+function flushPreinstalls() {
+ const Gerrit = window.Gerrit;
+ if (Gerrit?.flushPreinstalls) {
+ Gerrit.flushPreinstalls();
+ }
+}
+export const _testOnly_flushPreinstalls = flushPreinstalls;
+
+export function initGerritPluginApi() {
+ window.Gerrit = window.Gerrit || {};
+ flushPreinstalls();
+ initGerritPluginsMethods(window.Gerrit as GerritGlobal);
+ // Preloaded plugins should be installed after Gerrit.install() is set,
+ // since plugin preloader substitutes Gerrit.install() temporarily.
+ // (Gerrit.install() is set in initGerritPluginsMethods)
+ getPluginLoader().installPreloadedPlugins();
+}
+
+export function _testOnly_initGerritPluginApi(): GerritGlobal {
+ window.Gerrit = window.Gerrit || {};
+ initGerritPluginApi();
+ return window.Gerrit as GerritGlobal;
+}
+
+export function deprecatedDelete(
+ url: string,
+ callback?: (response: Response) => void
+) {
+ console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
+ return getRestAPI()
+ .send(HttpMethod.DELETE, url)
+ .then(response => {
+ if (response.status !== 204) {
+ return response.text().then(text => {
+ if (text) {
+ return Promise.reject(new Error(text));
+ } else {
+ return Promise.reject(new Error(`${response.status}`));
+ }
+ });
+ }
+ if (callback) callback(response);
+ return response;
+ });
+}
+
+function initGerritPluginsMethods(globalGerritObj: GerritGlobal) {
+ /**
+ * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
+ * the documentation how to replace it accordingly.
+ */
+ globalGerritObj.css = (rulesStr: string) => {
+ console.warn(
+ 'Gerrit.css(rulesStr) is deprecated!',
+ 'Use plugin.styles().css(rulesStr)'
+ );
+ if (!globalGerritObj._customStyleSheet) {
+ const styleEl = document.createElement('style');
+ document.head.appendChild(styleEl);
+ globalGerritObj._customStyleSheet = styleEl.sheet!;
+ }
+
+ const name = `__pg_js_api_class_${globalGerritObj._customStyleSheet.cssRules.length}`;
+ globalGerritObj._customStyleSheet.insertRule(
+ '.' + name + '{' + rulesStr + '}',
+ 0
+ );
+ return name;
+ };
+
+ globalGerritObj.install = (callback, opt_version, opt_src) => {
+ getPluginLoader().install(callback, opt_version, opt_src);
+ };
+
+ globalGerritObj.getLoggedIn = () => {
+ console.warn(
+ 'Gerrit.getLoggedIn() is deprecated! ' +
+ 'Use plugin.restApi().getLoggedIn()'
+ );
+ return document.createElement('gr-rest-api-interface').getLoggedIn();
+ };
+
+ globalGerritObj.get = (
+ url: string,
+ callback?: (response: unknown) => void
+ ) => {
+ console.warn('.get() is deprecated! Use plugin.restApi().get()');
+ send(HttpMethod.GET, url, callback);
+ };
+
+ globalGerritObj.post = (
+ url: string,
+ payload?: RequestPayload,
+ callback?: (response: unknown) => void
+ ) => {
+ console.warn('.post() is deprecated! Use plugin.restApi().post()');
+ send(HttpMethod.POST, url, callback, payload);
+ };
+
+ globalGerritObj.put = (
+ url: string,
+ payload?: RequestPayload,
+ callback?: (response: unknown) => void
+ ) => {
+ console.warn('.put() is deprecated! Use plugin.restApi().put()');
+ send(HttpMethod.PUT, url, callback, payload);
+ };
+
+ globalGerritObj.delete = (
+ url: string,
+ callback?: (response: Response) => void
+ ) => {
+ deprecatedDelete(url, callback);
+ };
+
+ globalGerritObj.awaitPluginsLoaded = () => {
+ return getPluginLoader().awaitPluginsLoaded();
+ };
+
+ // TODO(taoalpha): consider removing these proxy methods
+ // and using getPluginLoader() directly
+ globalGerritObj._loadPlugins = (plugins, opt_option) => {
+ getPluginLoader().loadPlugins(plugins, opt_option);
+ };
+
+ globalGerritObj._arePluginsLoaded = () => {
+ return getPluginLoader().arePluginsLoaded();
+ };
+
+ globalGerritObj._isPluginPreloaded = url => {
+ return getPluginLoader().isPluginPreloaded(url);
+ };
+
+ globalGerritObj._isPluginEnabled = pathOrUrl => {
+ return getPluginLoader().isPluginEnabled(pathOrUrl);
+ };
+
+ globalGerritObj._isPluginLoaded = pathOrUrl => {
+ return getPluginLoader().isPluginLoaded(pathOrUrl);
+ };
+
+ const eventEmitter = appContext.eventEmitter;
+
+ // TODO(taoalpha): List all internal supported event names.
+ // Also convert this to inherited class once we move Gerrit to class.
+ globalGerritObj._eventEmitter = eventEmitter;
+ /**
+ * Enabling EventEmitter interface on Gerrit.
+ *
+ * This will enable to signal across different parts of js code without relying on DOM,
+ * including core to core, plugin to plugin and also core to plugin.
+ *
+ * @example
+ *
+ * // Emit this event from pluginA
+ * Gerrit.install(pluginA => {
+ * fetch("some-api").then(() => {
+ * Gerrit.on("your-special-event", {plugin: pluginA});
+ * });
+ * });
+ *
+ * // Listen on your-special-event from pluignB
+ * Gerrit.install(pluginB => {
+ * Gerrit.on("your-special-event", ({plugin}) => {
+ * // do something, plugin is pluginA
+ * });
+ * });
+ */
+ globalGerritObj.addListener = (eventName: string, cb: EventCallback) =>
+ eventEmitter.addListener(eventName, cb);
+ globalGerritObj.dispatch = (eventName: string, detail: any) =>
+ eventEmitter.dispatch(eventName, detail);
+ globalGerritObj.emit = (eventName: string, detail: any) =>
+ eventEmitter.emit(eventName, detail);
+ globalGerritObj.off = (eventName: string, cb: EventCallback) =>
+ eventEmitter.off(eventName, cb);
+ globalGerritObj.on = (eventName: string, cb: EventCallback) =>
+ eventEmitter.on(eventName, cb);
+ globalGerritObj.once = (eventName: string, cb: EventCallback) =>
+ eventEmitter.once(eventName, cb);
+ globalGerritObj.removeAllListeners = (eventName: string) =>
+ eventEmitter.removeAllListeners(eventName);
+ globalGerritObj.removeListener = (eventName: string, cb: EventCallback) =>
+ eventEmitter.removeListener(eventName, cb);
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
index e5b32f7..9312f1b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-gerrit_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-js-api-interface.js';
-import {pluginLoader} from './gr-plugin-loader.js';
+import {getPluginLoader} from './gr-plugin-loader.js';
import {resetPlugins} from '../../../test/test-utils.js';
import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
@@ -28,10 +28,11 @@
suite('gr-gerrit tests', () => {
let element;
+ let clock;
let sendStub;
setup(() => {
- window.clock = sinon.useFakeTimers();
+ clock = sinon.useFakeTimers();
sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
stub('gr-rest-api-interface', {
@@ -46,16 +47,16 @@
});
teardown(() => {
- window.clock.restore();
+ clock.restore();
element._removeEventCallbacks();
resetPlugins();
});
suite('proxy methods', () => {
- test('Gerrit._isPluginEnabled proxy to pluginLoader', () => {
+ test('Gerrit._isPluginEnabled proxy to getPluginLoader()', () => {
const stubFn = sinon.stub();
sinon.stub(
- pluginLoader,
+ getPluginLoader(),
'isPluginEnabled')
.callsFake((...args) => stubFn(...args)
);
@@ -63,20 +64,20 @@
assert.isTrue(stubFn.calledWith('test_plugin'));
});
- test('Gerrit._isPluginLoaded proxy to pluginLoader', () => {
+ test('Gerrit._isPluginLoaded proxy to getPluginLoader()', () => {
const stubFn = sinon.stub();
sinon.stub(
- pluginLoader,
+ getPluginLoader(),
'isPluginLoaded')
.callsFake((...args) => stubFn(...args));
pluginApi._isPluginLoaded('test_plugin');
assert.isTrue(stubFn.calledWith('test_plugin'));
});
- test('Gerrit._isPluginPreloaded proxy to pluginLoader', () => {
+ test('Gerrit._isPluginPreloaded proxy to getPluginLoader()', () => {
const stubFn = sinon.stub();
sinon.stub(
- pluginLoader,
+ getPluginLoader(),
'isPluginPreloaded')
.callsFake((...args) => stubFn(...args));
pluginApi._isPluginPreloaded('test_plugin');
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
deleted file mode 100644
index e215bc9..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.js
+++ /dev/null
@@ -1,329 +0,0 @@
-/**
- * @license
- * 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.
- */
-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 {pluginLoader} from './gr-plugin-loader.js';
-import {patchNumEquals} from '../../../utils/patch-set-util.js';
-
-// Note: for new events, naming convention should be: `a-b`
-const EventType = {
- HISTORY: 'history',
- LABEL_CHANGE: 'labelchange',
- SHOW_CHANGE: 'showchange',
- SUBMIT_CHANGE: 'submitchange',
- SHOW_REVISION_ACTIONS: 'show-revision-actions',
- COMMIT_MSG_EDIT: 'commitmsgedit',
- COMMENT: 'comment',
- REVERT: 'revert',
- REVERT_SUBMISSION: 'revert_submission',
- POST_REVERT: 'postrevert',
- ANNOTATE_DIFF: 'annotatediff',
- ADMIN_MENU_LINKS: 'admin-menu-links',
- HIGHLIGHTJS_LOADED: 'highlightjs-loaded',
-};
-
-const Element = {
- CHANGE_ACTIONS: 'changeactions',
- REPLY_DIALOG: 'replydialog',
-};
-
-/**
- * @extends PolymerElement
- */
-class GrJsApiInterface extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get is() { return 'gr-js-api-interface'; }
-
- constructor() {
- super();
- this.Element = Element;
- this.EventType = EventType;
- }
-
- static get properties() {
- return {
- _elements: {
- type: Object,
- value: {}, // Shared across all instances.
- },
- _eventCallbacks: {
- type: Object,
- value: {}, // Shared across all instances.
- },
- };
- }
-
- handleEvent(type, detail) {
- pluginLoader.awaitPluginsLoaded().then(() => {
- switch (type) {
- case EventType.HISTORY:
- this._handleHistory(detail);
- break;
- case EventType.SHOW_CHANGE:
- this._handleShowChange(detail);
- break;
- case EventType.COMMENT:
- this._handleComment(detail);
- break;
- case EventType.LABEL_CHANGE:
- this._handleLabelChange(detail);
- break;
- case EventType.SHOW_REVISION_ACTIONS:
- this._handleShowRevisionActions(detail);
- break;
- case EventType.HIGHLIGHTJS_LOADED:
- this._handleHighlightjsLoaded(detail);
- break;
- default:
- console.warn('handleEvent called with unsupported event type:',
- type);
- break;
- }
- });
- }
-
- addElement(key, el) {
- this._elements[key] = el;
- }
-
- getElement(key) {
- return this._elements[key];
- }
-
- addEventCallback(eventName, callback) {
- if (!this._eventCallbacks[eventName]) {
- this._eventCallbacks[eventName] = [];
- }
- this._eventCallbacks[eventName].push(callback);
- }
-
- canSubmitChange(change, revision) {
- const submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
- const cancelSubmit = submitCallbacks.some(callback => {
- try {
- return callback(change, revision) === false;
- } catch (err) {
- console.error(err);
- }
- return false;
- });
-
- return !cancelSubmit;
- }
-
- _removeEventCallbacks() {
- for (const k in EventType) {
- if (!EventType.hasOwnProperty(k)) { continue; }
- this._eventCallbacks[EventType[k]] = [];
- }
- }
-
- _handleHistory(detail) {
- for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
- try {
- cb(detail.path);
- } catch (err) {
- console.error(err);
- }
- }
- }
-
- _handleShowChange(detail) {
- // Note (issue 8221) Shallow clone the change object and add a mergeable
- // getter with deprecation warning. This makes the change detail appear as
- // though SKIP_MERGEABLE was not set, so that plugins that expect it can
- // still access.
- //
- // This clone and getter can be removed after plugins migrate to use
- // info.mergeable.
- //
- // assign on getter with existing property will report error
- // see Issue: 12286
- const change = Object.assign({}, detail.change, {
- get mergeable() {
- console.warn('Accessing change.mergeable from SHOW_CHANGE is ' +
- 'deprecated! Use info.mergeable instead.');
- return detail.info && detail.info.mergeable;
- },
- });
- const patchNum = detail.patchNum;
- const info = detail.info;
-
- let revision;
- for (const rev of Object.values(change.revisions || {})) {
- if (patchNumEquals(rev._number, patchNum)) {
- revision = rev;
- break;
- }
- }
-
- for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
- try {
- cb(change, revision, info);
- } catch (err) {
- console.error(err);
- }
- }
- }
-
- /**
- * @param {!{change: !Object, revisionActions: !Object}} detail
- */
- _handleShowRevisionActions(detail) {
- const registeredCallbacks = this._getEventCallbacks(
- EventType.SHOW_REVISION_ACTIONS
- );
- for (const cb of registeredCallbacks) {
- try {
- cb(detail.revisionActions, detail.change);
- } catch (err) {
- console.error(err);
- }
- }
- }
-
- handleCommitMessage(change, msg) {
- for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
- try {
- cb(change, msg);
- } catch (err) {
- console.error(err);
- }
- }
- }
-
- _handleComment(detail) {
- for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
- try {
- cb(detail.node);
- } catch (err) {
- console.error(err);
- }
- }
- }
-
- _handleLabelChange(detail) {
- for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
- try {
- cb(detail.change);
- } catch (err) {
- console.error(err);
- }
- }
- }
-
- _handleHighlightjsLoaded(detail) {
- for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
- try {
- cb(detail.hljs);
- } catch (err) {
- console.error(err);
- }
- }
- }
-
- modifyRevertMsg(change, revertMsg, origMsg) {
- for (const cb of this._getEventCallbacks(EventType.REVERT)) {
- try {
- revertMsg = cb(change, revertMsg, origMsg);
- } catch (err) {
- console.error(err);
- }
- }
- return revertMsg;
- }
-
- modifyRevertSubmissionMsg(change, revertSubmissionMsg, origMsg) {
- for (const cb of this._getEventCallbacks(EventType.REVERT_SUBMISSION)) {
- try {
- revertSubmissionMsg = cb(change, revertSubmissionMsg, origMsg);
- } catch (err) {
- console.error(err);
- }
- }
- return revertSubmissionMsg;
- }
-
- getDiffLayers(path, changeNum, patchNum) {
- const layers = [];
- for (const annotationApi of
- this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
- try {
- const layer = annotationApi.getLayer(path, changeNum, patchNum);
- layers.push(layer);
- } catch (err) {
- console.error(err);
- }
- }
- return layers;
- }
-
- disposeDiffLayers(path) {
- for (const annotationApi of
- this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
- try {
- annotationApi.disposeLayer(path);
- } catch (err) {
- console.error(err);
- }
- }
- }
-
- /**
- * Retrieves coverage data possibly provided by a plugin.
- *
- * Will wait for plugins to be loaded. If multiple plugins offer a coverage
- * provider, the first one is returned. If no plugin offers a coverage provider,
- * will resolve to null.
- *
- * @return {!Promise<?GrAnnotationActionsInterface>}
- */
- getCoverageAnnotationApi() {
- return pluginLoader.awaitPluginsLoaded()
- .then(() => this._getEventCallbacks(EventType.ANNOTATE_DIFF)
- .find(api => api.getCoverageProvider()));
- }
-
- getAdminMenuLinks() {
- const links = [];
- for (const adminApi of
- this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
- links.push(...adminApi.getMenuLinks());
- }
- return links;
- }
-
- getLabelValuesPostRevert(change) {
- let labels = {};
- for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
- try {
- labels = cb(change);
- } catch (err) {
- console.error(err);
- }
- }
- return labels;
- }
-
- _getEventCallbacks(type) {
- return this._eventCallbacks[type] || [];
- }
-}
-
-customElements.define(GrJsApiInterface.is, GrJsApiInterface);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
new file mode 100644
index 0000000..736fac9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface-element.ts
@@ -0,0 +1,328 @@
+/**
+ * @license
+ * 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.
+ */
+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 {getPluginLoader} from './gr-plugin-loader';
+import {patchNumEquals} from '../../../utils/patch-set-util';
+import {customElement} from '@polymer/decorators';
+import {
+ ChangeInfo,
+ LabelNameToValuesMap,
+ RevisionInfo,
+} from '../../../types/common';
+import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {GrAdminApi, MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
+import {
+ JsApiService,
+ EventCallback,
+ ShowChangeDetail,
+ ShowRevisionActionsDetail,
+} from './gr-js-api-types';
+import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {DiffLayer, HighlightJS} from '../../../types/types';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
+
+const elements: {[key: string]: HTMLElement} = {};
+const eventCallbacks: {[key: string]: EventCallback[]} = {};
+
+@customElement('gr-js-api-interface')
+export class GrJsApiInterface
+ extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+ implements JsApiService {
+ handleEvent(type: EventType, detail: any) {
+ getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ switch (type) {
+ case EventType.HISTORY:
+ this._handleHistory(detail);
+ break;
+ case EventType.SHOW_CHANGE:
+ this._handleShowChange(detail);
+ break;
+ case EventType.COMMENT:
+ this._handleComment(detail);
+ break;
+ case EventType.LABEL_CHANGE:
+ this._handleLabelChange(detail);
+ break;
+ case EventType.SHOW_REVISION_ACTIONS:
+ this._handleShowRevisionActions(detail);
+ break;
+ case EventType.HIGHLIGHTJS_LOADED:
+ this._handleHighlightjsLoaded(detail);
+ break;
+ default:
+ console.warn(
+ 'handleEvent called with unsupported event type:',
+ type
+ );
+ break;
+ }
+ });
+ }
+
+ addElement(key: TargetElement, el: HTMLElement) {
+ elements[key] = el;
+ }
+
+ getElement(key: TargetElement) {
+ return elements[key];
+ }
+
+ addEventCallback(eventName: EventType, callback: EventCallback) {
+ if (!eventCallbacks[eventName]) {
+ eventCallbacks[eventName] = [];
+ }
+ eventCallbacks[eventName].push(callback);
+ }
+
+ canSubmitChange(change: ChangeInfo, revision?: RevisionInfo | null) {
+ const submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
+ const cancelSubmit = submitCallbacks.some(callback => {
+ try {
+ return callback(change, revision) === false;
+ } catch (err) {
+ console.error(err);
+ }
+ return false;
+ });
+
+ return !cancelSubmit;
+ }
+
+ /** For testing only. */
+ _removeEventCallbacks() {
+ for (const type of Object.values(EventType)) {
+ eventCallbacks[type] = [];
+ }
+ }
+
+ // TODO(TS): The HISTORY event and its handler seem unused.
+ _handleHistory(detail: {path: string}) {
+ for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
+ try {
+ cb(detail.path);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }
+
+ _handleShowChange(detail: ShowChangeDetail) {
+ // Note (issue 8221) Shallow clone the change object and add a mergeable
+ // getter with deprecation warning. This makes the change detail appear as
+ // though SKIP_MERGEABLE was not set, so that plugins that expect it can
+ // still access.
+ //
+ // This clone and getter can be removed after plugins migrate to use
+ // info.mergeable.
+ //
+ // assign on getter with existing property will report error
+ // see Issue: 12286
+ const change = {
+ ...detail.change,
+ get mergeable() {
+ console.warn(
+ 'Accessing change.mergeable from SHOW_CHANGE is ' +
+ 'deprecated! Use info.mergeable instead.'
+ );
+ return detail.info && detail.info.mergeable;
+ },
+ };
+ const patchNum = detail.patchNum;
+ const info = detail.info;
+
+ let revision;
+ for (const rev of Object.values(change.revisions || {})) {
+ if (patchNumEquals(rev._number, patchNum)) {
+ revision = rev;
+ break;
+ }
+ }
+
+ for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
+ try {
+ cb(change, revision, info);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }
+
+ _handleShowRevisionActions(detail: ShowRevisionActionsDetail) {
+ const registeredCallbacks = this._getEventCallbacks(
+ EventType.SHOW_REVISION_ACTIONS
+ );
+ for (const cb of registeredCallbacks) {
+ try {
+ cb(detail.revisionActions, detail.change);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }
+
+ handleCommitMessage(change: ChangeInfo | ParsedChangeInfo, msg: string) {
+ for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
+ try {
+ cb(change, msg);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }
+
+ // TODO(TS): The COMMENT event and its handler seem unused.
+ _handleComment(detail: {node: Node}) {
+ for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
+ try {
+ cb(detail.node);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }
+
+ _handleLabelChange(detail: {change: ChangeInfo}) {
+ for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
+ try {
+ cb(detail.change);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }
+
+ _handleHighlightjsLoaded(detail: {hljs: HighlightJS}) {
+ for (const cb of this._getEventCallbacks(EventType.HIGHLIGHTJS_LOADED)) {
+ try {
+ cb(detail.hljs);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }
+
+ modifyRevertMsg(change: ChangeInfo, revertMsg: string, origMsg: string) {
+ for (const cb of this._getEventCallbacks(EventType.REVERT)) {
+ try {
+ revertMsg = cb(change, revertMsg, origMsg) as string;
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ return revertMsg;
+ }
+
+ modifyRevertSubmissionMsg(
+ change: ChangeInfo,
+ revertSubmissionMsg: string,
+ origMsg: string
+ ) {
+ for (const cb of this._getEventCallbacks(EventType.REVERT_SUBMISSION)) {
+ try {
+ revertSubmissionMsg = cb(
+ change,
+ revertSubmissionMsg,
+ origMsg
+ ) as string;
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ return revertSubmissionMsg;
+ }
+
+ getDiffLayers(path: string, changeNum: number) {
+ const layers: DiffLayer[] = [];
+ for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+ const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
+ try {
+ const layer = annotationApi.getLayer(path, changeNum);
+ layers.push(layer);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ return layers;
+ }
+
+ disposeDiffLayers(path: string) {
+ for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
+ try {
+ const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
+ annotationApi.disposeLayer(path);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ }
+
+ /**
+ * Retrieves coverage data possibly provided by a plugin.
+ *
+ * Will wait for plugins to be loaded. If multiple plugins offer a coverage
+ * provider, the first one is returned. If no plugin offers a coverage provider,
+ * will resolve to null.
+ */
+ getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]> {
+ return getPluginLoader()
+ .awaitPluginsLoaded()
+ .then(() => {
+ const providers: GrAnnotationActionsInterface[] = [];
+ this._getEventCallbacks(EventType.ANNOTATE_DIFF).forEach(cb => {
+ const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
+ const provider = annotationApi.getCoverageProvider();
+ if (provider) providers.push(annotationApi);
+ });
+ return providers;
+ });
+ }
+
+ getAdminMenuLinks(): MenuLink[] {
+ const links: MenuLink[] = [];
+ for (const cb of this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
+ const adminApi = (cb as unknown) as GrAdminApi;
+ links.push(...adminApi.getMenuLinks());
+ }
+ return links;
+ }
+
+ getLabelValuesPostRevert(change?: ChangeInfo): LabelNameToValuesMap {
+ let labels: LabelNameToValuesMap = {};
+ for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
+ try {
+ labels = cb(change);
+ } catch (err) {
+ console.error(err);
+ }
+ }
+ return labels;
+ }
+
+ _getEventCallbacks(type: EventType) {
+ return eventCallbacks[type] || [];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-js-api-interface': JsApiService & Element;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
deleted file mode 100644
index 6f0ade9..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ /dev/null
@@ -1,34 +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 '../gr-rest-api-interface/gr-rest-api-interface.js';
-import './gr-js-api-interface-element.js';
-import './gr-public-js-api.js';
-import './gr-gerrit.js';
-
-/*
- Note: the order matters as files depend on each other.
- 1. gr-api-utils will be used in multiple files below.
- 2. gr-gerrit depends on gr-plugin-loader, gr-public-js-api and
- also gr-plugin-endpoints
- 3. gr-public-js-api depends on gr-plugin-rest-api
-*/
-/*
- FIXME(polymer-modulizer): the above comments were extracted
- from HTML and may be out of place here. Review them and
- then delete this comment!
-*/
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
new file mode 100644
index 0000000..b9a6ff4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.ts
@@ -0,0 +1,20 @@
+/**
+ * @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 '../gr-rest-api-interface/gr-rest-api-interface';
+import './gr-js-api-interface-element';
+import './gr-public-js-api';
+import './gr-gerrit';
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
index 589c9b3..b5c4e48 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.js
@@ -19,9 +19,9 @@
import './gr-js-api-interface.js';
import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
-import {GrPluginActionContext} from './gr-plugin-action-context.js';
+import {EventType} from '../../plugins/gr-plugin-types.js';
import {PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
-import {pluginLoader} from './gr-plugin-loader.js';
+import {getPluginLoader} from './gr-plugin-loader.js';
import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
import {stubBaseUrl} from '../../../test/test-utils.js';
@@ -36,13 +36,14 @@
let getResponseObjectStub;
let sendStub;
+ let clock;
const throwErrFn = function() {
throw Error('Unfortunately, this handler has stopped');
};
setup(() => {
- window.clock = sinon.useFakeTimers();
+ clock = sinon.useFakeTimers();
getResponseObjectStub = sinon.stub().returns(Promise.resolve());
sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
@@ -59,11 +60,11 @@
errorStub = sinon.stub(console, 'error');
pluginApi.install(p => { plugin = p; }, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
- pluginLoader.loadPlugins([]);
+ getPluginLoader().loadPlugins([]);
});
teardown(() => {
- window.clock.restore();
+ clock.restore();
element._removeEventCallbacks();
plugin = null;
});
@@ -182,81 +183,89 @@
});
});
- test('history event', done => {
- plugin.on(element.EventType.HISTORY, throwErrFn);
- plugin.on(element.EventType.HISTORY, path => {
- assert.equal(path, '/path/to/awesomesauce');
- assert.isTrue(errorStub.calledOnce);
- done();
- });
- element.handleEvent(element.EventType.HISTORY,
- {path: '/path/to/awesomesauce'});
+ test('history event', async () => {
+ let resolve;
+ const promise = new Promise(r => resolve = r);
+ plugin.on(EventType.HISTORY, throwErrFn);
+ plugin.on(EventType.HISTORY, resolve);
+ element.handleEvent(EventType.HISTORY, {path: '/path/to/awesomesauce'});
+ const path = await promise;
+ assert.equal(path, '/path/to/awesomesauce');
+ assert.isTrue(errorStub.calledOnce);
});
- test('showchange event', done => {
+ test('showchange event', async () => {
+ let resolve;
+ const promise = new Promise(r => resolve = r);
const testChange = {
_number: 42,
revisions: {def: {_number: 2}, abc: {_number: 1}},
};
- const expectedChange = Object.assign({mergeable: false}, testChange);
- plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
- plugin.on(element.EventType.SHOW_CHANGE, (change, revision, info) => {
- assert.deepEqual(change, expectedChange);
- assert.deepEqual(revision, testChange.revisions.abc);
- assert.deepEqual(info, {mergeable: false});
- assert.isTrue(errorStub.calledOnce);
- done();
+ const expectedChange = {mergeable: false, ...testChange};
+ plugin.on(EventType.SHOW_CHANGE, throwErrFn);
+ plugin.on(EventType.SHOW_CHANGE, (change, revision, info) => {
+ resolve({change, revision, info});
});
- element.handleEvent(element.EventType.SHOW_CHANGE,
+ element.handleEvent(EventType.SHOW_CHANGE,
{change: testChange, patchNum: 1, info: {mergeable: false}});
+
+ const {change, revision, info} = await promise;
+ assert.deepEqual(change, expectedChange);
+ assert.deepEqual(revision, testChange.revisions.abc);
+ assert.deepEqual(info, {mergeable: false});
+ assert.isTrue(errorStub.calledOnce);
});
- test('show-revision-actions event', done => {
+ test('show-revision-actions event', async () => {
+ let resolve;
+ const promise = new Promise(r => resolve = r);
const testChange = {
_number: 42,
revisions: {def: {_number: 2}, abc: {_number: 1}},
};
- plugin.on(element.EventType.SHOW_REVISION_ACTIONS, throwErrFn);
- plugin.on(element.EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
- assert.deepEqual(change, testChange);
- assert.deepEqual(actions, {test: {}});
- assert.isTrue(errorStub.calledOnce);
- done();
+ plugin.on(EventType.SHOW_REVISION_ACTIONS, throwErrFn);
+ plugin.on(EventType.SHOW_REVISION_ACTIONS, (actions, change) => {
+ resolve({change, actions});
});
- element.handleEvent(element.EventType.SHOW_REVISION_ACTIONS,
+ element.handleEvent(EventType.SHOW_REVISION_ACTIONS,
{change: testChange, revisionActions: {test: {}}});
+
+ const {change, actions} = await promise;
+ assert.deepEqual(change, testChange);
+ assert.deepEqual(actions, {test: {}});
+ assert.isTrue(errorStub.calledOnce);
});
- test('handleEvent awaits plugins load', done => {
+ test('handleEvent awaits plugins load', async () => {
const testChange = {
_number: 42,
revisions: {def: {_number: 2}, abc: {_number: 1}},
};
const spy = sinon.spy();
- pluginLoader.loadPlugins(['plugins/test.html']);
- plugin.on(element.EventType.SHOW_CHANGE, spy);
- element.handleEvent(element.EventType.SHOW_CHANGE,
+ getPluginLoader().loadPlugins(['plugins/test.html']);
+ plugin.on(EventType.SHOW_CHANGE, spy);
+ element.handleEvent(EventType.SHOW_CHANGE,
{change: testChange, patchNum: 1});
assert.isFalse(spy.called);
// Timeout on loading plugins
- window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+ clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
- flush(() => {
- assert.isTrue(spy.called);
- done();
- });
+ await flush();
+ assert.isTrue(spy.called);
});
- test('comment event', done => {
+ test('comment event', async () => {
+ let resolve;
+ const promise = new Promise(r => resolve = r);
const testCommentNode = {foo: 'bar'};
- plugin.on(element.EventType.COMMENT, throwErrFn);
- plugin.on(element.EventType.COMMENT, commentNode => {
- assert.deepEqual(commentNode, testCommentNode);
- assert.isTrue(errorStub.calledOnce);
- done();
- });
- element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
+ plugin.on(EventType.COMMENT, throwErrFn);
+ plugin.on(EventType.COMMENT, resolve);
+ element.handleEvent(EventType.COMMENT, {node: testCommentNode});
+
+ const commentNode = await promise;
+ assert.deepEqual(commentNode, testCommentNode);
+ assert.isTrue(errorStub.calledOnce);
});
test('revert event', () => {
@@ -267,13 +276,13 @@
assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
assert.equal(errorStub.callCount, 0);
- plugin.on(element.EventType.REVERT, throwErrFn);
- plugin.on(element.EventType.REVERT, appendToRevertMsg);
+ plugin.on(EventType.REVERT, throwErrFn);
+ plugin.on(EventType.REVERT, appendToRevertMsg);
assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
'test\n> origTest\ninfo');
assert.isTrue(errorStub.calledOnce);
- plugin.on(element.EventType.REVERT, appendToRevertMsg);
+ plugin.on(EventType.REVERT, appendToRevertMsg);
assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
'test\n> origTest\ninfo\n> origTest\ninfo');
assert.isTrue(errorStub.calledTwice);
@@ -287,64 +296,71 @@
assert.deepEqual(element.getLabelValuesPostRevert(null), {});
assert.equal(errorStub.callCount, 0);
- plugin.on(element.EventType.POST_REVERT, throwErrFn);
- plugin.on(element.EventType.POST_REVERT, getLabels);
+ plugin.on(EventType.POST_REVERT, throwErrFn);
+ plugin.on(EventType.POST_REVERT, getLabels);
assert.deepEqual(
element.getLabelValuesPostRevert(null), {'Code-Review': 1});
assert.isTrue(errorStub.calledOnce);
});
- test('commitmsgedit event', done => {
+ test('commitmsgedit event', async () => {
+ let resolve;
+ const promise = new Promise(r => resolve = r);
const testMsg = 'Test CL commit message';
- plugin.on(element.EventType.COMMIT_MSG_EDIT, throwErrFn);
- plugin.on(element.EventType.COMMIT_MSG_EDIT, (change, msg) => {
- assert.deepEqual(msg, testMsg);
- assert.isTrue(errorStub.calledOnce);
- done();
+ plugin.on(EventType.COMMIT_MSG_EDIT, throwErrFn);
+ plugin.on(EventType.COMMIT_MSG_EDIT, (change, msg) => {
+ resolve(msg);
});
element.handleCommitMessage(null, testMsg);
+
+ const msg = await promise;
+ assert.deepEqual(msg, testMsg);
+ assert.isTrue(errorStub.calledOnce);
});
- test('labelchange event', done => {
+ test('labelchange event', async () => {
+ let resolve;
+ const promise = new Promise(r => resolve = r);
const testChange = {_number: 42};
- plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
- plugin.on(element.EventType.LABEL_CHANGE, change => {
- assert.deepEqual(change, testChange);
- assert.isTrue(errorStub.calledOnce);
- done();
- });
- element.handleEvent(element.EventType.LABEL_CHANGE, {change: testChange});
+ plugin.on(EventType.LABEL_CHANGE, throwErrFn);
+ plugin.on(EventType.LABEL_CHANGE, resolve);
+ element.handleEvent(EventType.LABEL_CHANGE, {change: testChange});
+
+ const change = await promise;
+ assert.deepEqual(change, testChange);
+ assert.isTrue(errorStub.calledOnce);
});
test('submitchange', () => {
- plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
- plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
+ plugin.on(EventType.SUBMIT_CHANGE, throwErrFn);
+ plugin.on(EventType.SUBMIT_CHANGE, () => true);
assert.isTrue(element.canSubmitChange());
assert.isTrue(errorStub.calledOnce);
- plugin.on(element.EventType.SUBMIT_CHANGE, () => false);
- plugin.on(element.EventType.SUBMIT_CHANGE, () => true);
+ plugin.on(EventType.SUBMIT_CHANGE, () => false);
+ plugin.on(EventType.SUBMIT_CHANGE, () => true);
assert.isFalse(element.canSubmitChange());
assert.isTrue(errorStub.calledTwice);
});
- test('highlightjs-loaded event', done => {
+ test('highlightjs-loaded event', async () => {
+ let resolve;
+ const promise = new Promise(r => resolve = r);
const testHljs = {_number: 42};
- plugin.on(element.EventType.HIGHLIGHTJS_LOADED, throwErrFn);
- plugin.on(element.EventType.HIGHLIGHTJS_LOADED, hljs => {
- assert.deepEqual(hljs, testHljs);
- assert.isTrue(errorStub.calledOnce);
- done();
- });
- element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
+ plugin.on(EventType.HIGHLIGHTJS_LOADED, throwErrFn);
+ plugin.on(EventType.HIGHLIGHTJS_LOADED, resolve);
+ element.handleEvent(EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
+
+ const hljs = await promise;
+ assert.deepEqual(hljs, testHljs);
+ assert.isTrue(errorStub.calledOnce);
});
- test('getLoggedIn', done => {
+ test('getLoggedIn', () => {
// fake fetch for authCheck
sinon.stub(window, 'fetch').callsFake(() => Promise.resolve({status: 204}));
- plugin.restApi().getLoggedIn()
+ return plugin.restApi().getLoggedIn()
.then(loggedIn => {
assert.isTrue(loggedIn);
- done();
});
});
@@ -352,13 +368,6 @@
assert.isOk(plugin.attributeHelper());
});
- test('deprecated.install', () => {
- plugin.deprecated.install();
- assert.strictEqual(plugin.popup, plugin.deprecated.popup);
- assert.strictEqual(plugin.onAction, plugin.deprecated.onAction);
- assert.notStrictEqual(plugin.install, plugin.deprecated.install);
- });
-
test('getAdminMenuLinks', () => {
const links = [{text: 'a', url: 'b'}, {text: 'c', url: 'd'}];
const getCallbacksStub = sinon.stub(element, '_getEventCallbacks')
@@ -370,7 +379,7 @@
assert.deepEqual(result, links);
assert.isTrue(getCallbacksStub.calledOnce);
assert.equal(getCallbacksStub.lastCall.args[0],
- element.EventType.ADMIN_MENU_LINKS);
+ EventType.ADMIN_MENU_LINKS);
});
suite('test plugin with base url', () => {
@@ -412,56 +421,6 @@
plugin.popup('some-name');
assert.isTrue(openStub.calledOnce);
});
-
- test('deprecated.popup(element) creates popup with element', () => {
- const el = document.createElement('div');
- el.textContent = 'some text here';
- const openStub = sinon.stub(GrPopupInterface.prototype, 'open');
- openStub.returns(Promise.resolve({
- _getElement() {
- return document.createElement('div');
- }}));
- plugin.deprecated.popup(el);
- assert.isTrue(openStub.calledOnce);
- });
- });
-
- suite('onAction', () => {
- let change;
- let revision;
- let actionDetails;
-
- setup(() => {
- change = {};
- revision = {};
- actionDetails = {__key: 'some'};
- sinon.stub(plugin, 'on').callsArgWith(1, change, revision);
- sinon.stub(plugin, 'changeActions').returns({
- addTapListener: sinon.stub().callsArg(1),
- getActionDetails: () => actionDetails,
- });
- });
-
- test('returns GrPluginActionContext', () => {
- const stub = sinon.stub();
- plugin.deprecated.onAction('change', 'foo', ctx => {
- assert.isTrue(ctx instanceof GrPluginActionContext);
- assert.strictEqual(ctx.change, change);
- assert.strictEqual(ctx.revision, revision);
- assert.strictEqual(ctx.action, actionDetails);
- assert.strictEqual(ctx.plugin, plugin);
- stub();
- });
- assert.isTrue(stub.called);
- });
-
- test('other actions', () => {
- const stub = sinon.stub();
- plugin.deprecated.onAction('project', 'foo', stub);
- plugin.deprecated.onAction('edit', 'foo', stub);
- plugin.deprecated.onAction('branch', 'foo', stub);
- assert.isFalse(stub.called);
- });
});
suite('screen', () => {
@@ -477,18 +436,6 @@
);
});
- test('deprecated works', () => {
- const stub = sinon.stub();
- const hookStub = {onAttached: sinon.stub()};
- sinon.stub(plugin, 'hook').returns(hookStub);
- plugin.deprecated.screen('foo', stub);
- assert.isTrue(plugin.hook.calledWith('testplugin-screen-foo'));
- const fakeEl = {style: {display: ''}};
- hookStub.onAttached.callArgWith(0, fakeEl);
- assert.isTrue(stub.called);
- assert.equal(fakeEl.style.display, 'none');
- });
-
test('works', () => {
sinon.stub(plugin, 'registerCustomComponent');
plugin.screen('foo', 'some-module');
@@ -497,82 +444,11 @@
});
});
- suite('panel', () => {
- let fakeEl;
- let emulateAttached;
-
- setup(()=> {
- fakeEl = {change: {}, revision: {}};
- const hookStub = {onAttached: sinon.stub()};
- sinon.stub(plugin, 'hook').returns(hookStub);
- emulateAttached = () => hookStub.onAttached.callArgWith(0, fakeEl);
- });
-
- test('plugin.panel is deprecated', () => {
- plugin.panel('rubbish');
- assert.isTrue(console.error.called);
- });
-
- [
- ['CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK', 'change-view-integration'],
- ['CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK', 'change-metadata-item'],
- ].forEach(([panelName, endpointName]) => {
- test(`deprecated.panel works for ${panelName}`, () => {
- const callback = sinon.stub();
- plugin.deprecated.panel(panelName, callback);
- assert.isTrue(plugin.hook.calledWith(endpointName));
- emulateAttached();
- assert.isTrue(callback.called);
- const args = callback.args[0][0];
- assert.strictEqual(args.body, fakeEl);
- assert.strictEqual(args.p.CHANGE_INFO, fakeEl.change);
- assert.strictEqual(args.p.REVISION_INFO, fakeEl.revision);
- });
- });
- });
-
suite('settingsScreen', () => {
- test('plugin.settingsScreen is deprecated', () => {
- plugin.settingsScreen('rubbish');
- assert.isTrue(console.error.called);
- });
-
test('plugin.settings() returns GrSettingsApi', () => {
assert.isOk(plugin.settings());
assert.isTrue(plugin.settings() instanceof GrSettingsApi);
});
-
- test('plugin.deprecated.settingsScreen() works', () => {
- const hookStub = {onAttached: sinon.stub()};
- sinon.stub(plugin, 'hook').returns(hookStub);
- const fakeSettings = {};
- fakeSettings.title = sinon.stub().returns(fakeSettings);
- fakeSettings.token = sinon.stub().returns(fakeSettings);
- fakeSettings.module = sinon.stub().returns(fakeSettings);
- fakeSettings.build = sinon.stub().returns(hookStub);
- sinon.stub(plugin, 'settings').returns(fakeSettings);
- const callback = sinon.stub();
-
- plugin.deprecated.settingsScreen('path', 'menu', callback);
- assert.isTrue(fakeSettings.title.calledWith('menu'));
- assert.isTrue(fakeSettings.token.calledWith('path'));
- assert.isTrue(fakeSettings.module.calledWith('div'));
- assert.equal(fakeSettings.build.callCount, 1);
-
- const fakeBody = {};
- const fakeEl = {
- style: {
- display: '',
- },
- querySelector: sinon.stub().returns(fakeBody),
- };
- // Emulate settings screen attached
- hookStub.onAttached.callArgWith(0, fakeEl);
- assert.isTrue(callback.called);
- const args = callback.args[0][0];
- assert.strictEqual(args.body, fakeBody);
- assert.equal(fakeEl.style.display, 'none');
- });
});
});
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
new file mode 100644
index 0000000..6456370
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-types.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * 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.
+ */
+import {ActionInfo, ChangeInfo, PatchSetNum} from '../../../types/common';
+import {EventType, TargetElement} from '../../plugins/gr-plugin-types';
+import {DiffLayer} from '../../../types/types';
+import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {MenuLink} from '../../plugins/gr-admin-api/gr-admin-api';
+
+export interface ShowChangeDetail {
+ change: ChangeInfo;
+ patchNum: PatchSetNum;
+ info: {mergeable: boolean};
+}
+
+export interface ShowRevisionActionsDetail {
+ change: ChangeInfo;
+ revisionActions: {[key: string]: ActionInfo};
+}
+
+export type EventCallback = (...args: any[]) => any;
+
+export interface JsApiService {
+ getElement(key: TargetElement): HTMLElement;
+ addEventCallback(eventName: EventType, callback: EventCallback): void;
+ modifyRevertSubmissionMsg(
+ change: ChangeInfo,
+ revertSubmissionMsg: string,
+ origMsg: string
+ ): string;
+ handleEvent(eventName: EventType, detail: any): void;
+ modifyRevertMsg(
+ change: ChangeInfo,
+ revertMsg: string,
+ origMsg: string
+ ): string;
+ addElement(key: TargetElement, el: HTMLElement): void;
+ getDiffLayers(path: string, changeNum: number): DiffLayer[];
+ disposeDiffLayers(path: string): void;
+ getCoverageAnnotationApis(): Promise<GrAnnotationActionsInterface[]>;
+ getAdminMenuLinks(): MenuLink[];
+ // TODO(TS): Add more methods when needed for the TS conversion.
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
deleted file mode 100644
index 63555da..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
+++ /dev/null
@@ -1,104 +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 {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-export function GrPluginActionContext(plugin, action, change, revision) {
- this.action = action;
- this.plugin = plugin;
- this.change = change;
- this.revision = revision;
- this._popups = [];
-}
-
-GrPluginActionContext.prototype.popup = function(element) {
- this._popups.push(this.plugin.deprecated.popup(element));
-};
-
-GrPluginActionContext.prototype.hide = function() {
- for (const popupApi of this._popups) {
- popupApi.close();
- }
- this._popups.splice(0);
-};
-
-GrPluginActionContext.prototype.refresh = function() {
- window.location.reload();
-};
-
-GrPluginActionContext.prototype.textfield = function() {
- return document.createElement('paper-input');
-};
-
-GrPluginActionContext.prototype.br = function() {
- return document.createElement('br');
-};
-
-GrPluginActionContext.prototype.msg = function(text) {
- const label = document.createElement('gr-label');
- dom(label).appendChild(document.createTextNode(text));
- return label;
-};
-
-GrPluginActionContext.prototype.div = function(...els) {
- const div = document.createElement('div');
- for (const el of els) {
- dom(div).appendChild(el);
- }
- return div;
-};
-
-GrPluginActionContext.prototype.button = function(label, callbacks) {
- const onClick = callbacks && callbacks.onclick;
- const button = document.createElement('gr-button');
- dom(button).appendChild(document.createTextNode(label));
- if (onClick) {
- this.plugin.eventHelper(button).onTap(onClick);
- }
- return button;
-};
-
-GrPluginActionContext.prototype.checkbox = function() {
- const checkbox = document.createElement('input');
- checkbox.type = 'checkbox';
- return checkbox;
-};
-
-GrPluginActionContext.prototype.label = function(checkbox, title) {
- return this.div(checkbox, this.msg(title));
-};
-
-GrPluginActionContext.prototype.prependLabel = function(title, checkbox) {
- return this.label(checkbox, title);
-};
-
-GrPluginActionContext.prototype.call = function(payload, onSuccess) {
- if (!this.action.__url) {
- console.warn(`Unable to ${this.action.method} to ${this.action.__key}!`);
- return;
- }
- this.plugin.restApi()
- .send(this.action.method, this.action.__url, payload)
- .then(onSuccess)
- .catch(error => {
- document.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: `Plugin network error: ${error}`,
- },
- }));
- });
-};
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
new file mode 100644
index 0000000..ffdf710
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.ts
@@ -0,0 +1,128 @@
+/**
+ * @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 {RevisionInfo, ChangeInfo, RequestPayload} from '../../../types/common';
+import {PluginApi} from '../../plugins/gr-plugin-types';
+import {UIActionInfo} from './gr-change-actions-js-api';
+
+interface GrPopupInterface {
+ close(): void;
+}
+
+interface ButtonCallBacks {
+ onclick: (event: Event) => boolean;
+}
+
+export class GrPluginActionContext {
+ private _popups: GrPopupInterface[] = [];
+
+ constructor(
+ public readonly plugin: PluginApi,
+ public readonly action: UIActionInfo,
+ public readonly change: ChangeInfo,
+ public readonly revision: RevisionInfo
+ ) {}
+
+ popup(element: Node) {
+ this.plugin.popup().then(popApi => {
+ const popupEl = popApi._getElement();
+ if (!popupEl) {
+ throw new Error('Popup element not found');
+ }
+ popupEl.appendChild(element);
+ this._popups.push(popApi);
+ });
+ }
+
+ hide() {
+ for (const popupApi of this._popups) {
+ popupApi.close();
+ }
+ this._popups.splice(0);
+ }
+
+ refresh() {
+ window.location.reload();
+ }
+
+ textfield(): HTMLElement {
+ return document.createElement('paper-input');
+ }
+
+ br() {
+ return document.createElement('br');
+ }
+
+ msg(text: string) {
+ const label = document.createElement('gr-label');
+ label.appendChild(document.createTextNode(text));
+ return label;
+ }
+
+ div(...els: Node[]) {
+ const div = document.createElement('div');
+ for (const el of els) {
+ div.appendChild(el);
+ }
+ return div;
+ }
+
+ button(label: string, callbacks: ButtonCallBacks | undefined) {
+ const onClick = callbacks && callbacks.onclick;
+ const button = document.createElement('gr-button');
+ button.appendChild(document.createTextNode(label));
+ if (onClick) {
+ this.plugin.eventHelper(button).onTap(onClick);
+ }
+ return button;
+ }
+
+ checkbox() {
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ return checkbox;
+ }
+
+ label(checkbox: Node, title: string) {
+ return this.div(checkbox, this.msg(title));
+ }
+
+ prependLabel(title: string, checkbox: Node) {
+ return this.label(checkbox, title);
+ }
+
+ call(payload: RequestPayload, onSuccess: (result: unknown) => void) {
+ if (!this.action.method) return;
+ if (!this.action.__url) {
+ console.warn(`Unable to ${this.action.method} to ${this.action.__key}!`);
+ return;
+ }
+ this.plugin
+ .restApi()
+ .send(this.action.method, this.action.__url, payload)
+ .then(onSuccess)
+ .catch((error: unknown) => {
+ document.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: `Plugin network error: ${error}`,
+ },
+ })
+ );
+ });
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
index dde3c04..e764bf8 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-js-api-interface.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GrPluginActionContext} from './gr-plugin-action-context.js';
import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
@@ -34,15 +33,16 @@
instance = new GrPluginActionContext(plugin);
});
- test('popup() and hide()', () => {
+ test('popup() and hide()', async () => {
const popupApiStub = {
+ _getElement: sinon.stub().returns(document.createElement('div')),
close: sinon.stub(),
};
- sinon.stub(plugin.deprecated, 'popup').returns(popupApiStub);
- const el = {};
+ sinon.stub(plugin, 'popup').returns(Promise.resolve(popupApiStub));
+ const el = document.createElement('span');
instance.popup(el);
- assert.isTrue(instance.plugin.deprecated.popup.calledWith(el));
-
+ await flush();
+ assert.isTrue(popupApiStub._getElement.called);
instance.hide();
assert.isTrue(popupApiStub.close.called);
});
@@ -79,16 +79,14 @@
button = instance.button('foo', {onclick: clickStub});
// If you don't attach a Polymer element to the DOM, then the ready()
// callback will not be called and then e.g. this.$ is undefined.
- dom(document.body).appendChild(button);
+ document.body.appendChild(button);
});
- test('click', done => {
+ test('click', () => {
MockInteractions.tap(button);
- flush(() => {
- assert.isTrue(clickStub.called);
- assert.equal(button.textContent, 'foo');
- done();
- });
+ flush();
+ assert.isTrue(clickStub.called);
+ assert.equal(button.textContent, 'foo');
});
teardown(() => {
@@ -128,7 +126,7 @@
'METHOD', '/changes/1/revisions/2/foo~bar', payload));
});
- test('call error', done => {
+ test('call error', async () => {
instance.action = {
method: 'METHOD',
__key: 'key',
@@ -141,12 +139,10 @@
const errorStub = sinon.stub();
document.addEventListener('show-alert', errorStub);
instance.call();
- flush(() => {
- assert.isTrue(errorStub.calledOnce);
- assert.equal(errorStub.args[0][0].detail.message,
- 'Plugin network error: Error: boom');
- done();
- });
+ await flush();
+ assert.isTrue(errorStub.calledOnce);
+ assert.equal(errorStub.args[0][0].detail.message,
+ 'Plugin network error: Error: boom');
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
deleted file mode 100644
index dae8d3e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
+++ /dev/null
@@ -1,228 +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 {importHref} from '../../../scripts/import-href.js';
-
-/** @constructor */
-export class GrPluginEndpoints {
- constructor() {
- this._endpoints = {};
- this._callbacks = {};
- this._dynamicPlugins = {};
- this._importedUrls = new Set();
- this._pluginLoaded = false;
- }
-
- setPluginsReady() {
- this._pluginLoaded = true;
- }
-
- onNewEndpoint(endpoint, callback) {
- if (!this._callbacks[endpoint]) {
- this._callbacks[endpoint] = [];
- }
- this._callbacks[endpoint].push(callback);
- }
-
- onDetachedEndpoint(endpoint, callback) {
- if (this._callbacks[endpoint]) {
- this._callbacks[endpoint] = this._callbacks[endpoint].filter(
- cb => cb !== callback
- );
- }
- }
-
- _getOrCreateModuleInfo(plugin, opts) {
- const {endpoint, slot, type, moduleName, domHook} = opts;
- const existingModule = this._endpoints[endpoint].find(
- info =>
- info.plugin === plugin &&
- info.moduleName === moduleName &&
- info.domHook === domHook &&
- info.slot === slot
- );
- if (existingModule) {
- return existingModule;
- } else {
- const newModule = {
- moduleName,
- plugin,
- pluginUrl: plugin._url,
- type,
- domHook,
- slot,
- };
- this._endpoints[endpoint].push(newModule);
- return newModule;
- }
- }
-
- /**
- * Register a plugin to an endpoint.
- *
- * Dynamic plugins are registered to a specific prefix, such as
- * 'change-list-header'. These plugins are then fetched by prefix to determine
- * which endpoints to dynamically add to the page.
- *
- * @param {Object} plugin
- * @param {Object} opts
- */
- registerModule(plugin, opts) {
- const {endpoint, dynamicEndpoint} = opts;
- if (dynamicEndpoint) {
- if (!this._dynamicPlugins[dynamicEndpoint]) {
- this._dynamicPlugins[dynamicEndpoint] = new Set();
- }
- this._dynamicPlugins[dynamicEndpoint].add(endpoint);
- }
- if (!this._endpoints[endpoint]) {
- this._endpoints[endpoint] = [];
- }
- const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
- // TODO: the logic below seems wrong when:
- // multiple plugins register to the same endpoint
- // one register before plugins ready
- // the other done after, then only the later one will have the callbacks
- // invoked.
- if (this._pluginLoaded && this._callbacks[endpoint]) {
- this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
- }
- }
-
- getDynamicEndpoints(dynamicEndpoint) {
- const plugins = this._dynamicPlugins[dynamicEndpoint];
- if (!plugins) return [];
- return Array.from(plugins);
- }
-
- /**
- * Get detailed information about modules registered with an extension
- * endpoint.
- *
- * @param {string} name Endpoint name.
- * @param {?{
- * type: (string|undefined),
- * moduleName: (string|undefined)
- * }} opt_options
- * @return {!Array<{
- * moduleName: string,
- * plugin: Plugin,
- * pluginUrl: String,
- * type: EndpointType,
- * domHook: !Object
- * }>}
- */
- getDetails(name, opt_options) {
- const type = opt_options && opt_options.type;
- const moduleName = opt_options && opt_options.moduleName;
- if (!this._endpoints[name]) {
- return [];
- }
- return this._endpoints[name].filter(
- item =>
- (!type || item.type === type) &&
- (!moduleName || moduleName == item.moduleName)
- );
- }
-
- /**
- * Get detailed module names for instantiating at the endpoint.
- *
- * @param {string} name Endpoint name.
- * @param {?{
- * type: (string|undefined),
- * moduleName: (string|undefined)
- * }} opt_options
- * @return {!Array<string>}
- */
- getModules(name, opt_options) {
- const modulesData = this.getDetails(name, opt_options);
- if (!modulesData.length) {
- return [];
- }
- return modulesData.map(m => m.moduleName);
- }
-
- /**
- * Get plugin URLs with element and module definitions.
- *
- * @param {string} name Endpoint name.
- * @param {?{
- * type: (string|undefined),
- * moduleName: (string|undefined)
- * }} opt_options
- * @return {!Array<!URL>}
- */
- getPlugins(name, opt_options) {
- const modulesData = this.getDetails(name, opt_options);
- if (!modulesData.length) {
- return [];
- }
- return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
- }
-
- importUrl(pluginUrl) {
- let timerId;
- return Promise
- .race([
- new Promise((resolve, reject) => {
- this._importedUrls.add(pluginUrl.href);
- importHref(pluginUrl, resolve, reject);
- }),
- // Timeout after 3s
- new Promise(r => timerId = setTimeout(r, 3000)),
- ])
- .finally(() => {
- if (timerId) clearTimeout(timerId);
- });
- }
-
- /**
- * Get plugin URLs with element and module definitions.
- *
- * @param {string} name Endpoint name.
- * @param {?{
- * type: (string|undefined),
- * moduleName: (string|undefined)
- * }} opt_options
- * @return {!Array<!Promise<void>>}
- */
- getAndImportPlugins(name, opt_options) {
- return Promise.all(
- this.getPlugins(name, opt_options).map(pluginUrl => {
- if (this._importedUrls.has(pluginUrl.href)) {
- return Promise.resolve();
- }
-
- // TODO: we will deprecate html plugins entirely
- // for now, keep the original behavior and import
- // only for html ones
- if (pluginUrl && pluginUrl.pathname.endsWith('.html')) {
- return this.importUrl(pluginUrl);
- } else {
- return Promise.resolve();
- }
- })
- );
- }
-}
-
-// TODO(dmfilippov): Convert to service and add to appContext
-export let pluginEndpoints = new GrPluginEndpoints();
-export function _testOnly_resetEndpoints() {
- pluginEndpoints = new GrPluginEndpoints();
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
new file mode 100644
index 0000000..3935ef1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.ts
@@ -0,0 +1,226 @@
+/**
+ * @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 {importHref} from '../../../scripts/import-href';
+import {HookApi, PluginApi} from '../../plugins/gr-plugin-types';
+import {notUndefined} from '../../../types/types';
+
+type Callback = (value: any) => void;
+
+export interface ModuleInfo {
+ moduleName: string;
+ plugin: PluginApi;
+ pluginUrl?: URL;
+ type?: string;
+ domHook?: HookApi;
+ slot?: string;
+}
+
+interface Options {
+ endpoint: string;
+ dynamicEndpoint?: string;
+ slot?: string;
+ type?: string;
+ moduleName?: string;
+ domHook?: HookApi;
+}
+
+export class GrPluginEndpoints {
+ private readonly _endpoints = new Map<string, ModuleInfo[]>();
+
+ private readonly _callbacks = new Map<string, ((value: any) => void)[]>();
+
+ private readonly _dynamicPlugins = new Map<string, Set<string>>();
+
+ private readonly _importedUrls = new Set<string>();
+
+ private _pluginLoaded = false;
+
+ setPluginsReady() {
+ this._pluginLoaded = true;
+ }
+
+ onNewEndpoint(endpoint: string, callback: Callback) {
+ if (!this._callbacks.has(endpoint)) {
+ this._callbacks.set(endpoint, []);
+ }
+ this._callbacks.get(endpoint)!.push(callback);
+ }
+
+ onDetachedEndpoint(endpoint: string, callback: Callback) {
+ if (this._callbacks.has(endpoint)) {
+ const filteredCallbacks = this._callbacks
+ .get(endpoint)!
+ .filter((cb: Callback) => cb !== callback);
+ this._callbacks.set(endpoint, filteredCallbacks);
+ }
+ }
+
+ _getOrCreateModuleInfo(plugin: PluginApi, opts: Options): ModuleInfo {
+ const {endpoint, slot, type, moduleName, domHook} = opts;
+ const existingModule = this._endpoints
+ .get(endpoint!)!
+ .find(
+ (info: ModuleInfo) =>
+ info.plugin === plugin &&
+ info.moduleName === moduleName &&
+ info.domHook === domHook &&
+ info.slot === slot
+ );
+ if (existingModule) {
+ return existingModule;
+ } else {
+ const newModule: ModuleInfo = {
+ moduleName: moduleName!,
+ plugin,
+ pluginUrl: plugin._url,
+ type,
+ domHook,
+ slot,
+ };
+ this._endpoints.get(endpoint!)!.push(newModule);
+ return newModule;
+ }
+ }
+
+ /**
+ * Register a plugin to an endpoint.
+ *
+ * Dynamic plugins are registered to a specific prefix, such as
+ * 'change-list-header'. These plugins are then fetched by prefix to determine
+ * which endpoints to dynamically add to the page.
+ */
+ registerModule(plugin: PluginApi, opts: Options) {
+ const endpoint = opts.endpoint!;
+ const dynamicEndpoint = opts.dynamicEndpoint;
+ if (dynamicEndpoint) {
+ if (!this._dynamicPlugins.has(dynamicEndpoint)) {
+ this._dynamicPlugins.set(dynamicEndpoint, new Set());
+ }
+ this._dynamicPlugins.get(dynamicEndpoint)!.add(endpoint);
+ }
+ if (!this._endpoints.has(endpoint)) {
+ this._endpoints.set(endpoint, []);
+ }
+ const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
+ // TODO: the logic below seems wrong when:
+ // multiple plugins register to the same endpoint
+ // one register before plugins ready
+ // the other done after, then only the later one will have the callbacks
+ // invoked.
+ if (this._pluginLoaded && this._callbacks.has(endpoint)) {
+ this._callbacks.get(endpoint)!.forEach(callback => callback(moduleInfo));
+ }
+ }
+
+ getDynamicEndpoints(dynamicEndpoint: string): string[] {
+ const plugins = this._dynamicPlugins.get(dynamicEndpoint);
+ if (!plugins) return [];
+ return Array.from(plugins);
+ }
+
+ /**
+ * Get detailed information about modules registered with an extension
+ * endpoint.
+ */
+ getDetails(name: string, options?: Options): ModuleInfo[] {
+ const type = options && options.type;
+ const moduleName = options && options.moduleName;
+ if (!this._endpoints.has(name)) {
+ return [];
+ } else {
+ return this._endpoints
+ .get(name)!
+ .filter(
+ (item: ModuleInfo) =>
+ (!type || item.type === type) &&
+ (!moduleName || moduleName === item.moduleName)
+ );
+ }
+ }
+
+ /**
+ * Get detailed module names for instantiating at the endpoint.
+ */
+ getModules(name: string, options?: Options): string[] {
+ const modulesData = this.getDetails(name, options);
+ if (!modulesData.length) {
+ return [];
+ }
+ return modulesData.map(m => m.moduleName);
+ }
+
+ /**
+ * Get plugin URLs with element and module definitions.
+ */
+ getPlugins(name: string, options?: Options): URL[] {
+ const modulesData = this.getDetails(name, options);
+ if (!modulesData.length) {
+ return [];
+ }
+ return Array.from(new Set(modulesData.map(m => m.pluginUrl))).filter(
+ notUndefined
+ );
+ }
+
+ importUrl(pluginUrl: URL) {
+ let timerId: any;
+ return Promise.race([
+ new Promise((resolve, reject) => {
+ this._importedUrls.add(pluginUrl.href);
+ importHref(pluginUrl.href, resolve, reject);
+ }),
+ // Timeout after 3s
+ new Promise(r => (timerId = setTimeout(r, 3000))),
+ ]).finally(() => {
+ if (timerId) clearTimeout(timerId);
+ });
+ }
+
+ /**
+ * Get plugin URLs with element and module definitions.
+ */
+ getAndImportPlugins(name: string, options?: Options) {
+ return Promise.all(
+ this.getPlugins(name, options).map(pluginUrl => {
+ if (this._importedUrls.has(pluginUrl.href)) {
+ return Promise.resolve();
+ }
+
+ // TODO: we will deprecate html plugins entirely
+ // for now, keep the original behavior and import
+ // only for html ones
+ if (pluginUrl?.pathname.endsWith('.html')) {
+ return this.importUrl(pluginUrl);
+ } else {
+ return Promise.resolve();
+ }
+ })
+ );
+ }
+}
+
+// TODO(dmfilippov): Convert to service and add to appContext
+let pluginEndpoints = new GrPluginEndpoints();
+
+// To avoid mutable-exports, we don't want to export above variable directly
+export function getPluginEndpoints() {
+ return pluginEndpoints;
+}
+export function _testOnly_resetEndpoints() {
+ pluginEndpoints = new GrPluginEndpoints();
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
deleted file mode 100644
index f54cba4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.js
+++ /dev/null
@@ -1,453 +0,0 @@
-import {appContext} from '../../../services/app-context.js';
-
-/**
- * @license
- * Copyright (C) 2019 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 {importHref} from '../../../scripts/import-href.js';
-import {
- PLUGIN_LOADING_TIMEOUT_MS,
- PRELOADED_PROTOCOL,
- getPluginNameFromUrl,
-} from './gr-api-utils.js';
-import {Plugin} from './gr-public-js-api.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-import {pluginEndpoints} from './gr-plugin-endpoints.js';
-
-/**
- * @enum {string}
- */
-const PluginState = {
- /**
- * State that indicates the plugin is pending to be loaded.
- */
- PENDING: 'PENDING',
-
- /**
- * State that indicates the plugin is already loaded.
- */
- LOADED: 'LOADED',
-
- /**
- * State that indicates the plugin is already loaded.
- */
- PRE_LOADED: 'PRE_LOADED',
-
- /**
- * State that indicates the plugin failed to load.
- */
- LOAD_FAILED: 'LOAD_FAILED',
-};
-
-// Prefix for any unrecognized plugin urls.
-// Url should match following patterns:
-// /plugins/PLUGINNAME/static/SCRIPTNAME.(html|js)
-// /plugins/PLUGINNAME.(js|html)
-const UNKNOWN_PLUGIN_PREFIX = '__$$__';
-
-// Current API version for Plugin,
-// plugins with incompatible version will not be laoded.
-const API_VERSION = '0.1';
-
-/**
- * PluginLoader, responsible for:
- *
- * Loading all plugins and handling errors etc.
- * Recording plugin state.
- * Reporting on plugin loading status.
- * Retrieve plugin.
- * Check plugin status and if all plugins loaded.
- */
-export class PluginLoader {
- constructor() {
- this._pluginListLoaded = false;
-
- /** @type {Map<string,PluginLoader.PluginObject>} */
- this._plugins = new Map();
-
- this._reporting = null;
-
- // Promise that resolves when all plugins loaded
- this._loadingPromise = null;
-
- // Resolver to resolve _loadingPromise once all plugins loaded
- this._loadingResolver = null;
- }
-
- _getReporting() {
- if (!this._reporting) {
- this._reporting = appContext.reportingService;
- }
- return this._reporting;
- }
-
- /**
- * Use the plugin name or use the full url if not recognized.
- *
- * @see gr-api-utils#getPluginNameFromUrl
- * @param {string|URL} url
- */
- _getPluginKeyFromUrl(url) {
- return getPluginNameFromUrl(url) ||
- `${UNKNOWN_PLUGIN_PREFIX}${url}`;
- }
-
- /**
- * Load multiple plugins with certain options.
- *
- * @param {Array<string>} plugins
- * @param {Object<string, PluginLoader.PluginOption>} opts
- */
- loadPlugins(plugins = [], opts = {}) {
- this._pluginListLoaded = true;
-
- plugins.forEach(path => {
- const url = this._urlFor(path, window.ASSETS_PATH);
- // Skip if preloaded, for bundling.
- if (this.isPluginPreloaded(url)) return;
-
- const pluginKey = this._getPluginKeyFromUrl(url);
- // Skip if already installed.
- if (this._plugins.has(pluginKey)) return;
- this._plugins.set(pluginKey, {
- name: pluginKey,
- url,
- state: PluginState.PENDING,
- plugin: null,
- });
-
- if (this._isPathEndsWith(url, '.html')) {
- this._importHtmlPlugin(path, opts && opts[path]);
- } else if (this._isPathEndsWith(url, '.js')) {
- this._loadJsPlugin(path);
- } else {
- this._failToLoad(`Unrecognized plugin path ${path}`, path);
- }
- });
-
- this.awaitPluginsLoaded().then(() => {
- console.info('Plugins loaded');
- this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames());
- });
- }
-
- _isPathEndsWith(url, suffix) {
- if (!(url instanceof URL)) {
- try {
- url = new URL(url);
- } catch (e) {
- console.warn(e);
- return false;
- }
- }
-
- return url.pathname && url.pathname.endsWith(suffix);
- }
-
- _getAllInstalledPluginNames() {
- const installedPlugins = [];
- for (const plugin of this._plugins.values()) {
- if (plugin.state === PluginState.LOADED) {
- installedPlugins.push(plugin.name);
- }
- }
- return installedPlugins;
- }
-
- install(callback, opt_version, opt_src) {
- // HTML import polyfill adds __importElement pointing to the import tag.
- const script = document.currentScript &&
- (document.currentScript.__importElement || document.currentScript);
- let src = opt_src || (script && script.src);
- if (!src || src.startsWith('data:')) {
- src = script && script.baseURI;
- }
-
- if (opt_version && opt_version !== API_VERSION) {
- this._failToLoad(`Plugin ${src} install error: only version ` +
- API_VERSION + ' is supported in PolyGerrit. ' + opt_version +
- ' was given.', src);
- return;
- }
-
- const url = this._urlFor(src);
- const pluginObject = this.getPlugin(url);
- let plugin = pluginObject && pluginObject.plugin;
- if (!plugin) {
- plugin = new Plugin(url);
- }
- try {
- callback(plugin);
- this._pluginInstalled(url, plugin);
- } catch (e) {
- this._failToLoad(`${e.name}: ${e.message}`, src);
- }
- }
-
- // The polygerrit uses version of sinon where you can't stub getter,
- // declare it as a function here
- arePluginsLoaded() {
- // As the size of plugins is relatively small,
- // so the performance of this check should be reasonable
- if (!this._pluginListLoaded) return false;
- for (const plugin of this._plugins.values()) {
- if (plugin.state === PluginState.PENDING) return false;
- }
- return true;
- }
-
- _checkIfCompleted() {
- if (this.arePluginsLoaded()) {
- pluginEndpoints.setPluginsReady();
- if (this._loadingResolver) {
- this._loadingResolver();
- this._loadingResolver = null;
- this._loadingPromise = null;
- }
- }
- }
-
- _timeout() {
- const pendingPlugins = [];
- for (const plugin of this._plugins.values()) {
- if (plugin.state === PluginState.PENDING) {
- this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
- this._checkIfCompleted();
- pendingPlugins.push(plugin.url);
- }
- }
- return `Timeout when loading plugins: ${pendingPlugins.join(',')}`;
- }
-
- _failToLoad(message, pluginUrl) {
- // Show an alert with the error
- document.dispatchEvent(new CustomEvent('show-alert', {
- detail: {
- message: `Plugin install error: ${message} from ${pluginUrl}`,
- },
- }));
- this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
- this._checkIfCompleted();
- }
-
- _updatePluginState(pluginUrl, state) {
- const key = this._getPluginKeyFromUrl(pluginUrl);
- if (this._plugins.has(key)) {
- this._plugins.get(key).state = state;
- } else {
- // Plugin is not recorded for some reason.
- console.info(`Plugin loaded separately: ${pluginUrl}`);
- this._plugins.set(key, {
- name: key,
- url: pluginUrl,
- state,
- plugin: null,
- });
- }
- return this._plugins.get(key);
- }
-
- _pluginInstalled(url, plugin) {
- const pluginObj = this._updatePluginState(url, PluginState.LOADED);
- pluginObj.plugin = plugin;
- this._getReporting().pluginLoaded(plugin.getPluginName() || url);
- console.log(`Plugin ${plugin.getPluginName() || url} installed.`);
- this._checkIfCompleted();
- }
-
- installPreloadedPlugins() {
- if (!window.Gerrit || !window.Gerrit._preloadedPlugins) { return; }
- const Gerrit = window.Gerrit;
- for (const name in Gerrit._preloadedPlugins) {
- if (!Gerrit._preloadedPlugins.hasOwnProperty(name)) { continue; }
- const callback = Gerrit._preloadedPlugins[name];
- this.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
- }
- }
-
- isPluginPreloaded(pathOrUrl) {
- const url = this._urlFor(pathOrUrl);
- const name = getPluginNameFromUrl(url);
- if (name && window.Gerrit._preloadedPlugins) {
- return window.Gerrit._preloadedPlugins.hasOwnProperty(name);
- } else {
- return false;
- }
- }
-
- /**
- * Checks if given plugin path/url is enabled or not.
- *
- * @param {string} pathOrUrl
- */
- isPluginEnabled(pathOrUrl) {
- const url = this._urlFor(pathOrUrl);
- if (this.isPluginPreloaded(url)) return true;
- const key = this._getPluginKeyFromUrl(url);
- return this._plugins.has(key);
- }
-
- /**
- * Returns the plugin object with a given url.
- *
- * @param {string} pathOrUrl
- */
- getPlugin(pathOrUrl) {
- const key = this._getPluginKeyFromUrl(this._urlFor(pathOrUrl));
- return this._plugins.get(key);
- }
-
- /**
- * Checks if given plugin path/url is loaded or not.
- *
- * @param {string} pathOrUrl
- */
- isPluginLoaded(pathOrUrl) {
- const url = this._urlFor(pathOrUrl);
- const key = this._getPluginKeyFromUrl(url);
- return this._plugins.has(key) ?
- this._plugins.get(key).state === PluginState.LOADED :
- false;
- }
-
- _importHtmlPlugin(pluginUrl, opts = {}) {
- const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
- const urlWithoutAP = this._urlFor(pluginUrl);
- let onerror = null;
- if (urlWithAP !== urlWithoutAP) {
- onerror = () => this._loadHtmlPlugin(urlWithoutAP, opts.sync);
- }
- this._loadHtmlPlugin(urlWithAP, opts.sync, onerror);
- }
-
- _loadHtmlPlugin(url, sync, onerror) {
- if (!onerror) {
- onerror = () => {
- this._failToLoad(`${url} import error`, url);
- };
- }
-
- importHref(
- url, () => {},
- onerror,
- !sync);
- }
-
- _loadJsPlugin(pluginUrl) {
- const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
- const urlWithoutAP = this._urlFor(pluginUrl);
- let onerror = null;
- if (urlWithAP !== urlWithoutAP) {
- onerror = () => this._createScriptTag(urlWithoutAP);
- }
-
- this._createScriptTag(urlWithAP, onerror);
- }
-
- _createScriptTag(url, onerror) {
- if (!onerror) {
- onerror = () => this._failToLoad(`${url} load error`, url);
- }
-
- const el = document.createElement('script');
- el.defer = true;
- el.setAttribute('src', url);
- // no credentials to send when fetch plugin js
- // and this will help provide more meaningful error than
- // 'Script error.'
- el.setAttribute('crossorigin', 'anonymous');
- el.onerror = onerror;
- return document.body.appendChild(el);
- }
-
- _urlFor(pathOrUrl, assetsPath) {
- if (!pathOrUrl) {
- return pathOrUrl;
- }
-
- // theme is per host, should always load from assetsPath
- const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.html') ||
- pathOrUrl.endsWith('static/gerrit-theme.js');
- const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
- if (pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
- pathOrUrl.startsWith('http')) {
- // Plugins are loaded from another domain or preloaded.
- if (pathOrUrl.includes(location.host) &&
- shouldTryLoadFromAssetsPathFirst) {
- // if is loading from host server, try replace with cdn when assetsPath provided
- return pathOrUrl
- .replace(location.origin, assetsPath);
- }
- return pathOrUrl;
- }
-
- if (!pathOrUrl.startsWith('/')) {
- pathOrUrl = '/' + pathOrUrl;
- }
-
- if (shouldTryLoadFromAssetsPathFirst) {
- return assetsPath + pathOrUrl;
- }
-
- return window.location.origin + getBaseUrl() + pathOrUrl;
- }
-
- awaitPluginsLoaded() {
- // Resolve if completed.
- this._checkIfCompleted();
-
- if (this.arePluginsLoaded()) {
- return Promise.resolve();
- }
- if (!this._loadingPromise) {
- let timerId;
- this._loadingPromise =
- Promise.race([
- new Promise(resolve => this._loadingResolver = resolve),
- new Promise((_, reject) => timerId = setTimeout(
- () => {
- reject(new Error(this._timeout()));
- }, PLUGIN_LOADING_TIMEOUT_MS)),
- ]).finally(() => {
- if (timerId) clearTimeout(timerId);
- });
- }
- return this._loadingPromise;
- }
-}
-
-/**
- * @typedef {{
- * name:string,
- * url:string,
- * state:PluginState,
- * plugin:Object
- * }}
- */
-PluginLoader.PluginObject;
-
-/**
- * @typedef {{
- * sync:boolean,
- * }}
- */
-PluginLoader.PluginOption;
-
-// TODO(dmfilippov): Convert to service and add to appContext
-export let pluginLoader = new PluginLoader();
-export function _testOnly_resetPluginLoader() {
- pluginLoader = new PluginLoader();
- return pluginLoader;
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
new file mode 100644
index 0000000..47b7be3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader.ts
@@ -0,0 +1,455 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {appContext} from '../../../services/app-context';
+import {importHref} from '../../../scripts/import-href';
+import {
+ PLUGIN_LOADING_TIMEOUT_MS,
+ PRELOADED_PROTOCOL,
+ getPluginNameFromUrl,
+} from './gr-api-utils';
+import {Plugin} from './gr-public-js-api';
+import {getBaseUrl} from '../../../utils/url-util';
+import {getPluginEndpoints} from './gr-plugin-endpoints';
+import {PluginApi} from '../../plugins/gr-plugin-types';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {hasOwnProperty} from '../../../utils/common-util';
+
+enum PluginState {
+ /** State that indicates the plugin is pending to be loaded. */
+ PENDING = 'PENDING',
+ /** State that indicates the plugin is already loaded. */
+ LOADED = 'LOADED',
+ /** State that indicates the plugin failed to load. */
+ LOAD_FAILED = 'LOAD_FAILED',
+}
+
+interface PluginObject {
+ name: string;
+ url: string;
+ state: PluginState;
+ plugin: PluginApi | null;
+}
+
+interface PluginOption {
+ sync?: boolean;
+}
+
+export interface PluginOptionMap {
+ [path: string]: PluginOption;
+}
+
+type GerritScriptElement = HTMLScriptElement & {
+ __importElement: HTMLScriptElement;
+};
+
+type PluginCallback = (plugin: PluginApi) => void;
+
+interface PluginCallbackMap {
+ [name: string]: PluginCallback;
+}
+
+interface GerritGlobal {
+ _preloadedPlugins?: PluginCallbackMap;
+}
+
+// Prefix for any unrecognized plugin urls.
+// Url should match following patterns:
+// /plugins/PLUGINNAME/static/SCRIPTNAME.(html|js)
+// /plugins/PLUGINNAME.(js|html)
+const UNKNOWN_PLUGIN_PREFIX = '__$$__';
+
+// Current API version for Plugin,
+// plugins with incompatible version will not be laoded.
+const API_VERSION = '0.1';
+
+/**
+ * PluginLoader, responsible for:
+ *
+ * Loading all plugins and handling errors etc.
+ * Recording plugin state.
+ * Reporting on plugin loading status.
+ * Retrieve plugin.
+ * Check plugin status and if all plugins loaded.
+ */
+export class PluginLoader {
+ _pluginListLoaded = false;
+
+ _plugins = new Map<string, PluginObject>();
+
+ _reporting: ReportingService | null = null;
+
+ // Promise that resolves when all plugins loaded
+ _loadingPromise: Promise<void> | null = null;
+
+ // Resolver to resolve _loadingPromise once all plugins loaded
+ _loadingResolver: (() => void) | null = null;
+
+ _getReporting() {
+ if (!this._reporting) {
+ this._reporting = appContext.reportingService;
+ }
+ return this._reporting;
+ }
+
+ /**
+ * Use the plugin name or use the full url if not recognized.
+ */
+ _getPluginKeyFromUrl(url: string) {
+ return getPluginNameFromUrl(url) || `${UNKNOWN_PLUGIN_PREFIX}${url}`;
+ }
+
+ /**
+ * Load multiple plugins with certain options.
+ */
+ loadPlugins(plugins: string[] = [], opts: PluginOptionMap = {}) {
+ this._pluginListLoaded = true;
+
+ plugins.forEach(path => {
+ const url = this._urlFor(path, window.ASSETS_PATH);
+ // Skip if preloaded, for bundling.
+ if (this.isPluginPreloaded(url)) return;
+
+ const pluginKey = this._getPluginKeyFromUrl(url);
+ // Skip if already installed.
+ if (this._plugins.has(pluginKey)) return;
+ this._plugins.set(pluginKey, {
+ name: pluginKey,
+ url,
+ state: PluginState.PENDING,
+ plugin: null,
+ });
+
+ if (this._isPathEndsWith(url, '.html')) {
+ this._importHtmlPlugin(path, opts && opts[path]);
+ } else if (this._isPathEndsWith(url, '.js')) {
+ this._loadJsPlugin(path);
+ } else {
+ this._failToLoad(`Unrecognized plugin path ${path}`, path);
+ }
+ });
+
+ this.awaitPluginsLoaded().then(() => {
+ console.info('Plugins loaded');
+ this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames());
+ });
+ }
+
+ _isPathEndsWith(url: string | URL, suffix: string) {
+ if (!(url instanceof URL)) {
+ try {
+ url = new URL(url);
+ } catch (e) {
+ console.warn(e);
+ return false;
+ }
+ }
+
+ return url.pathname && url.pathname.endsWith(suffix);
+ }
+
+ _getAllInstalledPluginNames() {
+ const installedPlugins = [];
+ for (const plugin of this._plugins.values()) {
+ if (plugin.state === PluginState.LOADED) {
+ installedPlugins.push(plugin.name);
+ }
+ }
+ return installedPlugins;
+ }
+
+ install(
+ callback: (plugin: PluginApi) => void,
+ version?: string,
+ src?: string
+ ) {
+ // HTML import polyfill adds __importElement pointing to the import tag.
+ const gerritScript = document.currentScript as GerritScriptElement | null;
+ const script = gerritScript?.__importElement ?? gerritScript;
+ if (!src && script && script.src) {
+ src = script.src;
+ }
+ if ((!src || src.startsWith('data:')) && script && script.baseURI) {
+ src = script && script.baseURI;
+ }
+ if (!src) {
+ this._failToLoad('Failed to determine src.');
+ return;
+ }
+ if (version && version !== API_VERSION) {
+ this._failToLoad(
+ `Plugin ${src} install error: only version ${API_VERSION} is supported in PolyGerrit. ${version} was given.`,
+ src
+ );
+ return;
+ }
+
+ const url = this._urlFor(src);
+ const pluginObject = this.getPlugin(url);
+ let plugin = pluginObject && pluginObject.plugin;
+ if (!plugin) {
+ plugin = new Plugin(url);
+ }
+ try {
+ callback(plugin);
+ this._pluginInstalled(url, plugin);
+ } catch (e) {
+ this._failToLoad(`${e.name}: ${e.message}`, src);
+ }
+ }
+
+ // The polygerrit uses version of sinon where you can't stub getter,
+ // declare it as a function here
+ arePluginsLoaded() {
+ // As the size of plugins is relatively small,
+ // so the performance of this check should be reasonable
+ if (!this._pluginListLoaded) return false;
+ for (const plugin of this._plugins.values()) {
+ if (plugin.state === PluginState.PENDING) return false;
+ }
+ return true;
+ }
+
+ _checkIfCompleted() {
+ if (this.arePluginsLoaded()) {
+ getPluginEndpoints().setPluginsReady();
+ if (this._loadingResolver) {
+ this._loadingResolver();
+ this._loadingResolver = null;
+ this._loadingPromise = null;
+ }
+ }
+ }
+
+ _timeout() {
+ const pendingPlugins = [];
+ for (const plugin of this._plugins.values()) {
+ if (plugin.state === PluginState.PENDING) {
+ this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
+ this._checkIfCompleted();
+ pendingPlugins.push(plugin.url);
+ }
+ }
+ return `Timeout when loading plugins: ${pendingPlugins.join(',')}`;
+ }
+
+ _failToLoad(message: string, pluginUrl?: string) {
+ // Show an alert with the error
+ document.dispatchEvent(
+ new CustomEvent('show-alert', {
+ detail: {
+ message: `Plugin install error: ${message} from ${pluginUrl}`,
+ },
+ })
+ );
+ if (pluginUrl) this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
+ this._checkIfCompleted();
+ }
+
+ _updatePluginState(pluginUrl: string, state: PluginState): PluginObject {
+ const key = this._getPluginKeyFromUrl(pluginUrl);
+ if (this._plugins.has(key)) {
+ this._plugins.get(key)!.state = state;
+ } else {
+ // Plugin is not recorded for some reason.
+ console.info(`Plugin loaded separately: ${pluginUrl}`);
+ this._plugins.set(key, {
+ name: key,
+ url: pluginUrl,
+ state,
+ plugin: null,
+ });
+ }
+ return this._plugins.get(key)!;
+ }
+
+ _pluginInstalled(url: string, plugin: PluginApi) {
+ const pluginObj = this._updatePluginState(url, PluginState.LOADED);
+ pluginObj.plugin = plugin;
+ this._getReporting().pluginLoaded(plugin.getPluginName() || url);
+ console.info(`Plugin ${plugin.getPluginName() || url} installed.`);
+ this._checkIfCompleted();
+ }
+
+ installPreloadedPlugins() {
+ const Gerrit = window.Gerrit as GerritGlobal;
+ if (!Gerrit || !Gerrit._preloadedPlugins) {
+ return;
+ }
+ for (const name of Object.keys(Gerrit._preloadedPlugins)) {
+ const callback = Gerrit._preloadedPlugins[name];
+ this.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
+ }
+ }
+
+ isPluginPreloaded(pathOrUrl: string) {
+ const url = this._urlFor(pathOrUrl);
+ const name = getPluginNameFromUrl(url);
+ const Gerrit = window.Gerrit as GerritGlobal;
+ if (name && Gerrit?._preloadedPlugins) {
+ return hasOwnProperty(Gerrit._preloadedPlugins, name);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Checks if given plugin path/url is enabled or not.
+ */
+ isPluginEnabled(pathOrUrl: string) {
+ const url = this._urlFor(pathOrUrl);
+ if (this.isPluginPreloaded(url)) return true;
+ const key = this._getPluginKeyFromUrl(url);
+ return this._plugins.has(key);
+ }
+
+ /**
+ * Returns the plugin object with a given url.
+ */
+ getPlugin(pathOrUrl: string) {
+ const url = this._urlFor(pathOrUrl);
+ const key = this._getPluginKeyFromUrl(url);
+ return this._plugins.get(key);
+ }
+
+ /**
+ * Checks if given plugin path/url is loaded or not.
+ */
+ isPluginLoaded(pathOrUrl: string): boolean {
+ const url = this._urlFor(pathOrUrl);
+ const key = this._getPluginKeyFromUrl(url);
+ return this._plugins.has(key)
+ ? this._plugins.get(key)!.state === PluginState.LOADED
+ : false;
+ }
+
+ _importHtmlPlugin(pluginUrl: string, opts: PluginOption = {}) {
+ const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
+ const urlWithoutAP = this._urlFor(pluginUrl);
+ let onerror = undefined;
+ if (urlWithAP !== urlWithoutAP) {
+ onerror = () => this._loadHtmlPlugin(urlWithoutAP, opts.sync);
+ }
+ this._loadHtmlPlugin(urlWithAP, opts.sync, onerror);
+ }
+
+ _loadHtmlPlugin(url: string, sync?: boolean, onerror?: (e: Event) => void) {
+ if (!onerror) {
+ onerror = () => {
+ this._failToLoad(`${url} import error`, url);
+ };
+ }
+
+ importHref(url, () => {}, onerror, !sync);
+ }
+
+ _loadJsPlugin(pluginUrl: string) {
+ const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
+ const urlWithoutAP = this._urlFor(pluginUrl);
+ let onerror = undefined;
+ if (urlWithAP !== urlWithoutAP) {
+ onerror = () => this._createScriptTag(urlWithoutAP);
+ }
+
+ this._createScriptTag(urlWithAP, onerror);
+ }
+
+ _createScriptTag(url: string, onerror?: OnErrorEventHandler) {
+ if (!onerror) {
+ onerror = () => this._failToLoad(`${url} load error`, url);
+ }
+
+ const el = document.createElement('script');
+ el.defer = true;
+ el.setAttribute('src', url);
+ // no credentials to send when fetch plugin js
+ // and this will help provide more meaningful error than
+ // 'Script error.'
+ el.setAttribute('crossorigin', 'anonymous');
+ el.onerror = onerror;
+ return document.body.appendChild(el);
+ }
+
+ _urlFor(pathOrUrl: string, assetsPath?: string): string {
+ // theme is per host, should always load from assetsPath
+ const isThemeFile =
+ pathOrUrl.endsWith('static/gerrit-theme.html') ||
+ pathOrUrl.endsWith('static/gerrit-theme.js');
+ const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
+ if (
+ pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
+ pathOrUrl.startsWith('http')
+ ) {
+ // Plugins are loaded from another domain or preloaded.
+ if (
+ pathOrUrl.includes(location.host) &&
+ shouldTryLoadFromAssetsPathFirst &&
+ assetsPath
+ ) {
+ // if is loading from host server, try replace with cdn when assetsPath provided
+ return pathOrUrl.replace(location.origin, assetsPath);
+ }
+ return pathOrUrl;
+ }
+
+ if (!pathOrUrl.startsWith('/')) {
+ pathOrUrl = '/' + pathOrUrl;
+ }
+
+ if (shouldTryLoadFromAssetsPathFirst && assetsPath) {
+ return assetsPath + pathOrUrl;
+ }
+
+ return window.location.origin + getBaseUrl() + pathOrUrl;
+ }
+
+ awaitPluginsLoaded() {
+ // Resolve if completed.
+ this._checkIfCompleted();
+
+ if (this.arePluginsLoaded()) {
+ return Promise.resolve();
+ }
+ if (!this._loadingPromise) {
+ // TODO(TS): Should be a number, but TS thinks that is must be some weird
+ // NodeJS.Timeout object.
+ let timerId: any;
+ this._loadingPromise = Promise.race([
+ new Promise(resolve => (this._loadingResolver = resolve)),
+ new Promise(
+ (_, reject) =>
+ (timerId = setTimeout(() => {
+ reject(new Error(this._timeout()));
+ }, PLUGIN_LOADING_TIMEOUT_MS))
+ ),
+ ]).finally(() => {
+ if (timerId) clearTimeout(timerId);
+ }) as Promise<void>;
+ }
+ return this._loadingPromise;
+ }
+}
+
+// TODO(dmfilippov): Convert to service and add to appContext
+let pluginLoader = new PluginLoader();
+export function _testOnly_resetPluginLoader() {
+ pluginLoader = new PluginLoader();
+ return pluginLoader;
+}
+
+export function getPluginLoader() {
+ return pluginLoader;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
index 1e6938e..584cd39 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-loader_test.js
@@ -33,9 +33,10 @@
let url;
let sendStub;
let pluginLoader;
+ let clock;
setup(() => {
- window.clock = sinon.useFakeTimers();
+ clock = sinon.useFakeTimers();
sendStub = sinon.stub().returns(Promise.resolve({status: 200}));
stub('gr-rest-api-interface', {
@@ -53,7 +54,7 @@
});
teardown(() => {
- window.clock.restore();
+ clock.restore();
resetPlugins();
});
@@ -83,18 +84,16 @@
assert(callback.notCalled);
});
- test('report pluginsLoaded', done => {
+ test('report pluginsLoaded', async () => {
const pluginsLoadedStub = sinon.stub(pluginLoader._getReporting(),
'pluginsLoaded');
pluginsLoadedStub.reset();
window.Gerrit._loadPlugins([]);
- flush(() => {
- assert.isTrue(pluginsLoadedStub.called);
- done();
- });
+ await flush();
+ assert.isTrue(pluginsLoadedStub.called);
});
- test('arePluginsLoaded', done => {
+ test('arePluginsLoaded', () => {
assert.isFalse(pluginLoader.arePluginsLoaded());
const plugins = [
'http://test.com/plugins/foo/static/test.js',
@@ -104,15 +103,13 @@
pluginLoader.loadPlugins(plugins);
assert.isFalse(pluginLoader.arePluginsLoaded());
// Timeout on loading plugins
- window.clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
+ clock.tick(PLUGIN_LOADING_TIMEOUT_MS * 2);
- flush(() => {
- assert.isTrue(pluginLoader.arePluginsLoaded());
- done();
- });
+ flush();
+ assert.isTrue(pluginLoader.arePluginsLoaded());
});
- test('plugins installed successfully', done => {
+ test('plugins installed successfully', async () => {
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
pluginApi.install(() => void 0, undefined, url);
});
@@ -125,14 +122,12 @@
];
pluginLoader.loadPlugins(plugins);
- flush(() => {
- assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
- assert.isTrue(pluginLoader.arePluginsLoaded());
- done();
- });
+ await flush();
+ assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+ assert.isTrue(pluginLoader.arePluginsLoaded());
});
- test('isPluginEnabled and isPluginLoaded', done => {
+ test('isPluginEnabled and isPluginLoaded', () => {
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
pluginApi.install(() => void 0, undefined, url);
});
@@ -147,17 +142,14 @@
plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
);
- flush(() => {
- assert.isTrue(pluginLoader.arePluginsLoaded());
- assert.isTrue(
- plugins.every(plugin => pluginLoader.isPluginLoaded(plugin))
- );
-
- done();
- });
+ flush();
+ assert.isTrue(pluginLoader.arePluginsLoaded());
+ assert.isTrue(
+ plugins.every(plugin => pluginLoader.isPluginLoaded(plugin))
+ );
});
- test('plugins installed mixed result, 1 fail 1 succeed', done => {
+ test('plugins installed mixed result, 1 fail 1 succeed', async () => {
const plugins = [
'http://test.com/plugins/foo/static/test.js',
'http://test.com/plugins/bar/static/test.js',
@@ -179,15 +171,13 @@
pluginLoader.loadPlugins(plugins);
- flush(() => {
- assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
- assert.isTrue(pluginLoader.arePluginsLoaded());
- assert.isTrue(alertStub.calledOnce);
- done();
- });
+ await flush();
+ assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+ assert.isTrue(pluginLoader.arePluginsLoaded());
+ assert.isTrue(alertStub.calledOnce);
});
- test('isPluginEnabled and isPluginLoaded for mixed results', done => {
+ test('isPluginEnabled and isPluginLoaded for mixed results', async () => {
const plugins = [
'http://test.com/plugins/foo/static/test.js',
'http://test.com/plugins/bar/static/test.js',
@@ -212,17 +202,15 @@
plugins.every(plugin => pluginLoader.isPluginEnabled(plugin))
);
- flush(() => {
- assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
- assert.isTrue(pluginLoader.arePluginsLoaded());
- assert.isTrue(alertStub.calledOnce);
- assert.isTrue(pluginLoader.isPluginLoaded(plugins[1]));
- assert.isFalse(pluginLoader.isPluginLoaded(plugins[0]));
- done();
- });
+ await flush();
+ assert.isTrue(pluginsLoadedStub.calledWithExactly(['bar']));
+ assert.isTrue(pluginLoader.arePluginsLoaded());
+ assert.isTrue(alertStub.calledOnce);
+ assert.isTrue(pluginLoader.isPluginLoaded(plugins[1]));
+ assert.isFalse(pluginLoader.isPluginLoaded(plugins[0]));
});
- test('plugins installed all failed', done => {
+ test('plugins installed all failed', async () => {
const plugins = [
'http://test.com/plugins/foo/static/test.js',
'http://test.com/plugins/bar/static/test.js',
@@ -242,15 +230,13 @@
pluginLoader.loadPlugins(plugins);
- flush(() => {
- assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
- assert.isTrue(pluginLoader.arePluginsLoaded());
- assert.isTrue(alertStub.calledTwice);
- done();
- });
+ await flush();
+ assert.isTrue(pluginsLoadedStub.calledWithExactly([]));
+ assert.isTrue(pluginLoader.arePluginsLoaded());
+ assert.isTrue(alertStub.calledTwice);
});
- test('plugins installed failed becasue of wrong version', done => {
+ test('plugins installed failed becasue of wrong version', async () => {
const plugins = [
'http://test.com/plugins/foo/static/test.js',
'http://test.com/plugins/bar/static/test.js',
@@ -269,15 +255,13 @@
pluginLoader.loadPlugins(plugins);
- flush(() => {
- assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
- assert.isTrue(pluginLoader.arePluginsLoaded());
- assert.isTrue(alertStub.calledOnce);
- done();
- });
+ await flush();
+ assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo']));
+ assert.isTrue(pluginLoader.arePluginsLoaded());
+ assert.isTrue(alertStub.calledOnce);
});
- test('multiple assets for same plugin installed successfully', done => {
+ test('multiple assets for same plugin installed successfully', async () => {
sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
pluginApi.install(() => void 0, undefined, url);
});
@@ -291,11 +275,9 @@
];
pluginLoader.loadPlugins(plugins);
- flush(() => {
- assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
- assert.isTrue(pluginLoader.arePluginsLoaded());
- done();
- });
+ await flush();
+ assert.isTrue(pluginsLoadedStub.calledWithExactly(['foo', 'bar']));
+ assert.isTrue(pluginLoader.arePluginsLoaded());
});
suite('plugin path and url', () => {
@@ -457,7 +439,7 @@
assert.isTrue(document.body.appendChild.calledTwice);
});
- test('can call awaitPluginsLoaded multiple times', done => {
+ test('can call awaitPluginsLoaded multiple times', async () => {
const plugins = [
'http://e.com/foo/bar.js',
'http://e.com/bar/foo.js',
@@ -475,13 +457,9 @@
pluginLoader.loadPlugins(plugins);
- pluginLoader.awaitPluginsLoaded().then(() => {
- assert.isTrue(installed);
-
- pluginLoader.awaitPluginsLoaded().then(() => {
- done();
- });
- });
+ await pluginLoader.awaitPluginsLoaded();
+ assert.isTrue(installed);
+ await pluginLoader.awaitPluginsLoaded();
});
suite('preloaded plugins', () => {
@@ -540,4 +518,3 @@
});
});
});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
deleted file mode 100644
index 31ff8ee..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
+++ /dev/null
@@ -1,151 +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.
- */
-
-let restApi;
-
-export function _testOnlyResetRestApi() {
- restApi = null;
-}
-
-function getRestApi() {
- if (!restApi) {
- restApi = document.createElement('gr-rest-api-interface');
- }
- return restApi;
-}
-
-export function GrPluginRestApi(opt_prefix) {
- this.opt_prefix = opt_prefix || '';
-}
-
-GrPluginRestApi.prototype.getLoggedIn = function() {
- return getRestApi().getLoggedIn();
-};
-
-GrPluginRestApi.prototype.getVersion = function() {
- return getRestApi().getVersion();
-};
-
-GrPluginRestApi.prototype.getConfig = function() {
- return getRestApi().getConfig();
-};
-
-GrPluginRestApi.prototype.invalidateReposCache = function() {
- getRestApi().invalidateReposCache();
-};
-
-GrPluginRestApi.prototype.getAccount = function() {
- return getRestApi().getAccount();
-};
-
-GrPluginRestApi.prototype.getAccountCapabilities = function(capabilities) {
- return getRestApi().getAccountCapabilities(capabilities);
-};
-
-GrPluginRestApi.prototype.getRepos =
- function(filter, reposPerPage, opt_offset) {
- return getRestApi().getRepos(filter, reposPerPage, opt_offset);
- };
-
-/**
- * Fetch and return native browser REST API Response.
- *
- * @param {string} method HTTP Method (GET, POST, etc)
- * @param {string} url URL without base path or plugin prefix
- * @param {Object=} payload Respected for POST and PUT only.
- * @param {?function(?Response, string=)=} opt_errFn
- * passed as null sometimes.
- * @return {!Promise}
- */
-GrPluginRestApi.prototype.fetch = function(method, url, opt_payload,
- opt_errFn, opt_contentType) {
- return getRestApi().send(method, this.opt_prefix + url, opt_payload,
- opt_errFn, opt_contentType);
-};
-
-/**
- * Fetch and parse REST API response, if request succeeds.
- *
- * @param {string} method HTTP Method (GET, POST, etc)
- * @param {string} url URL without base path or plugin prefix
- * @param {Object=} payload Respected for POST and PUT only.
- * @param {?function(?Response, string=)=} opt_errFn
- * passed as null sometimes.
- * @return {!Promise} resolves on success, rejects on error.
- */
-GrPluginRestApi.prototype.send = function(method, url, opt_payload,
- opt_errFn, opt_contentType) {
- return this.fetch(method, url, opt_payload, opt_errFn, opt_contentType)
- .then(response => {
- if (response.status < 200 || response.status >= 300) {
- return response.text().then(text => {
- if (text) {
- return Promise.reject(new Error(text));
- } else {
- return Promise.reject(new Error(response.status));
- }
- });
- } else {
- return getRestApi().getResponseObject(response);
- }
- });
-};
-
-/**
- * @param {string} url URL without base path or plugin prefix
- * @return {!Promise} resolves on success, rejects on error.
- */
-GrPluginRestApi.prototype.get = function(url) {
- return this.send('GET', url);
-};
-
-/**
- * @param {string} url URL without base path or plugin prefix
- * @return {!Promise} resolves on success, rejects on error.
- */
-GrPluginRestApi.prototype.post = function(url, opt_payload, opt_errFn,
- opt_contentType) {
- return this.send('POST', url, opt_payload, opt_errFn, opt_contentType);
-};
-
-/**
- * @param {string} url URL without base path or plugin prefix
- * @return {!Promise} resolves on success, rejects on error.
- */
-GrPluginRestApi.prototype.put = function(url, opt_payload, opt_errFn,
- opt_contentType) {
- return this.send('PUT', url, opt_payload, opt_errFn, opt_contentType);
-};
-
-/**
- * @param {string} url URL without base path or plugin prefix
- * @return {!Promise} resolves on 204, rejects on error.
- */
-GrPluginRestApi.prototype.delete = function(url) {
- return this.fetch('DELETE', url).then(response => {
- if (response.status !== 204) {
- return response.text().then(text => {
- if (text) {
- return Promise.reject(new Error(text));
- } else {
- return Promise.reject(new Error(response.status));
- }
- });
- }
- return response;
- });
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
new file mode 100644
index 0000000..00b2963
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.ts
@@ -0,0 +1,182 @@
+/**
+ * @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 {
+ ErrorCallback,
+ RestApiService,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {HttpMethod} from '../../../constants/constants';
+import {RequestPayload} from '../../../types/common';
+
+let restApi: RestApiService | null = null;
+
+export function _testOnlyResetRestApi() {
+ restApi = null;
+}
+
+function getRestApi(): RestApiService {
+ if (!restApi) {
+ restApi = (document.createElement(
+ 'gr-rest-api-interface'
+ ) as unknown) as RestApiService;
+ }
+ return restApi;
+}
+
+export class GrPluginRestApi {
+ constructor(private readonly prefix = '') {}
+
+ getLoggedIn() {
+ return getRestApi().getLoggedIn();
+ }
+
+ getVersion() {
+ return getRestApi().getVersion();
+ }
+
+ getConfig() {
+ return getRestApi().getConfig();
+ }
+
+ invalidateReposCache() {
+ getRestApi().invalidateReposCache();
+ }
+
+ getAccount() {
+ return getRestApi().getAccount();
+ }
+
+ getAccountCapabilities(capabilities: string[]) {
+ return getRestApi().getAccountCapabilities(capabilities);
+ }
+
+ getRepos(filter: string, reposPerPage: number, offset?: number) {
+ return getRestApi().getRepos(filter, reposPerPage, offset);
+ }
+
+ fetch(
+ method: HttpMethod,
+ url: string,
+ payload?: RequestPayload,
+ errFn?: undefined,
+ contentType?: string
+ ): Promise<Response>;
+
+ fetch(
+ method: HttpMethod,
+ url: string,
+ payload: RequestPayload | undefined,
+ errFn: ErrorCallback,
+ contentType?: string
+ ): Promise<Response | void>;
+
+ fetch(
+ method: HttpMethod,
+ url: string,
+ payload: RequestPayload | undefined,
+ errFn?: ErrorCallback,
+ contentType?: string
+ ): Promise<Response | void>;
+
+ /**
+ * Fetch and return native browser REST API Response.
+ */
+ fetch(
+ method: HttpMethod,
+ url: string,
+ payload?: RequestPayload,
+ errFn?: ErrorCallback,
+ contentType?: string
+ ): Promise<Response | void> {
+ return getRestApi().send(
+ method,
+ this.prefix + url,
+ payload,
+ errFn,
+ contentType
+ );
+ }
+
+ /**
+ * Fetch and parse REST API response, if request succeeds.
+ */
+ send(
+ method: HttpMethod,
+ url: string,
+ payload?: RequestPayload,
+ errFn?: ErrorCallback,
+ contentType?: string
+ ) {
+ return this.fetch(method, url, payload, errFn, contentType).then(
+ response => {
+ if (!response) {
+ // TODO(TS): Fix method definition
+ // If errFn exists and doesn't throw an exception, the fetch method
+ // returns empty response
+ throw new Error('errFn must throw an exception');
+ }
+ if (response.status < 200 || response.status >= 300) {
+ return response.text().then(text => {
+ if (text) {
+ return Promise.reject(new Error(text));
+ } else {
+ return Promise.reject(new Error(`${response.status}`));
+ }
+ });
+ } else {
+ return getRestApi().getResponseObject(response);
+ }
+ }
+ );
+ }
+
+ get(url: string) {
+ return this.send(HttpMethod.GET, url);
+ }
+
+ post(
+ url: string,
+ payload?: RequestPayload,
+ errFn?: ErrorCallback,
+ contentType?: string
+ ) {
+ return this.send(HttpMethod.POST, url, payload, errFn, contentType);
+ }
+
+ put(
+ url: string,
+ payload?: RequestPayload,
+ errFn?: ErrorCallback,
+ contentType?: string
+ ) {
+ return this.send(HttpMethod.PUT, url, payload, errFn, contentType);
+ }
+
+ delete(url: string) {
+ return this.fetch(HttpMethod.DELETE, url).then(response => {
+ if (response.status !== 204) {
+ return response.text().then(text => {
+ if (text) {
+ return Promise.reject(new Error(text));
+ } else {
+ return Promise.reject(new Error(`${response.status}`));
+ }
+ });
+ }
+ return response;
+ });
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
deleted file mode 100644
index 0256ad1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ /dev/null
@@ -1,441 +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 {getBaseUrl} from '../../../utils/url-util.js';
-import {getSharedApiEl} from '../../../utils/dom-util.js';
-import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper.js';
-import {GrChangeActionsInterface} from './gr-change-actions-js-api.js';
-import {GrChangeReplyInterface} from './gr-change-reply-js-api.js';
-import {GrDomHooksManager} from '../../plugins/gr-dom-hooks/gr-dom-hooks.js';
-import {GrThemeApi} from '../../plugins/gr-theme-api/gr-theme-api.js';
-import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface.js';
-import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api.js';
-import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api.js';
-import {GrChangeMetadataApi} from '../../plugins/gr-change-metadata-api/gr-change-metadata-api.js';
-import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper.js';
-import {GrPluginRestApi} from './gr-plugin-rest-api.js';
-import {GrRepoApi} from '../../plugins/gr-repo-api/gr-repo-api.js';
-import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api.js';
-import {GrStylesApi} from '../../plugins/gr-styles-api/gr-styles-api.js';
-import {GrPluginActionContext} from './gr-plugin-action-context.js';
-import {pluginEndpoints} from './gr-plugin-endpoints.js';
-
-import {
- PRELOADED_PROTOCOL,
- getPluginNameFromUrl,
- send,
-} from './gr-api-utils.js';
-
-const PANEL_ENDPOINTS_MAPPING = {
- CHANGE_SCREEN_BELOW_COMMIT_INFO_BLOCK: 'change-view-integration',
- CHANGE_SCREEN_BELOW_CHANGE_INFO_BLOCK: 'change-metadata-item',
-};
-
-/**
- * Plugin-provided custom components can affect content in extension
- * points using one of following methods:
- * - DECORATE: custom component is set with `content` attribute and may
- * decorate (e.g. style) DOM element.
- * - REPLACE: contents of extension point are replaced with the custom
- * component.
- * - STYLE: custom component is a shared styles module that is inserted
- * into the extension point.
- */
-const EndpointType = {
- DECORATE: 'decorate',
- REPLACE: 'replace',
- STYLE: 'style',
-};
-
-export class Plugin {
- constructor(opt_url) {
- this._domHooks = new GrDomHooksManager(this);
-
- if (!opt_url) {
- console.warn(
- 'Plugin not being loaded from /plugins base path.',
- 'Unable to determine name.'
- );
- return this;
- }
- this.deprecated = {
- _loadedGwt: deprecatedAPI._loadedGwt.bind(this),
- install: deprecatedAPI.install.bind(this),
- onAction: deprecatedAPI.onAction.bind(this),
- panel: deprecatedAPI.panel.bind(this),
- popup: deprecatedAPI.popup.bind(this),
- screen: deprecatedAPI.screen.bind(this),
- settingsScreen: deprecatedAPI.settingsScreen.bind(this),
- };
-
- this._url = new URL(opt_url);
- this._name = getPluginNameFromUrl(this._url);
- this.sharedApiElement = getSharedApiEl();
- }
-
- getPluginName() {
- return this._name;
- }
-
- registerStyleModule(endpoint, moduleName) {
- pluginEndpoints.registerModule(this, {
- endpoint,
- type: EndpointType.STYLE,
- moduleName,
- });
- }
-
- /**
- * Registers an endpoint for the plugin.
- */
- registerCustomComponent(endpointName, opt_moduleName, opt_options) {
- return this._registerCustomComponent(
- endpointName,
- opt_moduleName,
- opt_options
- );
- }
-
- /**
- * Registers a dynamic endpoint for the plugin.
- *
- * Dynamic plugins are registered by specific prefix, such as
- * 'change-list-header'.
- */
- registerDynamicCustomComponent(endpointName, opt_moduleName, opt_options) {
- const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
- return this._registerCustomComponent(
- fullEndpointName,
- opt_moduleName,
- opt_options,
- endpointName
- );
- }
-
- _registerCustomComponent(
- endpoint,
- opt_moduleName,
- opt_options,
- dynamicEndpoint
- ) {
- const type =
- opt_options && opt_options.replace
- ? EndpointType.REPLACE
- : EndpointType.DECORATE;
- const slot = (opt_options && opt_options.slot) || '';
- const domHook = this._domHooks.getDomHook(endpoint, opt_moduleName);
- const moduleName = opt_moduleName || domHook.getModuleName();
- pluginEndpoints.registerModule(this, {
- slot,
- endpoint,
- type,
- moduleName,
- domHook,
- dynamicEndpoint,
- });
- return domHook.getPublicAPI();
- }
-
- /**
- * Returns instance of DOM hook API for endpoint. Creates a placeholder
- * element for the first call.
- */
- hook(endpointName, opt_options) {
- return this.registerCustomComponent(endpointName, undefined, opt_options);
- }
-
- getServerInfo() {
- return document.createElement('gr-rest-api-interface').getConfig();
- }
-
- on(eventName, callback) {
- this.sharedApiElement.addEventCallback(eventName, callback);
- }
-
- url(opt_path) {
- const relPath = '/plugins/' + this._name + (opt_path || '/');
- const sameOriginPath = window.location.origin + `${getBaseUrl()}${relPath}`;
- if (window.location.origin === this._url.origin) {
- // Plugin loaded from the same origin as gr-app, getBaseUrl in effect.
- return sameOriginPath;
- } else if (this._url.protocol === PRELOADED_PROTOCOL) {
- // Plugin is preloaded, load plugin with ASSETS_PATH or location.origin
- return window.ASSETS_PATH
- ? `${window.ASSETS_PATH}${relPath}`
- : sameOriginPath;
- } else {
- // Plugin loaded from assets bundle, expect assets placed along with it.
- return this._url.href.split('/plugins/' + this._name)[0] + relPath;
- }
- }
-
- screenUrl(opt_screenName) {
- const origin = location.origin;
- const base = getBaseUrl();
- const tokenPart = opt_screenName ? '/' + opt_screenName : '';
- return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
- }
-
- _send(method, url, opt_callback, opt_payload) {
- return send(method, this.url(url), opt_callback, opt_payload);
- }
-
- get(url, opt_callback) {
- console.warn('.get() is deprecated! Use .restApi().get()');
- return this._send('GET', url, opt_callback);
- }
-
- post(url, payload, opt_callback) {
- console.warn('.post() is deprecated! Use .restApi().post()');
- return this._send('POST', url, opt_callback, payload);
- }
-
- put(url, payload, opt_callback) {
- console.warn('.put() is deprecated! Use .restApi().put()');
- return this._send('PUT', url, opt_callback, payload);
- }
-
- delete(url, opt_callback) {
- console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
- return this.restApi()
- .delete(this.url(url))
- .then(res => {
- if (opt_callback) {
- opt_callback(res);
- }
- return res;
- });
- }
-
- annotationApi() {
- return new GrAnnotationActionsInterface(this);
- }
-
- changeActions() {
- return new GrChangeActionsInterface(
- this,
- this.sharedApiElement.getElement(
- this.sharedApiElement.Element.CHANGE_ACTIONS
- )
- );
- }
-
- changeReply() {
- return new GrChangeReplyInterface(this, this.sharedApiElement);
- }
-
- theme() {
- return new GrThemeApi(this);
- }
-
- project() {
- return new GrRepoApi(this);
- }
-
- changeMetadata() {
- return new GrChangeMetadataApi(this);
- }
-
- admin() {
- return new GrAdminApi(this);
- }
-
- settings() {
- return new GrSettingsApi(this);
- }
-
- styles() {
- return new GrStylesApi();
- }
-
- /**
- * To make REST requests for plugin-provided endpoints, use
- *
- * @example
- * const pluginRestApi = plugin.restApi(plugin.url());
- *
- * @param {string=} opt_prefix url for subsequent .get(), .post() etc requests.
- */
- restApi(opt_prefix) {
- return new GrPluginRestApi(opt_prefix);
- }
-
- attributeHelper(element) {
- return new GrAttributeHelper(element);
- }
-
- eventHelper(element) {
- return new GrEventHelper(element);
- }
-
- popup(moduleName) {
- if (typeof moduleName !== 'string') {
- console.error('.popup(element) deprecated, use .popup(moduleName)!');
- return;
- }
- const api = new GrPopupInterface(this, moduleName);
- return api.open();
- }
-
- panel() {
- console.error(
- '.panel() is deprecated! ' + 'Use registerCustomComponent() instead.'
- );
- }
-
- settingsScreen() {
- console.error(
- '.settingsScreen() is deprecated! ' + 'Use .settings() instead.'
- );
- }
-
- screen(screenName, opt_moduleName) {
- if (opt_moduleName && typeof opt_moduleName !== 'string') {
- console.error(
- '.screen(pattern, callback) deprecated, use ' +
- '.screen(screenName, opt_moduleName)!'
- );
- return;
- }
- return this.registerCustomComponent(
- this._getScreenName(screenName),
- opt_moduleName
- );
- }
-
- _getScreenName(screenName) {
- return `${this.getPluginName()}-screen-${screenName}`;
- }
-}
-
-// TODO: should be removed soon after all core plugins moved away from it.
-const deprecatedAPI = {
- _loadedGwt: () => {},
-
- install() {
- console.log('Installing deprecated APIs is deprecated!');
- for (const method in this.deprecated) {
- if (method === 'install') continue;
- this[method] = this.deprecated[method];
- }
- },
-
- popup(el) {
- console.warn(
- 'plugin.deprecated.popup() is deprecated, '
- + 'use plugin.popup() insted!'
- );
- if (!el) {
- throw new Error('Popup contents not found');
- }
- const api = new GrPopupInterface(this);
- api.open().then(api => api._getElement().appendChild(el));
- return api;
- },
-
- onAction(type, action, callback) {
- console.warn(
- 'plugin.deprecated.onAction() is deprecated,' +
- ' use plugin.changeActions() instead!'
- );
- if (type !== 'change' && type !== 'revision') {
- console.warn(`${type} actions are not supported.`);
- return;
- }
- this.on('showchange', (change, revision) => {
- const details = this.changeActions().getActionDetails(action);
- if (!details) {
- console.warn(
- `${this.getPluginName()} onAction error: ${action} not found!`
- );
- return;
- }
- this.changeActions().addTapListener(details.__key, () => {
- callback(new GrPluginActionContext(this, details, change, revision));
- });
- });
- },
-
- screen(pattern, callback) {
- console.warn(
- 'plugin.deprecated.screen is deprecated,'
- + ' use plugin.screen instead!'
- );
- if (pattern instanceof RegExp) {
- console.error(
- 'deprecated.screen() does not support RegExp. ' +
- 'Please use strings for patterns.'
- );
- return;
- }
- this.hook(this._getScreenName(pattern)).onAttached(el => {
- el.style.display = 'none';
- callback({
- body: el,
- token: el.token,
- onUnload: () => {},
- setTitle: () => {},
- setWindowTitle: () => {},
- show: () => {
- el.style.display = 'initial';
- },
- });
- });
- },
-
- settingsScreen(path, menu, callback) {
- console.warn('.settingsScreen() is deprecated! Use .settings() instead.');
- const hook = this.settings().title(menu)
- .token(path)
- .module('div')
- .build();
- hook.onAttached(el => {
- el.style.display = 'none';
- const body = el.querySelector('div');
- callback({
- body,
- onUnload: () => {},
- setTitle: () => {},
- setWindowTitle: () => {},
- show: () => {
- el.style.display = 'initial';
- },
- });
- });
- },
-
- panel(extensionpoint, callback) {
- console.warn(
- '.panel() is deprecated! ' + 'Use registerCustomComponent() instead.'
- );
- const endpoint = PANEL_ENDPOINTS_MAPPING[extensionpoint];
- if (!endpoint) {
- console.warn(`.panel ${extensionpoint} not supported!`);
- return;
- }
- this.hook(endpoint).onAttached(el =>
- callback({
- body: el,
- p: {
- CHANGE_INFO: el.change,
- REVISION_INFO: el.revision,
- },
- onUnload: () => {},
- })
- );
- },
-};
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
new file mode 100644
index 0000000..a0a8c4d
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.ts
@@ -0,0 +1,334 @@
+/**
+ * @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 {getBaseUrl} from '../../../utils/url-util';
+import {getSharedApiEl} from '../../../utils/dom-util';
+import {GrAttributeHelper} from '../../plugins/gr-attribute-helper/gr-attribute-helper';
+import {GrChangeActionsInterface} from './gr-change-actions-js-api';
+import {GrChangeReplyInterface} from './gr-change-reply-js-api';
+import {GrDomHooksManager} from '../../plugins/gr-dom-hooks/gr-dom-hooks';
+import {GrThemeApi} from '../../plugins/gr-theme-api/gr-theme-api';
+import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
+import {GrAdminApi} from '../../plugins/gr-admin-api/gr-admin-api';
+import {GrAnnotationActionsInterface} from './gr-annotation-actions-js-api';
+import {GrChangeMetadataApi} from '../../plugins/gr-change-metadata-api/gr-change-metadata-api';
+import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper';
+import {GrPluginRestApi} from './gr-plugin-rest-api';
+import {GrRepoApi} from '../../plugins/gr-repo-api/gr-repo-api';
+import {GrSettingsApi} from '../../plugins/gr-settings-api/gr-settings-api';
+import {GrStylesApi} from '../../plugins/gr-styles-api/gr-styles-api';
+import {getPluginEndpoints} from './gr-plugin-endpoints';
+
+import {PRELOADED_PROTOCOL, getPluginNameFromUrl, send} from './gr-api-utils';
+import {GrReportingJsApi} from './gr-reporting-js-api';
+import {
+ EventType,
+ HookApi,
+ PluginApi,
+ RegisterOptions,
+ TargetElement,
+} from '../../plugins/gr-plugin-types';
+import {RequestPayload} from '../../../types/common';
+import {HttpMethod} from '../../../constants/constants';
+import {JsApiService} from './gr-js-api-types';
+import {GrChangeActions} from '../../change/gr-change-actions/gr-change-actions';
+import {GrChecksApi} from '../../plugins/gr-checks-api/gr-checks-api';
+
+/**
+ * Plugin-provided custom components can affect content in extension
+ * points using one of following methods:
+ * - DECORATE: custom component is set with `content` attribute and may
+ * decorate (e.g. style) DOM element.
+ * - REPLACE: contents of extension point are replaced with the custom
+ * component.
+ * - STYLE: custom component is a shared styles module that is inserted
+ * into the extension point.
+ */
+enum EndpointType {
+ DECORATE = 'decorate',
+ REPLACE = 'replace',
+ STYLE = 'style',
+}
+
+const PLUGIN_NAME_NOT_SET = 'NULL';
+
+export type SendCallback = (response: unknown) => void;
+
+export class Plugin implements PluginApi {
+ readonly _url?: URL;
+
+ private _domHooks: GrDomHooksManager;
+
+ private readonly _name: string = PLUGIN_NAME_NOT_SET;
+
+ // TODO(TS): Change type to GrJsApiInterface
+ private readonly sharedApiElement: JsApiService;
+
+ constructor(url?: string) {
+ this.sharedApiElement = getSharedApiEl();
+ this._domHooks = new GrDomHooksManager(this);
+
+ if (!url) {
+ console.warn(
+ 'Plugin not being loaded from /plugins base path.',
+ 'Unable to determine name.'
+ );
+ return this;
+ }
+
+ this._url = new URL(url);
+ this._name = getPluginNameFromUrl(this._url) ?? 'NULL';
+ }
+
+ getPluginName() {
+ return this._name;
+ }
+
+ registerStyleModule(endpoint: string, moduleName: string) {
+ getPluginEndpoints().registerModule(this, {
+ endpoint,
+ type: EndpointType.STYLE,
+ moduleName,
+ });
+ }
+
+ /**
+ * Registers an endpoint for the plugin.
+ */
+ registerCustomComponent(
+ endpointName: string,
+ moduleName?: string,
+ options?: RegisterOptions
+ ): HookApi {
+ return this._registerCustomComponent(endpointName, moduleName, options);
+ }
+
+ /**
+ * Registers a dynamic endpoint for the plugin.
+ *
+ * Dynamic plugins are registered by specific prefix, such as
+ * 'change-list-header'.
+ */
+ registerDynamicCustomComponent(
+ endpointName: string,
+ moduleName?: string,
+ options?: RegisterOptions
+ ): HookApi {
+ const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
+ return this._registerCustomComponent(
+ fullEndpointName,
+ moduleName,
+ options,
+ endpointName
+ );
+ }
+
+ _registerCustomComponent(
+ endpoint: string,
+ moduleName?: string,
+ options?: RegisterOptions,
+ dynamicEndpoint?: string
+ ): HookApi {
+ const type =
+ options && options.replace ? EndpointType.REPLACE : EndpointType.DECORATE;
+ const slot = (options && options.slot) || '';
+ const domHook = this._domHooks.getDomHook(endpoint, moduleName);
+ moduleName = moduleName || domHook.getModuleName();
+ getPluginEndpoints().registerModule(this, {
+ slot,
+ endpoint,
+ type,
+ moduleName,
+ domHook,
+ dynamicEndpoint,
+ });
+ return domHook;
+ }
+
+ /**
+ * Returns instance of DOM hook API for endpoint. Creates a placeholder
+ * element for the first call.
+ */
+ hook(endpointName: string, options?: RegisterOptions) {
+ return this.registerCustomComponent(endpointName, undefined, options);
+ }
+
+ getServerInfo() {
+ return document.createElement('gr-rest-api-interface').getConfig();
+ }
+
+ on(eventName: EventType, callback: (...args: any[]) => any) {
+ this.sharedApiElement.addEventCallback(eventName, callback);
+ }
+
+ url(path?: string) {
+ if (!this._url) throw new Error('plugin url not set');
+ const relPath = '/plugins/' + this._name + (path || '/');
+ const sameOriginPath = window.location.origin + `${getBaseUrl()}${relPath}`;
+ if (window.location.origin === this._url.origin) {
+ // Plugin loaded from the same origin as gr-app, getBaseUrl in effect.
+ return sameOriginPath;
+ } else if (this._url.protocol === PRELOADED_PROTOCOL) {
+ // Plugin is preloaded, load plugin with ASSETS_PATH or location.origin
+ return window.ASSETS_PATH
+ ? `${window.ASSETS_PATH}${relPath}`
+ : sameOriginPath;
+ } else {
+ // Plugin loaded from assets bundle, expect assets placed along with it.
+ return this._url.href.split('/plugins/' + this._name)[0] + relPath;
+ }
+ }
+
+ screenUrl(screenName?: string) {
+ const origin = location.origin;
+ const base = getBaseUrl();
+ const tokenPart = screenName ? '/' + screenName : '';
+ return `${origin}${base}/x/${this.getPluginName()}${tokenPart}`;
+ }
+
+ _send(
+ method: HttpMethod,
+ url: string,
+ callback?: SendCallback,
+ payload?: RequestPayload
+ ) {
+ return send(method, this.url(url), callback, payload);
+ }
+
+ get(url: string, callback?: SendCallback) {
+ console.warn('.get() is deprecated! Use .restApi().get()');
+ return this._send(HttpMethod.GET, url, callback);
+ }
+
+ post(url: string, payload: RequestPayload, callback?: SendCallback) {
+ console.warn('.post() is deprecated! Use .restApi().post()');
+ return this._send(HttpMethod.POST, url, callback, payload);
+ }
+
+ put(url: string, payload: RequestPayload, callback?: SendCallback) {
+ console.warn('.put() is deprecated! Use .restApi().put()');
+ return this._send(HttpMethod.PUT, url, callback, payload);
+ }
+
+ delete(url: string, callback?: SendCallback) {
+ console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
+ return this.restApi()
+ .delete(this.url(url))
+ .then(res => {
+ if (callback) callback(res);
+ return res;
+ });
+ }
+
+ annotationApi() {
+ return new GrAnnotationActionsInterface(this);
+ }
+
+ changeActions() {
+ return new GrChangeActionsInterface(
+ this,
+ (this.sharedApiElement.getElement(
+ TargetElement.CHANGE_ACTIONS
+ ) as unknown) as GrChangeActions
+ );
+ }
+
+ changeReply() {
+ return new GrChangeReplyInterface(this, this.sharedApiElement);
+ }
+
+ checks(): GrChecksApi {
+ return new GrChecksApi(this);
+ }
+
+ reporting() {
+ return new GrReportingJsApi(this);
+ }
+
+ theme() {
+ return new GrThemeApi(this);
+ }
+
+ project() {
+ return new GrRepoApi(this);
+ }
+
+ changeMetadata() {
+ return new GrChangeMetadataApi(this);
+ }
+
+ admin() {
+ return new GrAdminApi(this);
+ }
+
+ settings() {
+ return new GrSettingsApi(this);
+ }
+
+ styles() {
+ return new GrStylesApi();
+ }
+
+ /**
+ * To make REST requests for plugin-provided endpoints, use
+ *
+ * @example
+ * const pluginRestApi = plugin.restApi(plugin.url());
+ * @param prefix url for subsequent .get(), .post() etc requests.
+ */
+ restApi(prefix?: string) {
+ return new GrPluginRestApi(prefix);
+ }
+
+ attributeHelper(element: HTMLElement) {
+ return new GrAttributeHelper(element);
+ }
+
+ eventHelper(element: HTMLElement) {
+ return new GrEventHelper(element);
+ }
+
+ popup(): Promise<GrPopupInterface>;
+
+ popup(moduleName: string): Promise<GrPopupInterface>;
+
+ popup(moduleName?: string): Promise<GrPopupInterface | null> {
+ if (moduleName !== undefined && typeof moduleName !== 'string') {
+ console.error('.popup(element) deprecated, use .popup(moduleName)!');
+ return Promise.resolve(null);
+ }
+ return new GrPopupInterface(this, moduleName).open();
+ }
+
+ screen(screenName: string, moduleName?: string) {
+ if (moduleName && typeof moduleName !== 'string') {
+ console.error(
+ '.screen(pattern, callback) deprecated, use ' +
+ '.screen(screenName, moduleName)!'
+ );
+ return;
+ }
+ return this.registerCustomComponent(
+ this._getScreenName(screenName),
+ moduleName
+ );
+ }
+
+ _getScreenName(screenName: string) {
+ return `${this.getPluginName()}-screen-${screenName}`;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
new file mode 100644
index 0000000..87b320c4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api.ts
@@ -0,0 +1,43 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {appContext} from '../../../services/app-context';
+import {EventDetails} from '../../../services/gr-reporting/gr-reporting';
+import {PluginApi} from '../../plugins/gr-plugin-types';
+
+/**
+ * Defines all methods that will be exported to plugin from reporting service.
+ */
+export class GrReportingJsApi {
+ private readonly reporting = appContext.reportingService;
+
+ constructor(private readonly plugin: PluginApi) {}
+
+ reportInteraction(eventName: string, details?: EventDetails) {
+ return this.reporting.reportInteraction(
+ `${this.plugin.getPluginName()}-${eventName}`,
+ details
+ );
+ }
+
+ reportLifeCycle(eventName: string, details?: EventDetails) {
+ return this.reporting.reportLifeCycle(
+ `${this.plugin.getPluginName()}-${eventName}`,
+ details
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
new file mode 100644
index 0000000..1229641
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-reporting-js-api_test.js
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../../test/common-test-setup-karma.js';
+import '../../change/gr-reply-dialog/gr-reply-dialog.js';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
+import {appContext} from '../../../services/app-context.js';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+suite('gr-reporting-js-api tests', () => {
+ let reporting;
+ let plugin;
+
+ setup(() => {
+ stub('gr-rest-api-interface', {
+ getConfig() { return Promise.resolve({}); },
+ getAccount() { return Promise.resolve(null); },
+ });
+ });
+
+ suite('early init', () => {
+ setup(() => {
+ pluginApi.install(p => { plugin = p; }, '0.1',
+ 'http://test.com/plugins/testplugin/static/test.js');
+ reporting = plugin.reporting();
+ });
+
+ teardown(() => {
+ reporting = null;
+ });
+
+ test('redirect reportInteraction call to reportingService', () => {
+ sinon.spy(appContext.reportingService, 'reportInteraction');
+ reporting.reportInteraction('test', {});
+ assert.isTrue(appContext.reportingService.reportInteraction.called);
+ assert.equal(
+ appContext.reportingService.reportInteraction.lastCall.args[0],
+ 'testplugin-test'
+ );
+ assert.deepEqual(
+ appContext.reportingService.reportInteraction.lastCall.args[1],
+ {}
+ );
+ });
+
+ test('redirect reportLifeCycle call to reportingService', () => {
+ sinon.spy(appContext.reportingService, 'reportLifeCycle');
+ reporting.reportLifeCycle('test', {});
+ assert.isTrue(appContext.reportingService.reportLifeCycle.called);
+ assert.equal(
+ appContext.reportingService.reportLifeCycle.lastCall.args[0],
+ 'testplugin-test'
+ );
+ assert.deepEqual(
+ appContext.reportingService.reportLifeCycle.lastCall.args[1],
+ {}
+ );
+ });
+ });
+});
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
deleted file mode 100644
index eb23708..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.js
+++ /dev/null
@@ -1,187 +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 '../../../styles/gr-voting-styles.js';
-import '../../../styles/shared-styles.js';
-import '../gr-account-label/gr-account-label.js';
-import '../gr-account-chip/gr-account-chip.js';
-import '../gr-button/gr-button.js';
-import '../gr-icons/gr-icons.js';
-import '../gr-label/gr-label.js';
-import '../gr-rest-api-interface/gr-rest-api-interface.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-label-info_html.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/** @extends PolymerElement */
-class GrLabelInfo extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-label-info'; }
-
- static get properties() {
- return {
- labelInfo: Object,
- label: String,
- /** @type {?} */
- change: Object,
- account: Object,
- mutable: Boolean,
- };
- }
-
- /**
- * @param {!Object} labelInfo
- * @param {!Object} account
- * @param {Object} changeLabelsRecord not used, but added as a parameter in
- * order to trigger computation when a label is removed from the change.
- */
- _mapLabelInfo(labelInfo, account, changeLabelsRecord) {
- const result = [];
- if (!labelInfo || !account) { return result; }
- if (!labelInfo.values) {
- if (labelInfo.rejected || labelInfo.approved) {
- const ok = labelInfo.approved || !labelInfo.rejected;
- return [{
- value: ok ? '👍️' : '👎️',
- className: ok ? 'positive' : 'negative',
- account: ok ? labelInfo.approved : labelInfo.rejected,
- }];
- }
- return result;
- }
- // Sort votes by positivity.
- const votes = (labelInfo.all || []).sort((a, b) => a.value - b.value);
- const values = Object.keys(labelInfo.values);
- for (const label of votes) {
- if (label.value && label.value != labelInfo.default_value) {
- let labelClassName;
- let labelValPrefix = '';
- if (label.value > 0) {
- labelValPrefix = '+';
- if (parseInt(label.value, 10) ===
- parseInt(values[values.length - 1], 10)) {
- labelClassName = 'max';
- } else {
- labelClassName = 'positive';
- }
- } else if (label.value < 0) {
- if (parseInt(label.value, 10) === parseInt(values[0], 10)) {
- labelClassName = 'min';
- } else {
- labelClassName = 'negative';
- }
- }
- const formattedLabel = {
- value: labelValPrefix + label.value,
- className: labelClassName,
- account: label,
- };
- if (label._account_id === account._account_id) {
- // Put self-votes at the top.
- result.unshift(formattedLabel);
- } else {
- result.push(formattedLabel);
- }
- }
- }
- return result;
- }
-
- /**
- * A user is able to delete a vote iff the mutable property is true and the
- * reviewer that left the vote exists in the list of removable_reviewers
- * received from the backend.
- *
- * @param {!Object} reviewer An object describing the reviewer that left the
- * vote.
- * @param {boolean} mutable
- * @param {!Object} change
- */
- _computeDeleteClass(reviewer, mutable, change) {
- if (!mutable || !change || !change.removable_reviewers) {
- return 'hidden';
- }
- const removable = change.removable_reviewers;
- if (removable.find(r => r._account_id === reviewer._account_id)) {
- return '';
- }
- return 'hidden';
- }
-
- /**
- * Closure annotation for Polymer.prototype.splice is off.
- * For now, suppressing annotations.
- *
- * @suppress {checkTypes} */
- _onDeleteVote(e) {
- e.preventDefault();
- let target = dom(e).rootTarget;
- while (!target.classList.contains('deleteBtn')) {
- if (!target.parentElement) { return; }
- target = target.parentElement;
- }
-
- target.disabled = true;
- const accountID = parseInt(target.getAttribute('data-account-id'), 10);
- this._xhrPromise =
- this.$.restAPI.deleteVote(this.change._number, accountID, this.label)
- .then(response => {
- target.disabled = false;
- if (!response.ok) { return; }
- GerritNav.navigateToChange(this.change);
- })
- .catch(err => {
- target.disabled = false;
- return;
- });
- }
-
- _computeValueTooltip(labelInfo, score) {
- if (!labelInfo || !labelInfo.values || !labelInfo.values[score]) {
- return '';
- }
- return labelInfo.values[score];
- }
-
- /**
- * @param {!Object} labelInfo
- * @param {Object} changeLabelsRecord not used, but added as a parameter in
- * order to trigger computation when a label is removed from the change.
- */
- _computeShowPlaceholder(labelInfo, changeLabelsRecord) {
- if (labelInfo &&
- !labelInfo.values && (labelInfo.rejected || labelInfo.approved)) {
- return 'hidden';
- }
-
- if (labelInfo && labelInfo.all) {
- for (const label of labelInfo.all) {
- if (label.value && label.value != labelInfo.default_value) {
- return 'hidden';
- }
- }
- }
- return '';
- }
-}
-
-customElements.define(GrLabelInfo.is, GrLabelInfo);
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
new file mode 100644
index 0000000..1dac371
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info.ts
@@ -0,0 +1,267 @@
+/**
+ * @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 '../../../styles/gr-voting-styles';
+import '../../../styles/shared-styles';
+import '../gr-account-label/gr-account-label';
+import '../gr-account-link/gr-account-link';
+import '../gr-button/gr-button';
+import '../gr-icons/gr-icons';
+import '../gr-label/gr-label';
+import '../gr-rest-api-interface/gr-rest-api-interface';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-label-info_html';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property} from '@polymer/decorators';
+import {
+ ChangeInfo,
+ AccountInfo,
+ LabelInfo,
+ ApprovalInfo,
+ AccountId,
+ isQuickLabelInfo,
+ isDetailedLabelInfo,
+} from '../../../types/common';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {GrButton} from '../gr-button/gr-button';
+import {getVotingRangeOrDefault} from '../../../utils/label-util';
+
+export interface GrLabelInfo {
+ $: {
+ restAPI: RestApiService & Element;
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-label-info': GrLabelInfo;
+ }
+}
+
+enum LabelClassName {
+ NEGATIVE = 'negative',
+ POSITIVE = 'positive',
+ MIN = 'min',
+ MAX = 'max',
+}
+
+interface FormattedLabel {
+ className?: LabelClassName;
+ account: ApprovalInfo;
+ value: string;
+}
+
+@customElement('gr-label-info')
+export class GrLabelInfo extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Object})
+ labelInfo?: LabelInfo;
+
+ @property({type: String})
+ label = '';
+
+ @property({type: Object})
+ change?: ChangeInfo;
+
+ @property({type: Object})
+ account?: AccountInfo;
+
+ @property({type: Boolean})
+ mutable = false;
+
+ // TODO(TS): not used, remove later
+ _xhrPromise?: Promise<void>;
+
+ /**
+ * This method also listens on change.labels.*,
+ * to trigger computation when a label is removed from the change.
+ */
+ _mapLabelInfo(labelInfo?: LabelInfo, account?: AccountInfo) {
+ const result: FormattedLabel[] = [];
+ if (!labelInfo) {
+ return result;
+ }
+ if (!isDetailedLabelInfo(labelInfo)) {
+ if (
+ isQuickLabelInfo(labelInfo) &&
+ (labelInfo.rejected || labelInfo.approved)
+ ) {
+ const ok = labelInfo.approved || !labelInfo.rejected;
+ return [
+ {
+ value: ok ? '👍️' : '👎️',
+ className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
+ account: ok ? labelInfo.approved : labelInfo.rejected,
+ },
+ ];
+ }
+ return result;
+ }
+
+ // Sort votes by positivity.
+ // TODO(TS): maybe mark value as required if always present
+ const votes = (labelInfo.all || []).sort(
+ (a, b) => (a.value || 0) - (b.value || 0)
+ );
+ const votingRange = getVotingRangeOrDefault(labelInfo);
+ for (const label of votes) {
+ if (
+ label.value &&
+ (!isQuickLabelInfo(labelInfo) ||
+ label.value !== labelInfo.default_value)
+ ) {
+ let labelClassName;
+ let labelValPrefix = '';
+ if (label.value > 0) {
+ labelValPrefix = '+';
+ if (label.value === votingRange.max) {
+ labelClassName = LabelClassName.MAX;
+ } else {
+ labelClassName = LabelClassName.POSITIVE;
+ }
+ } else if (label.value < 0) {
+ if (label.value === votingRange.min) {
+ labelClassName = LabelClassName.MIN;
+ } else {
+ labelClassName = LabelClassName.NEGATIVE;
+ }
+ }
+ const formattedLabel = {
+ value: `${labelValPrefix}${label.value}`,
+ className: labelClassName,
+ account: label,
+ };
+ if (label._account_id === account?._account_id) {
+ // Put self-votes at the top.
+ result.unshift(formattedLabel);
+ } else {
+ result.push(formattedLabel);
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * A user is able to delete a vote iff the mutable property is true and the
+ * reviewer that left the vote exists in the list of removable_reviewers
+ * received from the backend.
+ *
+ * @param reviewer An object describing the reviewer that left the
+ * vote.
+ */
+ _computeDeleteClass(
+ reviewer: ApprovalInfo,
+ mutable: boolean,
+ change: ChangeInfo
+ ) {
+ if (!mutable || !change || !change.removable_reviewers) {
+ return 'hidden';
+ }
+ const removable = change.removable_reviewers;
+ if (removable.find(r => r._account_id === reviewer._account_id)) {
+ return '';
+ }
+ return 'hidden';
+ }
+
+ /**
+ * Closure annotation for Polymer.prototype.splice is off.
+ * For now, suppressing annotations.
+ */
+ _onDeleteVote(e: MouseEvent) {
+ if (!this.change) return;
+
+ e.preventDefault();
+ let target = (dom(e) as EventApi).rootTarget as GrButton;
+ while (!target.classList.contains('deleteBtn')) {
+ if (!target.parentElement) {
+ return;
+ }
+ target = target.parentElement as GrButton;
+ }
+
+ target.disabled = true;
+ const accountID = Number(
+ `${target.getAttribute('data-account-id')}`
+ ) as AccountId;
+ this._xhrPromise = this.$.restAPI
+ .deleteVote(this.change._number, accountID, this.label)
+ .then(response => {
+ target.disabled = false;
+ if (!response.ok) {
+ return;
+ }
+ if (this.change) {
+ GerritNav.navigateToChange(this.change);
+ }
+ })
+ .catch(err => {
+ console.warn(err);
+ target.disabled = false;
+ return;
+ });
+ }
+
+ _computeValueTooltip(labelInfo: LabelInfo, score: string) {
+ if (
+ !labelInfo ||
+ !isDetailedLabelInfo(labelInfo) ||
+ !labelInfo.values[score]
+ ) {
+ return '';
+ }
+ return labelInfo.values[score];
+ }
+
+ /**
+ * This method also listens change.labels.* in
+ * order to trigger computation when a label is removed from the change.
+ */
+ _computeShowPlaceholder(labelInfo?: LabelInfo) {
+ if (!labelInfo) {
+ return '';
+ }
+ if (
+ !isDetailedLabelInfo(labelInfo) &&
+ isQuickLabelInfo(labelInfo) &&
+ (labelInfo.rejected || labelInfo.approved)
+ ) {
+ return 'hidden';
+ }
+
+ if (isDetailedLabelInfo(labelInfo) && labelInfo.all) {
+ for (const label of labelInfo.all) {
+ if (
+ label.value &&
+ (!isQuickLabelInfo(labelInfo) ||
+ label.value !== labelInfo.default_value)
+ ) {
+ return 'hidden';
+ }
+ }
+ }
+ return '';
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
index dbc3b5e..3955cd4 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
@@ -23,7 +23,6 @@
<style include="shared-styles">
.placeholder {
color: var(--deemphasized-text-color);
- padding-top: var(--spacing-xs);
}
.hidden {
display: none;
@@ -68,7 +67,8 @@
gr-button[disabled] iron-icon {
color: var(--border-color);
}
- gr-account-chip {
+ gr-account-link {
+ --account-max-length: 120px;
margin-right: var(--spacing-xs);
}
iron-icon {
@@ -101,10 +101,7 @@
</gr-label>
</td>
<td>
- <gr-account-chip
- account="[[mappedLabel.account]]"
- transparent-background=""
- ></gr-account-chip>
+ <gr-account-link account="[[mappedLabel.account]]"></gr-account-link>
</td>
<td>
<gr-button
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
index ee9fb13..3a2cc39 100644
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
@@ -17,12 +17,11 @@
import '../../../test/common-test-setup-karma.js';
import './gr-label-info.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {isHidden} from '../../../test/test-utils.js';
const basicFixture = fixtureFromElement('gr-label-info');
-suite('gr-account-link tests', () => {
+suite('gr-label-info tests', () => {
let element;
setup(() => {
@@ -58,7 +57,7 @@
element.labelInfo = test;
element.label = 'test';
- flushAsynchronousOperations();
+ flush();
});
test('_computeCanDeleteVote', () => {
@@ -93,15 +92,15 @@
suite('label color and order', () => {
test('valueless label rejected', () => {
element.labelInfo = {rejected: {name: 'someone'}};
- flushAsynchronousOperations();
- const labels = dom(element.root).querySelectorAll('gr-label');
+ flush();
+ const labels = element.root.querySelectorAll('gr-label');
assert.isTrue(labels[0].classList.contains('negative'));
});
test('valueless label approved', () => {
element.labelInfo = {approved: {name: 'someone'}};
- flushAsynchronousOperations();
- const labels = dom(element.root).querySelectorAll('gr-label');
+ flush();
+ const labels = element.root.querySelectorAll('gr-label');
assert.isTrue(labels[0].classList.contains('positive'));
});
@@ -121,8 +120,8 @@
'+2': 'Ready to submit',
},
};
- flushAsynchronousOperations();
- const labels = dom(element.root).querySelectorAll('gr-label');
+ flush();
+ const labels = element.root.querySelectorAll('gr-label');
assert.isTrue(labels[0].classList.contains('max'));
assert.isTrue(labels[1].classList.contains('positive'));
assert.isTrue(labels[2].classList.contains('negative'));
@@ -141,8 +140,8 @@
'+1': 'Looks good to me',
},
};
- flushAsynchronousOperations();
- const labels = dom(element.root).querySelectorAll('gr-label');
+ flush();
+ const labels = element.root.querySelectorAll('gr-label');
assert.isTrue(labels[0].classList.contains('max'));
assert.isTrue(labels[1].classList.contains('min'));
});
@@ -159,8 +158,8 @@
'+2': 'Looks good to me',
},
};
- flushAsynchronousOperations();
- const labels = dom(element.root).querySelectorAll('gr-label');
+ flush();
+ const labels = element.root.querySelectorAll('gr-label');
assert.isTrue(labels[0].classList.contains('max'));
assert.isTrue(labels[1].classList.contains('positive'));
});
@@ -181,9 +180,9 @@
'+1': 'Looks good to me',
},
};
- flushAsynchronousOperations();
+ flush();
const chips =
- dom(element.root).querySelectorAll('gr-account-chip');
+ element.root.querySelectorAll('gr-account-link');
assert.equal(chips[0].account._account_id, element.account._account_id);
});
});
@@ -205,25 +204,32 @@
});
test('placeholder', () => {
+ const values = {
+ '0': 'No score',
+ '+1': 'good',
+ '+2': 'excellent',
+ '-1': 'bad',
+ '-2': 'terrible',
+ };
element.labelInfo = {};
assert.isFalse(isHidden(element.shadowRoot
.querySelector('.placeholder')));
- element.labelInfo = {all: []};
+ element.labelInfo = {all: [], values};
assert.isFalse(isHidden(element.shadowRoot
.querySelector('.placeholder')));
- element.labelInfo = {all: [{value: 1}]};
+ element.labelInfo = {all: [{value: 1}], values};
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.placeholder')));
element.labelInfo = {rejected: []};
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.placeholder')));
- element.labelInfo = {values: [], rejected: [], all: [{value: 1}]};
+ element.labelInfo = {values: [], rejected: [], all: [{value: 1}, values]};
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.placeholder')));
element.labelInfo = {approved: []};
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.placeholder')));
- element.labelInfo = {values: [], approved: [], all: [{value: 1}]};
+ element.labelInfo = {values: [], approved: [], all: [{value: 1}, values]};
assert.isTrue(isHidden(element.shadowRoot
.querySelector('.placeholder')));
});
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js b/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
deleted file mode 100644
index 5df6b58..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.js
+++ /dev/null
@@ -1,34 +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 {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-label_html.js';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin.js';
-
-/**
- * @extends PolymerElement
- */
-class GrLabel extends TooltipMixin(
- GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-label'; }
-}
-
-customElements.define(GrLabel.is, GrLabel);
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
new file mode 100644
index 0000000..46b10cc
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
@@ -0,0 +1,44 @@
+/**
+ * @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.
+ */
+
+/**
+ * @fileoverview Consider removing this element as
+ * its functionality seems to be duplicated with gr-tooltip and only
+ * used in gr-label-info.
+ */
+
+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 {customElement} from '@polymer/decorators';
+import {htmlTemplate} from './gr-label_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-label': GrLabel;
+ }
+}
+
+@customElement('gr-label')
+export class GrLabel extends TooltipMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
deleted file mode 100644
index d17f7d8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.js
+++ /dev/null
@@ -1,87 +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 '../gr-autocomplete/gr-autocomplete.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-labeled-autocomplete_html.js';
-
-/** @extends PolymerElement */
-class GrLabeledAutocomplete extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-labeled-autocomplete'; }
- /**
- * Fired when a value is chosen.
- *
- * @event commit
- */
-
- static get properties() {
- return {
-
- /**
- * Used just like the query property of gr-autocomplete.
- *
- * @type {function(string): Promise<?>}
- */
- query: {
- type: Function,
- value() {
- return function() {
- return Promise.resolve([]);
- };
- },
- },
-
- text: {
- type: String,
- value: '',
- notify: true,
- },
- label: String,
- placeholder: String,
- disabled: Boolean,
-
- _autocompleteThreshold: {
- type: Number,
- value: 0,
- readOnly: true,
- },
- };
- }
-
- _handleTriggerClick(e) {
- // Stop propagation here so we don't confuse gr-autocomplete, which
- // listens for taps on body to try to determine when it's blurred.
- e.stopPropagation();
- this.$.autocomplete.focus();
- }
-
- setText(text) {
- this.$.autocomplete.setText(text);
- }
-
- clear() {
- this.setText('');
- }
-}
-
-customElements.define(GrLabeledAutocomplete.is, GrLabeledAutocomplete);
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
new file mode 100644
index 0000000..4240c77
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
@@ -0,0 +1,83 @@
+/**
+ * @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 '../gr-autocomplete/gr-autocomplete';
+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-labeled-autocomplete_html';
+import {customElement, property} from '@polymer/decorators';
+import {
+ GrAutocomplete,
+ AutocompleteQuery,
+} from '../gr-autocomplete/gr-autocomplete';
+
+export interface GrLabeledAutocomplete {
+ $: {
+ autocomplete: GrAutocomplete;
+ };
+}
+@customElement('gr-labeled-autocomplete')
+export class GrLabeledAutocomplete extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when a value is chosen.
+ *
+ * @event commit
+ */
+
+ @property({type: Object})
+ query: AutocompleteQuery = () => Promise.resolve([]);
+
+ @property({type: String, notify: true})
+ text = '';
+
+ @property({type: String})
+ label?: string;
+
+ @property({type: String})
+ placeholder?: string;
+
+ @property({type: Boolean})
+ disabled?: boolean;
+
+ _handleTriggerClick(e: Event) {
+ // Stop propagation here so we don't confuse gr-autocomplete, which
+ // listens for taps on body to try to determine when it's blurred.
+ e.stopPropagation();
+ this.$.autocomplete.focus();
+ }
+
+ setText(text: string) {
+ this.$.autocomplete.setText(text);
+ }
+
+ clear() {
+ this.setText('');
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-labeled-autocomplete': GrLabeledAutocomplete;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
index fa50624..934ab84 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_html.ts
@@ -49,7 +49,7 @@
<div id="body">
<gr-autocomplete
id="autocomplete"
- threshold="[[_autocompleteThreshold]]"
+ threshold="0"
query="[[query]]"
disabled="[[disabled]]"
placeholder="[[placeholder]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
deleted file mode 100644
index 3c3fc6a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.js
+++ /dev/null
@@ -1,150 +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 '../gr-js-api-interface/gr-js-api-interface.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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-lib-loader_html.js';
-
-const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
-
-/** @extends PolymerElement */
-class GrLibLoader extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-lib-loader'; }
-
- static get properties() {
- return {
- _hljsState: {
- type: Object,
-
- // NOTE: intended singleton.
- value: {
- configured: false,
- loading: false,
- callbacks: [],
- },
- },
- };
- }
-
- /**
- * Get the HLJS library. Returns a promise that resolves with a reference to
- * the library after it's been loaded. The promise resolves immediately if
- * it's already been loaded.
- *
- * @return {!Promise<Object>}
- */
- getHLJS() {
- return new Promise((resolve, reject) => {
- // If the lib is totally loaded, resolve immediately.
- if (this._getHighlightLib()) {
- resolve(this._getHighlightLib());
- return;
- }
-
- // If the library is not currently being loaded, then start loading it.
- if (!this._hljsState.loading) {
- this._hljsState.loading = true;
- this._loadScript(this._getHLJSUrl())
- .then(this._onHLJSLibLoaded.bind(this))
- .catch(reject);
- }
-
- this._hljsState.callbacks.push(resolve);
- });
- }
-
- /**
- * Execute callbacks awaiting the HLJS lib load.
- */
- _onHLJSLibLoaded() {
- const lib = this._getHighlightLib();
- this._hljsState.loading = false;
- this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.HIGHLIGHTJS_LOADED, {
- hljs: lib,
- });
- for (const cb of this._hljsState.callbacks) {
- cb(lib);
- }
- this._hljsState.callbacks = [];
- }
-
- /**
- * Get the HLJS library, assuming it has been loaded. Configure the library
- * if it hasn't already been configured.
- *
- * @return {!Object}
- */
- _getHighlightLib() {
- const lib = window.hljs;
- if (lib && !this._hljsState.configured) {
- this._hljsState.configured = true;
-
- lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
- }
- return lib;
- }
-
- /**
- * Get the resource path used to load the application. If the application
- * was loaded through a CDN, then this will be the path to CDN resources.
- *
- * @return {string}
- */
- _getLibRoot() {
- if (window.STATIC_RESOURCE_PATH) {
- return window.STATIC_RESOURCE_PATH + '/';
- }
- return '/';
- }
-
- /**
- * Load and execute a JS file from the lib root.
- *
- * @param {string} src The path to the JS file without the lib root.
- * @return {Promise} a promise that resolves when the script's onload
- * executes.
- */
- _loadScript(src) {
- return new Promise((resolve, reject) => {
- const script = document.createElement('script');
-
- if (!src) {
- reject(new Error('Unable to load blank script url.'));
- return;
- }
-
- script.setAttribute('src', src);
- script.onload = resolve;
- script.onerror = reject;
- dom(document.head).appendChild(script);
- });
- }
-
- _getHLJSUrl() {
- const root = this._getLibRoot();
- if (!root) { return null; }
- return root + HLJS_PATH;
- }
-}
-
-customElements.define(GrLibLoader.is, GrLibLoader);
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
new file mode 100644
index 0000000..ad97d02
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader.ts
@@ -0,0 +1,160 @@
+/**
+ * @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 '../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-lib-loader_html';
+import {EventType} from '../../plugins/gr-plugin-types';
+import {customElement, property} from '@polymer/decorators';
+import {JsApiService} from '../gr-js-api-interface/gr-js-api-types';
+import {HighlightJS} from '../../../types/types';
+
+// preloaded in PolyGerritIndexHtml.soy
+const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
+
+type HljsCallback = (value?: HighlightJS) => void;
+
+interface HljsState {
+ configured: boolean;
+ loading: boolean;
+ callbacks: HljsCallback[];
+}
+
+export interface GrLibLoader {
+ $: {
+ jsAPI: JsApiService & Element;
+ };
+}
+@customElement('gr-lib-loader')
+export class GrLibLoader extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ // NOTE: intended singleton.
+ @property({type: Object})
+ _hljsState: HljsState = {
+ configured: false,
+ loading: false,
+ callbacks: [],
+ };
+
+ /**
+ * Get the HLJS library. Returns a promise that resolves with a reference to
+ * the library after it's been loaded. The promise resolves immediately if
+ * it's already been loaded.
+ */
+ getHLJS(): Promise<HighlightJS | undefined> {
+ return new Promise<HighlightJS | undefined>((resolve, reject) => {
+ // If the lib is totally loaded, resolve immediately.
+ if (this._getHighlightLib()) {
+ resolve(this._getHighlightLib());
+ return;
+ }
+
+ // If the library is not currently being loaded, then start loading it.
+ if (!this._hljsState.loading) {
+ this._hljsState.loading = true;
+ this._loadScript(this._getHLJSUrl())
+ .then(() => this._onHLJSLibLoaded())
+ .catch(reject);
+ }
+
+ this._hljsState.callbacks.push(resolve);
+ });
+ }
+
+ /**
+ * Execute callbacks awaiting the HLJS lib load.
+ */
+ _onHLJSLibLoaded() {
+ const lib = this._getHighlightLib();
+ this._hljsState.loading = false;
+ this.$.jsAPI.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
+ hljs: lib,
+ });
+ for (const cb of this._hljsState.callbacks) {
+ cb(lib);
+ }
+ this._hljsState.callbacks = [];
+ }
+
+ /**
+ * Get the HLJS library, assuming it has been loaded. Configure the library
+ * if it hasn't already been configured.
+ */
+ _getHighlightLib(): HighlightJS | undefined {
+ const lib = window.hljs;
+ if (lib && !this._hljsState.configured) {
+ this._hljsState.configured = true;
+
+ lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+ }
+ return lib;
+ }
+
+ /**
+ * Get the resource path used to load the application. If the application
+ * was loaded through a CDN, then this will be the path to CDN resources.
+ */
+ _getLibRoot() {
+ if (window.STATIC_RESOURCE_PATH) {
+ return window.STATIC_RESOURCE_PATH + '/';
+ }
+ return '/';
+ }
+
+ /**
+ * Load and execute a JS file from the lib root.
+ *
+ * @param src The path to the JS file without the lib root.
+ * @return a promise that resolves when the script's onload
+ * executes.
+ */
+ _loadScript(src: string | null) {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+
+ if (!src) {
+ reject(new Error('Unable to load blank script url.'));
+ return;
+ }
+
+ script.setAttribute('src', src);
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ }
+
+ _getHLJSUrl() {
+ const root = this._getLibRoot();
+ if (!root) {
+ return null;
+ }
+ return root + HLJS_PATH;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-lib-loader': GrLibLoader;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
index 4231d71..1ce175f 100644
--- a/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/gr-lib-loader_test.js
@@ -47,7 +47,7 @@
element._hljsState.callbacks = [];
});
- test('only load once', done => {
+ test('only load once', async () => {
sinon.stub(element, '_getHLJSUrl').returns('');
const firstCallHandler = sinon.stub();
element.getHLJS().then(firstCallHandler);
@@ -67,13 +67,11 @@
// Now load the library.
resolveLoad();
- flush(() => {
- // The state should be loaded and both handlers called.
- assert.isFalse(element._hljsState.loading);
- assert.isTrue(firstCallHandler.called);
- assert.isTrue(secondCallHandler.called);
- done();
- });
+ await flush();
+ // The state should be loaded and both handlers called.
+ assert.isFalse(element._hljsState.loading);
+ assert.isTrue(firstCallHandler.called);
+ assert.isTrue(secondCallHandler.called);
});
suite('preloaded', () => {
@@ -90,22 +88,17 @@
delete window.hljs;
});
- test('returns hljs', done => {
+ test('returns hljs', async () => {
const firstCallHandler = sinon.stub();
element.getHLJS().then(firstCallHandler);
- flush(() => {
- assert.isTrue(firstCallHandler.called);
- assert.isTrue(firstCallHandler.calledWith(hljsStub));
- done();
- });
+ await flush();
+ assert.isTrue(firstCallHandler.called);
+ assert.isTrue(firstCallHandler.calledWith(hljsStub));
});
- test('configures hljs', done => {
- element.getHLJS().then(() => {
- assert.isTrue(window.hljs.configure.calledOnce);
- done();
- });
- });
+ test('configures hljs', () => element.getHLJS().then(() => {
+ assert.isTrue(window.hljs.configure.calledOnce);
+ }));
});
suite('_getHLJSUrl', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
deleted file mode 100644
index 803f802..0000000
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
+++ /dev/null
@@ -1,111 +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 {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-limited-text_html.js';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin.js';
-
-/**
- * The gr-limited-text element is for displaying text with a maximum length
- * (in number of characters) to display. If the length of the text exceeds the
- * configured limit, then an ellipsis indicates that the text was truncated
- * and a tooltip containing the full text is enabled.
- *
- * @extends PolymerElement
- */
-class GrLimitedText extends TooltipMixin(
- GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-limited-text'; }
-
- static get properties() {
- return {
- /** The un-truncated text to display. */
- text: {
- type: String,
- value: '',
- },
-
- /** The maximum length for the text to display before truncating. */
- limit: {
- type: Number,
- value: null,
- },
-
- tooltip: {
- type: String,
- value: '',
- },
-
- /** Boolean property used by TooltipMixin. */
- hasTooltip: {
- type: Boolean,
- value: false,
- },
-
- /**
- * Disable the tooltip.
- * When set to true, will not show tooltip even text is over limit
- */
- disableTooltip: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- static get observers() {
- return [
- '_updateTitle(text, tooltip, limit)',
- ];
- }
-
- /**
- * The text or limit have changed. Recompute whether a tooltip needs to be
- * enabled.
- */
- _updateTitle(text, tooltip, limit) {
- // Polymer 2: check for undefined
- if ([text, limit, tooltip].includes(undefined)) {
- return;
- }
-
- this.hasTooltip = !!tooltip || (!!limit && text.length > limit);
- if (this.hasTooltip && !this.disableTooltip) {
- // Combine the text and title if over-length
- if (limit && text.length > limit) {
- this.title = `${text}${tooltip? ` (${tooltip})` : ''}`;
- } else {
- this.title = tooltip;
- }
- } else {
- this.title = '';
- }
- }
-
- _computeDisplayText(text, limit) {
- if (!!limit && !!text && text.length > limit) {
- return text.substr(0, limit - 1) + '…';
- }
- return text;
- }
-}
-
-customElements.define(GrLimitedText.is, GrLimitedText);
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
new file mode 100644
index 0000000..4d65874
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
@@ -0,0 +1,93 @@
+/**
+ * @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 {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-limited-text_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {customElement, observe, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-limited-text': GrLimitedText;
+ }
+}
+
+/**
+ * The gr-limited-text element is for displaying text with a maximum length
+ * (in number of characters) to display. If the length of the text exceeds the
+ * configured limit, then an ellipsis indicates that the text was truncated
+ * and a tooltip containing the full text is enabled.
+ */
+@customElement('gr-limited-text')
+export class GrLimitedText extends TooltipMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /** The un-truncated text to display. */
+ @property({type: String})
+ text = '';
+
+ /** The maximum length for the text to display before truncating. */
+ @property({type: Number})
+ limit: number | null = null;
+
+ @property({type: String})
+ tooltip = '';
+
+ /** Boolean property used by TooltipMixin. */
+ @property({type: Boolean})
+ hasTooltip = false;
+
+ /** Boolean property used by TooltipMixin. */
+ @property({type: Boolean})
+ disableTooltip = false;
+
+ /**
+ * The text or limit have changed. Recompute whether a tooltip needs to be
+ * enabled.
+ */
+ @observe('text', 'tooltip', 'limit')
+ _updateTitle(text: string, tooltip: string, limit?: number) {
+ // Polymer 2: check for undefined
+ if ([text, limit, tooltip].includes(undefined)) {
+ return;
+ }
+
+ this.hasTooltip = !!tooltip || (!!limit && text.length > limit);
+ if (this.hasTooltip && !this.disableTooltip) {
+ // Combine the text and title if over-length
+ if (limit && text.length > limit) {
+ this.title = `${text}${tooltip ? ` (${tooltip})` : ''}`;
+ } else {
+ this.title = tooltip;
+ }
+ } else {
+ this.title = '';
+ }
+ }
+
+ _computeDisplayText(text: string, limit?: number) {
+ if (!!limit && !!text && text.length > limit) {
+ return text.substr(0, limit - 1) + '…';
+ }
+ return text;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
index 6e27eeb..3b99d6d 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
@@ -30,31 +30,31 @@
test('tooltip without title input', () => {
const updateSpy = sinon.spy(element, '_updateTitle');
element.text = 'abc 123';
- flushAsynchronousOperations();
+ flush();
assert.isTrue(updateSpy.calledOnce);
assert.isNotOk(element.getAttribute('title'));
assert.isFalse(element.hasTooltip);
element.limit = 10;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(updateSpy.calledTwice);
assert.isNotOk(element.getAttribute('title'));
assert.isFalse(element.hasTooltip);
element.limit = 3;
- flushAsynchronousOperations();
+ flush();
assert.equal(updateSpy.callCount, 3);
assert.equal(element.getAttribute('title'), 'abc 123');
assert.equal(element.title, 'abc 123');
assert.isTrue(element.hasTooltip);
element.limit = 100;
- flushAsynchronousOperations();
+ flush();
assert.equal(updateSpy.callCount, 4);
assert.isFalse(element.hasTooltip);
element.limit = null;
- flushAsynchronousOperations();
+ flush();
assert.equal(updateSpy.callCount, 5);
assert.isNotOk(element.getAttribute('title'));
assert.isFalse(element.hasTooltip);
@@ -63,20 +63,20 @@
test('with tooltip input', () => {
const updateSpy = sinon.spy(element, '_updateTitle');
element.tooltip = 'abc 123';
- flushAsynchronousOperations();
+ flush();
assert.isTrue(updateSpy.calledOnce);
assert.isTrue(element.hasTooltip);
assert.equal(element.getAttribute('title'), 'abc 123');
assert.equal(element.title, 'abc 123');
element.text = 'abc';
- flushAsynchronousOperations();
+ flush();
assert.equal(element.getAttribute('title'), 'abc 123');
assert.isTrue(element.hasTooltip);
element.text = 'abcdef';
element.limit = 3;
- flushAsynchronousOperations();
+ flush();
assert.equal(element.getAttribute('title'), 'abcdef (abc 123)');
assert.isTrue(element.hasTooltip);
});
@@ -92,7 +92,7 @@
element.text = 'abcdefghijklmn';
element.disableTooltip = true;
element.limit = 10;
- flushAsynchronousOperations();
+ flush();
assert.equal(element.getAttribute('title'), '');
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
deleted file mode 100644
index 46588ef..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
+++ /dev/null
@@ -1,71 +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 '../gr-button/gr-button.js';
-import '../gr-icons/gr-icons.js';
-import '../gr-limited-text/gr-limited-text.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-linked-chip_html.js';
-
-/**
- * @extends PolymerElement
- */
-class GrLinkedChip extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-linked-chip'; }
-
- static get properties() {
- return {
- href: String,
- disabled: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- removable: {
- type: Boolean,
- value: false,
- },
- text: String,
- transparentBackground: {
- type: Boolean,
- value: false,
- },
-
- /** If provided, sets the maximum length of the content. */
- limit: Number,
- };
- }
-
- _getBackgroundClass(transparent) {
- return transparent ? 'transparentBackground' : '';
- }
-
- _handleRemoveTap(e) {
- e.preventDefault();
- this.dispatchEvent(new CustomEvent('remove', {
- composed: true, bubbles: true,
- }));
- }
-}
-
-customElements.define(GrLinkedChip.is, GrLinkedChip);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
new file mode 100644
index 0000000..dbb8725
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.ts
@@ -0,0 +1,74 @@
+/**
+ * @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 '../gr-button/gr-button';
+import '../gr-icons/gr-icons';
+import '../gr-limited-text/gr-limited-text';
+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 {customElement, property} from '@polymer/decorators';
+import {htmlTemplate} from './gr-linked-chip_html';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-linked-chip': GrLinkedChip;
+ }
+}
+
+@customElement('gr-linked-chip')
+export class GrLinkedChip extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ href?: string;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ disabled = false;
+
+ @property({type: Boolean})
+ removable = false;
+
+ @property({type: String})
+ text?: string;
+
+ @property({type: Boolean})
+ transparentBackground = false;
+
+ /** If provided, sets the maximum length of the content. */
+ @property({type: Number})
+ limit?: number;
+
+ _getBackgroundClass(transparent: boolean) {
+ return transparent ? 'transparentBackground' : '';
+ }
+
+ _handleRemoveTap(e: Event) {
+ e.preventDefault();
+ this.dispatchEvent(
+ new CustomEvent('remove', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
index b111e84..dd2b98a 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
@@ -30,7 +30,7 @@
test('remove fired', () => {
const spy = sinon.spy();
element.addEventListener('remove', spy);
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.$.remove);
assert.isTrue(spy.called);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
deleted file mode 100644
index b969d1d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.js
+++ /dev/null
@@ -1,131 +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/shared-styles.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.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 'ba-linkify/ba-linkify.js';
-import {htmlTemplate} from './gr-linked-text_html.js';
-import {GrLinkTextParser} from './link-text-parser.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-/** @extends PolymerElement */
-class GrLinkedText extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-linked-text'; }
-
- static get properties() {
- return {
- removeZeroWidthSpace: Boolean,
- content: {
- type: String,
- observer: '_contentChanged',
- },
- pre: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- disabled: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
- config: Object,
- };
- }
-
- static get observers() {
- return [
- '_contentOrConfigChanged(content, config)',
- ];
- }
-
- _contentChanged(content) {
- // In the case where the config may not be set (perhaps due to the
- // request for it still being in flight), set the content anyway to
- // prevent waiting on the config to display the text.
- if (this.config != null) { return; }
- this.$.output.textContent = content;
- }
-
- /**
- * Because either the source text or the linkification config has changed,
- * the content should be re-parsed.
- *
- * @param {string|null|undefined} content The raw, un-linkified source
- * string to parse.
- * @param {Object|null|undefined} config The server config specifying
- * commentLink patterns
- */
- _contentOrConfigChanged(content, config) {
- if (!GerritNav.mapCommentlinks) return;
- config = GerritNav.mapCommentlinks(config);
- const output = dom(this.$.output);
- output.textContent = '';
- const parser = new GrLinkTextParser(config,
- this._handleParseResult.bind(this), this.removeZeroWidthSpace);
- parser.parse(content);
-
- // Ensure that external links originating from HTML commentlink configs
- // open in a new tab. @see Issue 5567
- // Ensure links to the same host originating from commentlink configs
- // open in the same tab. When target is not set - default is _self
- // @see Issue 4616
- output.querySelectorAll('a').forEach(anchor => {
- if (anchor.hostname === window.location.hostname) {
- anchor.removeAttribute('target');
- } else {
- anchor.setAttribute('target', '_blank');
- }
- anchor.setAttribute('rel', 'noopener');
- });
- }
-
- /**
- * This method is called when the GrLikTextParser emits a partial result
- * (used as the "callback" parameter). It will be called in either of two
- * ways:
- * - To create a link: when called with `text` and `href` arguments, a link
- * element should be created and attached to the resulting DOM.
- * - To attach an arbitrary fragment: when called with only the `fragment`
- * argument, the fragment should be attached to the resulting DOM as is.
- *
- * @param {string|null} text
- * @param {string|null} href
- * @param {DocumentFragment|undefined} fragment
- */
- _handleParseResult(text, href, fragment) {
- const output = dom(this.$.output);
- if (href) {
- const a = document.createElement('a');
- a.href = href;
- a.textContent = text;
- a.target = '_blank';
- a.rel = 'noopener';
- output.appendChild(a);
- } else if (fragment) {
- output.appendChild(fragment);
- }
- }
-}
-
-customElements.define(GrLinkedText.is, GrLinkedText);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
new file mode 100644
index 0000000..e2c2d7f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.ts
@@ -0,0 +1,145 @@
+/**
+ * @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/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-linked-text_html';
+import {GrLinkTextParser, LinkTextParserConfig} from './link-text-parser';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {customElement, property, observe} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-linked-text': GrLinkedText;
+ }
+}
+
+export interface GrLinkedText {
+ $: {
+ output: HTMLSpanElement;
+ };
+}
+
+@customElement('gr-linked-text')
+export class GrLinkedText extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean})
+ removeZeroWidthSpace?: boolean;
+
+ // content default is null, because this.$.output.textContent is string|null
+ @property({type: String})
+ content: string | null = null;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ pre = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ disabled = false;
+
+ @property({type: Object})
+ config?: LinkTextParserConfig;
+
+ @observe('content')
+ _contentChanged(content: string | null) {
+ // In the case where the config may not be set (perhaps due to the
+ // request for it still being in flight), set the content anyway to
+ // prevent waiting on the config to display the text.
+ if (!this.config) {
+ return;
+ }
+ this.$.output.textContent = content;
+ }
+
+ /**
+ * Because either the source text or the linkification config has changed,
+ * the content should be re-parsed.
+ *
+ * @param content The raw, un-linkified source string to parse.
+ * @param config The server config specifying commentLink patterns
+ */
+ @observe('content', 'config')
+ _contentOrConfigChanged(
+ content: string | null,
+ config?: LinkTextParserConfig
+ ) {
+ if (!config) {
+ return;
+ }
+
+ // TODO(TS): mapCommentlinks always has value, remove
+ if (!GerritNav.mapCommentlinks) return;
+ config = GerritNav.mapCommentlinks(config);
+ const output = this.$.output;
+ output.textContent = '';
+ const parser = new GrLinkTextParser(
+ config,
+ (text: string | null, href: string | null, fragment?: DocumentFragment) =>
+ this._handleParseResult(text, href, fragment),
+ this.removeZeroWidthSpace
+ );
+ parser.parse(content);
+
+ // Ensure that external links originating from HTML commentlink configs
+ // open in a new tab. @see Issue 5567
+ // Ensure links to the same host originating from commentlink configs
+ // open in the same tab. When target is not set - default is _self
+ // @see Issue 4616
+ output.querySelectorAll('a').forEach(anchor => {
+ if (anchor.hostname === window.location.hostname) {
+ anchor.removeAttribute('target');
+ } else {
+ anchor.setAttribute('target', '_blank');
+ }
+ anchor.setAttribute('rel', 'noopener');
+ });
+ }
+
+ /**
+ * This method is called when the GrLikTextParser emits a partial result
+ * (used as the "callback" parameter). It will be called in either of two
+ * ways:
+ * - To create a link: when called with `text` and `href` arguments, a link
+ * element should be created and attached to the resulting DOM.
+ * - To attach an arbitrary fragment: when called with only the `fragment`
+ * argument, the fragment should be attached to the resulting DOM as is.
+ */
+ private _handleParseResult(
+ text: string | null,
+ href: string | null,
+ fragment?: DocumentFragment
+ ) {
+ const output = this.$.output;
+ if (href) {
+ const a = document.createElement('a');
+ a.setAttribute('href', href);
+ // GrLinkTextParser either pass text and href together or
+ // only DocumentFragment - see LinkTextParserCallback
+ a.textContent = text!;
+ a.target = '_blank';
+ a.setAttribute('rel', 'noopener');
+ output.appendChild(a);
+ } else if (fragment) {
+ output.appendChild(fragment);
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
index a295d1a..a67fbc4 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
@@ -17,7 +17,6 @@
import '../../../test/common-test-setup-karma.js';
import './gr-linked-text.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {html} from '@polymer/polymer/lib/utils/html-tag.js';
@@ -342,7 +341,7 @@
},
};
element.content = '- B: 123, 45';
- const links = dom(element.root).querySelectorAll('a');
+ const links = element.root.querySelectorAll('a');
assert.equal(links.length, 2);
assert.equal(element.shadowRoot
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
deleted file mode 100644
index f49cf0f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ /dev/null
@@ -1,356 +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 {getBaseUrl} from '../../../utils/url-util.js';
-
-/**
- * Pattern describing URLs with supported protocols.
- *
- * @type {RegExp}
- */
-const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
-
-/**
- * Construct a parser for linkifying text. Will linkify plain URLs that appear
- * in the text as well as custom links if any are specified in the linkConfig
- * parameter.
- *
- * @constructor
- * @param {Object|null|undefined} linkConfig Comment links as specified by the
- * commentlinks field on a project config.
- * @param {Function} callback The callback to be fired when an intermediate
- * parse result is emitted. The callback is passed text and href strings
- * if a link is to be created, or a document fragment otherwise.
- * @param {boolean|undefined} opt_removeZeroWidthSpace If true, zero-width
- * spaces will be removed from R=<email> and CC=<email> expressions.
- */
-export function GrLinkTextParser(linkConfig, callback,
- opt_removeZeroWidthSpace) {
- this.linkConfig = linkConfig;
- this.callback = callback;
- this.removeZeroWidthSpace = opt_removeZeroWidthSpace;
- this.baseUrl = getBaseUrl();
- Object.preventExtensions(this);
-}
-
-/**
- * Emit a callback to create a link element.
- *
- * @param {string} text The text of the link.
- * @param {string} href The URL to use as the href of the link.
- */
-GrLinkTextParser.prototype.addText = function(text, href) {
- if (!text) { return; }
- this.callback(text, href);
-};
-
-/**
- * Given the source text and a list of CommentLinkItem objects that were
- * generated by the commentlinks config, emit parsing callbacks.
- *
- * @param {string} text The chuml of source text over which the outputArray
- * items range.
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray The list of items to add
- * resulting from commentlink matches.
- */
-GrLinkTextParser.prototype.processLinks = function(text, outputArray) {
- this.sortArrayReverse(outputArray);
- const fragment = document.createDocumentFragment();
- let cursor = text.length;
-
- // Start inserting linkified URLs from the end of the String. That way, the
- // string positions of the items don't change as we iterate through.
- outputArray.forEach(item => {
- // Add any text between the current linkified item and the item added
- // before if it exists.
- if (item.position + item.length !== cursor) {
- fragment.insertBefore(
- document.createTextNode(
- text.slice(item.position + item.length, cursor)),
- fragment.firstChild);
- }
- fragment.insertBefore(item.html, fragment.firstChild);
- cursor = item.position;
- });
-
- // Add the beginning portion at the end.
- if (cursor !== 0) {
- fragment.insertBefore(
- document.createTextNode(text.slice(0, cursor)), fragment.firstChild);
- }
-
- this.callback(null, null, fragment);
-};
-
-/**
- * Sort the given array of CommentLinkItems such that the positions are in
- * reverse order.
- *
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray
- */
-GrLinkTextParser.prototype.sortArrayReverse = function(outputArray) {
- outputArray.sort((a, b) => b.position - a.position);
-};
-
-/**
- * Create a CommentLinkItem and append it to the given output array. This
- * method can be called in either of two ways:
- * - With `text` and `href` parameters provided, and the `html` parameter
- * passed as `null`. In this case, the new CommentLinkItem will be a link
- * element with the given text and href value.
- * - With the `html` paremeter provided, and the `text` and `href` parameters
- * passed as `null`. In this case, the string of HTML will be parsed and the
- * first resulting node will be used as the resulting content.
- *
- * @param {string|null} text The text to use if creating a link.
- * @param {string|null} href The href to use as the URL if creating a link.
- * @param {string|null} html The html to parse and use as the result.
- * @param {number} position The position inside the source text where the item
- * starts.
- * @param {number} length The number of characters in the source text
- * represented by the item.
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
- * new item is to be appended.
- */
-GrLinkTextParser.prototype.addItem =
- function(text, href, html, position, length, outputArray) {
- let htmlOutput = '';
-
- if (href) {
- const a = document.createElement('a');
- a.href = href;
- a.textContent = text;
- a.target = '_blank';
- a.rel = 'noopener';
- htmlOutput = a;
- } else if (html) {
- const fragment = document.createDocumentFragment();
- // Create temporary div to hold the nodes in.
- const div = document.createElement('div');
- div.innerHTML = html;
- while (div.firstChild) {
- fragment.appendChild(div.firstChild);
- }
- htmlOutput = fragment;
- }
-
- outputArray.push({
- html: htmlOutput,
- position,
- length,
- });
- };
-
-/**
- * Create a CommentLinkItem for a link and append it to the given output
- * array.
- *
- * @param {string|null} text The text for the link.
- * @param {string|null} href The href to use as the URL of the link.
- * @param {number} position The position inside the source text where the link
- * starts.
- * @param {number} length The number of characters in the source text
- * represented by the link.
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
- * new item is to be appended.
- */
-GrLinkTextParser.prototype.addLink =
- function(text, href, position, length, outputArray) {
- if (!text || this.hasOverlap(position, length, outputArray)) { return; }
- if (!!this.baseUrl && href.startsWith('/') &&
- !href.startsWith(this.baseUrl)) {
- href = this.baseUrl + href;
- }
- this.addItem(text, href, null, position, length, outputArray);
- };
-
-/**
- * Create a CommentLinkItem specified by an HTMl string and append it to the
- * given output array.
- *
- * @param {string|null} html The html to parse and use as the result.
- * @param {number} position The position inside the source text where the item
- * starts.
- * @param {number} length The number of characters in the source text
- * represented by the item.
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray The array to which the
- * new item is to be appended.
- */
-GrLinkTextParser.prototype.addHTML =
- function(html, position, length, outputArray) {
- if (this.hasOverlap(position, length, outputArray)) { return; }
- if (!!this.baseUrl && html.match(/<a href=\"\//g) &&
- !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)) {
- html = html.replace(/<a href=\"\//g, `<a href=\"${this.baseUrl}\/`);
- }
- this.addItem(null, null, html, position, length, outputArray);
- };
-
-/**
- * Does the given range overlap with anything already in the item list.
- *
- * @param {number} position
- * @param {number} length
- * @param {!Array<Gerrit.CommentLinkItem>} outputArray
- */
-GrLinkTextParser.prototype.hasOverlap =
- function(position, length, outputArray) {
- const endPosition = position + length;
- for (let i = 0; i < outputArray.length; i++) {
- const arrayItemStart = outputArray[i].position;
- const arrayItemEnd = outputArray[i].position + outputArray[i].length;
- if ((position >= arrayItemStart && position < arrayItemEnd) ||
- (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
- (position === arrayItemStart && position === arrayItemEnd)) {
- return true;
- }
- }
- return false;
- };
-
-/**
- * Parse the given source text and emit callbacks for the items that are
- * parsed.
- *
- * @param {string} text
- */
-GrLinkTextParser.prototype.parse = function(text) {
- if (text) {
- linkify(text, {
- callback: this.parseChunk.bind(this),
- });
- }
-};
-
-/**
- * Callback that is pased into the linkify function. ba-linkify will call this
- * method in either of two ways:
- * - With both a `text` and `href` parameter provided: this indicates that
- * ba-linkify has found a plain URL and wants it linkified.
- * - With only a `text` parameter provided: this represents the non-link
- * content that lies between the links the library has found.
- *
- * @param {string} text
- * @param {string|null|undefined} href
- */
-GrLinkTextParser.prototype.parseChunk = function(text, href) {
- // TODO(wyatta) switch linkify sequence, see issue 5526.
- if (this.removeZeroWidthSpace) {
- // Remove the zero-width space added in gr-change-view.
- text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
- }
-
- // If the href is provided then ba-linkify has recognized it as a URL. If
- // the source text does not include a protocol, the protocol will be added
- // by ba-linkify. Create the link if the href is provided and its protocol
- // matches the expected pattern.
- if (href) {
- const result = URL_PROTOCOL_PATTERN.exec(href);
- if (result) {
- const prefixText = result[1];
- if (prefixText.length > 0) {
- // Fix for simple cases from
- // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
- // When leading whitespace is missed before link,
- // linkify add this text before link as a schema name to href.
- // We suppose, that prefixText just a single word
- // before link and add this word as is, without processing
- // any patterns in it.
- this.parseLinks(prefixText, []);
- text = text.substring(prefixText.length);
- href = href.substring(prefixText.length);
- }
- this.addText(text, href);
- return;
- }
- }
- // For the sections of text that lie between the links found by
- // ba-linkify, we search for the project-config-specified link patterns.
- this.parseLinks(text, this.linkConfig);
-};
-
-/**
- * Walk over the given source text to find matches for comemntlink patterns
- * and emit parse result callbacks.
- *
- * @param {string} text The raw source text.
- * @param {Object|null|undefined} patterns A comment links specification
- * object.
- */
-GrLinkTextParser.prototype.parseLinks = function(text, patterns) {
- // The outputArray is used to store all of the matches found for all
- // patterns.
- const outputArray = [];
- for (const p in patterns) {
- if (patterns[p].enabled != null && patterns[p].enabled == false) {
- continue;
- }
- // PolyGerrit doesn't use hash-based navigation like the GWT UI.
- // Account for this.
- if (patterns[p].html) {
- patterns[p].html =
- patterns[p].html.replace(/<a href=\"#\//g, '<a href="/');
- } else if (patterns[p].link) {
- if (patterns[p].link[0] == '#') {
- patterns[p].link = patterns[p].link.substr(1);
- }
- }
-
- const pattern = new RegExp(patterns[p].match, 'g');
-
- let match;
- let textToCheck = text;
- let susbtrIndex = 0;
-
- while ((match = pattern.exec(textToCheck)) != null) {
- textToCheck = textToCheck.substr(match.index + match[0].length);
- let result = match[0].replace(pattern,
- patterns[p].html || patterns[p].link);
-
- if (patterns[p].html) {
- let i;
- // Skip portion of replacement string that is equal to original to
- // allow overlapping patterns.
- for (i = 0; i < result.length; i++) {
- if (result[i] !== match[0][i]) { break; }
- }
- result = result.slice(i);
-
- this.addHTML(
- result,
- susbtrIndex + match.index + i,
- match[0].length - i,
- outputArray);
- } else if (patterns[p].link) {
- this.addLink(
- match[0],
- result,
- susbtrIndex + match.index,
- match[0].length,
- outputArray);
- } else {
- throw Error('linkconfig entry ' + p +
- ' doesn’t contain a link or html attribute.');
- }
-
- // Update the substring location so we know where we are in relation to
- // the initial full text string.
- susbtrIndex = susbtrIndex + match.index + match[0].length;
- }
- }
- this.processLinks(text, outputArray);
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
new file mode 100644
index 0000000..9066911
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.ts
@@ -0,0 +1,428 @@
+/**
+ * @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 'ba-linkify/ba-linkify';
+import {getBaseUrl} from '../../../utils/url-util';
+import {CommentLinkInfo} from '../../../types/common';
+
+/**
+ * Pattern describing URLs with supported protocols.
+ */
+const URL_PROTOCOL_PATTERN = /^(.*)(https?:\/\/|mailto:)/;
+
+export type LinkTextParserCallback = ((text: string, href: string) => void) &
+ ((text: null, href: null, fragment: DocumentFragment) => void);
+
+export interface CommentLinkItem {
+ position: number;
+ length: number;
+ html: HTMLAnchorElement | DocumentFragment;
+}
+
+export type LinkTextParserConfig = {[name: string]: CommentLinkInfo};
+
+export class GrLinkTextParser {
+ private readonly baseUrl = getBaseUrl();
+
+ /**
+ * Construct a parser for linkifying text. Will linkify plain URLs that appear
+ * in the text as well as custom links if any are specified in the linkConfig
+ * parameter.
+ *
+ * @constructor
+ * @param linkConfig Comment links as specified by the commentlinks field on a
+ * project config.
+ * @param callback The callback to be fired when an intermediate parse result
+ * is emitted. The callback is passed text and href strings if a link is to
+ * be created, or a document fragment otherwise.
+ * @param removeZeroWidthSpace If true, zero-width spaces will be removed from
+ * R=<email> and CC=<email> expressions.
+ */
+ constructor(
+ private readonly linkConfig: LinkTextParserConfig,
+ private readonly callback: LinkTextParserCallback,
+ private readonly removeZeroWidthSpace?: boolean
+ ) {
+ Object.preventExtensions(this);
+ }
+
+ /**
+ * Emit a callback to create a link element.
+ *
+ * @param text The text of the link.
+ * @param href The URL to use as the href of the link.
+ */
+ addText(text: string, href: string) {
+ if (!text) {
+ return;
+ }
+ this.callback(text, href);
+ }
+
+ /**
+ * Given the source text and a list of CommentLinkItem objects that were
+ * generated by the commentlinks config, emit parsing callbacks.
+ *
+ * @param text The chuml of source text over which the outputArray items range.
+ * @param outputArray The list of items to add resulting from commentlink
+ * matches.
+ */
+ processLinks(text: string, outputArray: CommentLinkItem[]) {
+ this.sortArrayReverse(outputArray);
+ const fragment = document.createDocumentFragment();
+ let cursor = text.length;
+
+ // Start inserting linkified URLs from the end of the String. That way, the
+ // string positions of the items don't change as we iterate through.
+ outputArray.forEach(item => {
+ // Add any text between the current linkified item and the item added
+ // before if it exists.
+ if (item.position + item.length !== cursor) {
+ fragment.insertBefore(
+ document.createTextNode(
+ text.slice(item.position + item.length, cursor)
+ ),
+ fragment.firstChild
+ );
+ }
+ fragment.insertBefore(item.html, fragment.firstChild);
+ cursor = item.position;
+ });
+
+ // Add the beginning portion at the end.
+ if (cursor !== 0) {
+ fragment.insertBefore(
+ document.createTextNode(text.slice(0, cursor)),
+ fragment.firstChild
+ );
+ }
+
+ this.callback(null, null, fragment);
+ }
+
+ /**
+ * Sort the given array of CommentLinkItems such that the positions are in
+ * reverse order.
+ */
+ sortArrayReverse(outputArray: CommentLinkItem[]) {
+ outputArray.sort((a, b) => b.position - a.position);
+ }
+
+ addItem(
+ text: string,
+ href: string,
+ html: null,
+ position: number,
+ length: number,
+ outputArray: CommentLinkItem[]
+ ): void;
+
+ addItem(
+ text: null,
+ href: null,
+ html: string,
+ position: number,
+ length: number,
+ outputArray: CommentLinkItem[]
+ ): void;
+
+ /**
+ * Create a CommentLinkItem and append it to the given output array. This
+ * method can be called in either of two ways:
+ * - With `text` and `href` parameters provided, and the `html` parameter
+ * passed as `null`. In this case, the new CommentLinkItem will be a link
+ * element with the given text and href value.
+ * - With the `html` paremeter provided, and the `text` and `href` parameters
+ * passed as `null`. In this case, the string of HTML will be parsed and the
+ * first resulting node will be used as the resulting content.
+ *
+ * @param text The text to use if creating a link.
+ * @param href The href to use as the URL if creating a link.
+ * @param html The html to parse and use as the result.
+ * @param position The position inside the source text where the item
+ * starts.
+ * @param length The number of characters in the source text
+ * represented by the item.
+ * @param outputArray The array to which the
+ * new item is to be appended.
+ */
+ addItem(
+ text: string | null,
+ href: string | null,
+ html: string | null,
+ position: number,
+ length: number,
+ outputArray: CommentLinkItem[]
+ ): void {
+ if (href) {
+ const a = document.createElement('a');
+ a.setAttribute('href', href);
+ a.textContent = text;
+ a.target = '_blank';
+ a.rel = 'noopener';
+ outputArray.push({
+ html: a,
+ position,
+ length,
+ });
+ } else if (html) {
+ // addItem has 2 overloads. If href is null, then html
+ // can't be null.
+ // TODO(TS): remove if(html) and keep else block without condition
+ const fragment = document.createDocumentFragment();
+ // Create temporary div to hold the nodes in.
+ const div = document.createElement('div');
+ div.innerHTML = html;
+ while (div.firstChild) {
+ fragment.appendChild(div.firstChild);
+ }
+ outputArray.push({
+ html: fragment,
+ position,
+ length,
+ });
+ }
+ }
+
+ /**
+ * Create a CommentLinkItem for a link and append it to the given output
+ * array.
+ *
+ * @param text The text for the link.
+ * @param href The href to use as the URL of the link.
+ * @param position The position inside the source text where the link
+ * starts.
+ * @param length The number of characters in the source text
+ * represented by the link.
+ * @param outputArray The array to which the
+ * new item is to be appended.
+ */
+ addLink(
+ text: string,
+ href: string,
+ position: number,
+ length: number,
+ outputArray: CommentLinkItem[]
+ ) {
+ // TODO(TS): remove !test condition
+ if (!text || this.hasOverlap(position, length, outputArray)) {
+ return;
+ }
+ if (
+ !!this.baseUrl &&
+ href.startsWith('/') &&
+ !href.startsWith(this.baseUrl)
+ ) {
+ href = this.baseUrl + href;
+ }
+ this.addItem(text, href, null, position, length, outputArray);
+ }
+
+ /**
+ * Create a CommentLinkItem specified by an HTMl string and append it to the
+ * given output array.
+ *
+ * @param html The html to parse and use as the result.
+ * @param position The position inside the source text where the item
+ * starts.
+ * @param length The number of characters in the source text
+ * represented by the item.
+ * @param outputArray The array to which the
+ * new item is to be appended.
+ */
+ addHTML(
+ html: string,
+ position: number,
+ length: number,
+ outputArray: CommentLinkItem[]
+ ) {
+ if (this.hasOverlap(position, length, outputArray)) {
+ return;
+ }
+ if (
+ !!this.baseUrl &&
+ html.match(/<a href="\//g) &&
+ !new RegExp(`<a href="${this.baseUrl}`, 'g').test(html)
+ ) {
+ html = html.replace(/<a href="\//g, `<a href="${this.baseUrl}/`);
+ }
+ this.addItem(null, null, html, position, length, outputArray);
+ }
+
+ /**
+ * Does the given range overlap with anything already in the item list.
+ */
+ hasOverlap(position: number, length: number, outputArray: CommentLinkItem[]) {
+ const endPosition = position + length;
+ for (let i = 0; i < outputArray.length; i++) {
+ const arrayItemStart = outputArray[i].position;
+ const arrayItemEnd = outputArray[i].position + outputArray[i].length;
+ if (
+ (position >= arrayItemStart && position < arrayItemEnd) ||
+ (endPosition > arrayItemStart && endPosition <= arrayItemEnd) ||
+ (position === arrayItemStart && position === arrayItemEnd)
+ ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Parse the given source text and emit callbacks for the items that are
+ * parsed.
+ */
+ parse(text?: string | null) {
+ if (text) {
+ window.linkify(text, {
+ callback: (text: string, href?: string) => this.parseChunk(text, href),
+ });
+ }
+ }
+
+ /**
+ * Callback that is pased into the linkify function. ba-linkify will call this
+ * method in either of two ways:
+ * - With both a `text` and `href` parameter provided: this indicates that
+ * ba-linkify has found a plain URL and wants it linkified.
+ * - With only a `text` parameter provided: this represents the non-link
+ * content that lies between the links the library has found.
+ *
+ */
+ parseChunk(text: string, href?: string) {
+ // TODO(wyatta) switch linkify sequence, see issue 5526.
+ if (this.removeZeroWidthSpace) {
+ // Remove the zero-width space added in gr-change-view.
+ text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
+ }
+
+ // If the href is provided then ba-linkify has recognized it as a URL. If
+ // the source text does not include a protocol, the protocol will be added
+ // by ba-linkify. Create the link if the href is provided and its protocol
+ // matches the expected pattern.
+ if (href) {
+ const result = URL_PROTOCOL_PATTERN.exec(href);
+ if (result) {
+ const prefixText = result[1];
+ if (prefixText.length > 0) {
+ // Fix for simple cases from
+ // https://bugs.chromium.org/p/gerrit/issues/detail?id=11697
+ // When leading whitespace is missed before link,
+ // linkify add this text before link as a schema name to href.
+ // We suppose, that prefixText just a single word
+ // before link and add this word as is, without processing
+ // any patterns in it.
+ this.parseLinks(prefixText, {});
+ text = text.substring(prefixText.length);
+ href = href.substring(prefixText.length);
+ }
+ this.addText(text, href);
+ return;
+ }
+ }
+ // For the sections of text that lie between the links found by
+ // ba-linkify, we search for the project-config-specified link patterns.
+ this.parseLinks(text, this.linkConfig);
+ }
+
+ /**
+ * Walk over the given source text to find matches for comemntlink patterns
+ * and emit parse result callbacks.
+ *
+ * @param text The raw source text.
+ * @param config A comment links specification object.
+ */
+ parseLinks(text: string, config: LinkTextParserConfig) {
+ // The outputArray is used to store all of the matches found for all
+ // patterns.
+ const outputArray: CommentLinkItem[] = [];
+ for (const p in config) {
+ // TODO(TS): it seems, the following line can be rewritten as:
+ // if(enabled === false || enabled === 0 || enabled === '')
+ // Should be double-checked before update
+ // eslint-disable-next-line eqeqeq
+ if (config[p].enabled != null && config[p].enabled == false) {
+ continue;
+ }
+ // PolyGerrit doesn't use hash-based navigation like the GWT UI.
+ // Account for this.
+ const html = config[p].html;
+ const link = config[p].link;
+ if (html) {
+ config[p].html = html.replace(/<a href="#\//g, '<a href="/');
+ } else if (link) {
+ if (link[0] === '#') {
+ config[p].link = link.substr(1);
+ }
+ }
+
+ const pattern = new RegExp(config[p].match, 'g');
+
+ let match;
+ let textToCheck = text;
+ let susbtrIndex = 0;
+
+ while ((match = pattern.exec(textToCheck))) {
+ textToCheck = textToCheck.substr(match.index + match[0].length);
+ let result = match[0].replace(
+ pattern,
+ // Either html or link has a value. Otherwise an exception is thrown
+ // in the code below.
+ (config[p].html || config[p].link)!
+ );
+
+ if (config[p].html) {
+ let i;
+ // Skip portion of replacement string that is equal to original to
+ // allow overlapping patterns.
+ for (i = 0; i < result.length; i++) {
+ if (result[i] !== match[0][i]) {
+ break;
+ }
+ }
+ result = result.slice(i);
+
+ this.addHTML(
+ result,
+ susbtrIndex + match.index + i,
+ match[0].length - i,
+ outputArray
+ );
+ } else if (config[p].link) {
+ this.addLink(
+ match[0],
+ result,
+ susbtrIndex + match.index,
+ match[0].length,
+ outputArray
+ );
+ } else {
+ throw Error(
+ 'linkconfig entry ' +
+ p +
+ ' doesn’t contain a link or html attribute.'
+ );
+ }
+
+ // Update the substring location so we know where we are in relation to
+ // the initial full text string.
+ susbtrIndex = susbtrIndex + match.index + match[0].length;
+ }
+ }
+ this.processLinks(text, outputArray);
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
deleted file mode 100644
index 94fc135..0000000
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
+++ /dev/null
@@ -1,123 +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-input/iron-input.js';
-import '@polymer/iron-icon/iron-icon.js';
-import '../../../styles/shared-styles.js';
-import '../gr-button/gr-button.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-list-view_html.js';
-import {encodeURL, getBaseUrl} from '../../../utils/url-util.js';
-import page from 'page/page.mjs';
-
-const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
-
-/**
- * @extends PolymerElement
- */
-class GrListView extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-list-view'; }
-
- static get properties() {
- return {
- createNew: Boolean,
- items: Array,
- itemsPerPage: Number,
- filter: {
- type: String,
- observer: '_filterChanged',
- },
- offset: Number,
- loading: Boolean,
- path: String,
- };
- }
-
- /** @override */
- detached() {
- super.detached();
- this.cancelDebouncer('reload');
- }
-
- _filterChanged(newFilter, oldFilter) {
- if (!newFilter && !oldFilter) {
- return;
- }
-
- this._debounceReload(newFilter);
- }
-
- _debounceReload(filter) {
- this.debounce('reload', () => {
- if (filter) {
- return page.show(`${this.path}/q/filter:` +
- encodeURL(filter, false));
- }
- page.show(this.path);
- }, REQUEST_DEBOUNCE_INTERVAL_MS);
- }
-
- _createNewItem() {
- this.dispatchEvent(new CustomEvent('create-clicked', {
- composed: true, bubbles: true,
- }));
- }
-
- _computeNavLink(offset, direction, itemsPerPage, filter, path) {
- // Offset could be a string when passed from the router.
- offset = +(offset || 0);
- const newOffset = Math.max(0, offset + (itemsPerPage * direction));
- let href = getBaseUrl() + path;
- if (filter) {
- href += '/q/filter:' + encodeURL(filter, false);
- }
- if (newOffset > 0) {
- href += ',' + newOffset;
- }
- return href;
- }
-
- _computeCreateClass(createNew) {
- return createNew ? 'show' : '';
- }
-
- _hidePrevArrow(loading, offset) {
- return loading || offset === 0;
- }
-
- _hideNextArrow(loading, items) {
- if (loading || !items || !items.length) {
- return true;
- }
- const lastPage = items.length < this.itemsPerPage + 1;
- return lastPage;
- }
-
- // TODO: fix offset (including itemsPerPage)
- // to either support a decimal or make it go to the nearest
- // whole number (e.g 3).
- _computePage(offset, itemsPerPage) {
- return offset / itemsPerPage + 1;
- }
-}
-
-customElements.define(GrListView.is, GrListView);
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
new file mode 100644
index 0000000..342e937
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.ts
@@ -0,0 +1,149 @@
+/**
+ * @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-input/iron-input';
+import '@polymer/iron-icon/iron-icon';
+import '../../../styles/shared-styles';
+import '../gr-button/gr-button';
+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-list-view_html';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {page} from '../../../utils/page-wrapper-utils';
+import {property, observe, customElement} from '@polymer/decorators';
+
+const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-list-view': GrListView;
+ }
+}
+
+@customElement('gr-list-view')
+class GrListView extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Boolean})
+ createNew?: boolean;
+
+ @property({type: Array})
+ items?: unknown[];
+
+ @property({type: Number})
+ itemsPerPage = 25;
+
+ @property({type: String})
+ filter?: string;
+
+ @property({type: Number})
+ offset?: number;
+
+ @property({type: Boolean})
+ loading?: boolean;
+
+ @property({type: String})
+ path?: string;
+
+ /** @override */
+ detached() {
+ super.detached();
+ this.cancelDebouncer('reload');
+ }
+
+ @observe('filter')
+ _filterChanged(newFilter: string, oldFilter: string) {
+ if (!newFilter && !oldFilter) {
+ return;
+ }
+
+ this._debounceReload(newFilter);
+ }
+
+ _debounceReload(filter: string) {
+ this.debounce(
+ 'reload',
+ () => {
+ if (this.path) {
+ if (filter) {
+ return page.show(
+ `${this.path}/q/filter:` + encodeURL(filter, false)
+ );
+ }
+ return page.show(this.path);
+ }
+ },
+ REQUEST_DEBOUNCE_INTERVAL_MS
+ );
+ }
+
+ _createNewItem() {
+ this.dispatchEvent(
+ new CustomEvent('create-clicked', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+
+ _computeNavLink(
+ offset: number,
+ direction: number,
+ itemsPerPage: number,
+ filter: string,
+ path: string
+ ) {
+ // Offset could be a string when passed from the router.
+ offset = +(offset || 0);
+ const newOffset = Math.max(0, offset + itemsPerPage * direction);
+ let href = getBaseUrl() + path;
+ if (filter) {
+ href += '/q/filter:' + encodeURL(filter, false);
+ }
+ if (newOffset > 0) {
+ href += `,${newOffset}`;
+ }
+ return href;
+ }
+
+ _computeCreateClass(createNew?: boolean) {
+ return createNew ? 'show' : '';
+ }
+
+ _hidePrevArrow(loading?: boolean, offset?: number) {
+ return loading || offset === 0;
+ }
+
+ _hideNextArrow(loading?: boolean, items?: unknown[]) {
+ if (loading || !items || !items.length) {
+ return true;
+ }
+ const lastPage = items.length < this.itemsPerPage + 1;
+ return lastPage;
+ }
+
+ // TODO: fix offset (including itemsPerPage)
+ // to either support a decimal or make it go to the nearest
+ // whole number (e.g 3).
+ _computePage(offset: number, itemsPerPage: number) {
+ return offset / itemsPerPage + 1;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
index 7782629..066c53e 100644
--- a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.js
@@ -17,7 +17,7 @@
import '../../../test/common-test-setup-karma.js';
import './gr-list-view.js';
-import page from 'page/page.mjs';
+import {page} from '../../../utils/page-wrapper-utils.js';
import {stubBaseUrl} from '../../../test/test-utils.js';
const basicFixture = fixtureFromElement('gr-list-view');
@@ -59,13 +59,16 @@
'/admin/projects/q/filter:plugins%252F,50');
});
- test('_onValueChange', done => {
+ test('_onValueChange', async () => {
+ let resolve;
+ const promise = new Promise(r => resolve = r);
element.path = '/admin/projects';
- sinon.stub(page, 'show').callsFake( url => {
- assert.equal(url, '/admin/projects/q/filter:test');
- done();
- });
+ sinon.stub(page, 'show').callsFake(resolve);
+
element.filter = 'test';
+
+ const url = await promise;
+ assert.equal(url, '/admin/projects/q/filter:test');
});
test('_filterChanged not reload when swap between falsy values', () => {
@@ -76,23 +79,21 @@
assert.isFalse(element._debounceReload.called);
});
- test('next button', done => {
+ test('next button', () => {
element.itemsPerPage = 25;
let projects = new Array(26);
+ flush();
- flush(() => {
- let loading;
- assert.isFalse(element._hideNextArrow(loading, projects));
- loading = true;
- assert.isTrue(element._hideNextArrow(loading, projects));
- loading = false;
- assert.isFalse(element._hideNextArrow(loading, projects));
- element._projects = [];
- assert.isTrue(element._hideNextArrow(loading, element._projects));
- projects = new Array(4);
- assert.isTrue(element._hideNextArrow(loading, projects));
- done();
- });
+ let loading;
+ assert.isFalse(element._hideNextArrow(loading, projects));
+ loading = true;
+ assert.isTrue(element._hideNextArrow(loading, projects));
+ loading = false;
+ assert.isFalse(element._hideNextArrow(loading, projects));
+ element._projects = [];
+ assert.isTrue(element._hideNextArrow(loading, element._projects));
+ projects = new Array(4);
+ assert.isTrue(element._hideNextArrow(loading, projects));
});
test('prev button', () => {
@@ -110,7 +111,7 @@
.querySelector('#createNewContainer').classList
.contains('show'));
element.createNew = true;
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.shadowRoot
.querySelector('#createNewContainer').classList
.contains('show'));
@@ -120,7 +121,7 @@
const clickHandler = sinon.stub();
element.addEventListener('create-clicked', clickHandler);
element.createNew = true;
- flushAsynchronousOperations();
+ flush();
MockInteractions.tap(element.shadowRoot.querySelector('#createNew'));
assert.isTrue(clickHandler.called);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
deleted file mode 100644
index 882c557..0000000
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ /dev/null
@@ -1,126 +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 {IronOverlayBehaviorImpl} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.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-overlay_html.js';
-import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin.js';
-
-const AWAIT_MAX_ITERS = 10;
-const AWAIT_STEP = 5;
-const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
-
-/**
- * @extends PolymerElement
- */
-class GrOverlay extends IronOverlayMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-overlay'; }
- /**
- * Fired when a fullscreen overlay is closed
- *
- * @event fullscreen-overlay-closed
- */
-
- /**
- * Fired when an overlay is opened in full screen mode
- *
- * @event fullscreen-overlay-opened
- */
-
- static get properties() {
- return {
- _fullScreenOpen: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('iron-overlay-closed',
- () => this._close());
- this.addEventListener('iron-overlay-cancelled',
- () => this._close());
- }
-
- open(...args) {
- return new Promise((resolve, reject) => {
- IronOverlayBehaviorImpl.open.apply(this, args);
- if (this._isMobile()) {
- this.dispatchEvent(new CustomEvent('fullscreen-overlay-opened', {
- composed: true, bubbles: true,
- }));
- this._fullScreenOpen = true;
- }
- this._awaitOpen(resolve, reject);
- });
- }
-
- _isMobile() {
- return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
- }
-
- _close() {
- if (this._fullScreenOpen) {
- this.dispatchEvent(new CustomEvent('fullscreen-overlay-closed', {
- composed: true, bubbles: true,
- }));
- this._fullScreenOpen = false;
- }
- }
-
- /**
- * Override the focus stops that iron-overlay-behavior tries to find.
- */
- setFocusStops(stops) {
- this.__firstFocusableNode = stops.start;
- this.__lastFocusableNode = stops.end;
- }
-
- /**
- * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
- * opening. Eventually replace with a direct way to listen to the overlay.
- */
- _awaitOpen(fn, reject) {
- let iters = 0;
- const step = () => {
- this.async(() => {
- if (this.style.display !== 'none') {
- fn.call(this);
- } else if (iters++ < AWAIT_MAX_ITERS) {
- step.call(this);
- } else {
- reject(new Error('gr-overlay _awaitOpen failed to resolve'));
- }
- }, AWAIT_STEP);
- };
- step.call(this);
- }
-
- _id() {
- return this.getAttribute('id') || 'global';
- }
-}
-
-customElements.define(GrOverlay.is, GrOverlay);
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
new file mode 100644
index 0000000..957496c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -0,0 +1,161 @@
+/**
+ * @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 '../../../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-overlay_html';
+import {IronOverlayMixin} from '../../../mixins/iron-overlay-mixin/iron-overlay-mixin';
+import {customElement, property} from '@polymer/decorators';
+import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
+
+const AWAIT_MAX_ITERS = 10;
+const AWAIT_STEP = 5;
+const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-overlay': GrOverlay;
+ }
+}
+
+@customElement('gr-overlay')
+export class GrOverlay extends IronOverlayMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement)),
+ IronOverlayBehavior as IronOverlayBehavior
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * Fired when a fullscreen overlay is closed
+ *
+ * @event fullscreen-overlay-closed
+ */
+
+ /**
+ * Fired when an overlay is opened in full screen mode
+ *
+ * @event fullscreen-overlay-opened
+ */
+
+ @property({type: Boolean})
+ private _fullScreenOpen = false;
+
+ private _boundHandleClose: () => void = () => super.close();
+
+ private focusableNodes: Node[] | undefined;
+
+ get _focusableNodes() {
+ if (this.focusableNodes) {
+ return this.focusableNodes;
+ }
+ // TODO(TS): to avoid ts error for:
+ // Only public and protected methods of the base class are accessible
+ // via the 'super' keyword.
+ // we call IronFocsablesHelper directly here
+ // Currently IronFocsablesHelper is not exported from iron-focusables-helper
+ // as it should so we use Polymer.IronFocsablesHelper here instead
+ // (can not use the IronFocsablesHelperClass
+ // in case different behavior due to singleton)
+ // once the type contains the exported member,
+ // should replace with:
+ // import {IronFocusablesHelper} from '@polymer/iron-overlay-behavior/iron-focusables-helper';
+ return (window.Polymer as any).IronFocusablesHelper.getTabbableNodes(this);
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('iron-overlay-closed', () => this._overlayClosed());
+ this.addEventListener('iron-overlay-cancelled', () =>
+ this._overlayClosed()
+ );
+ }
+
+ open() {
+ window.addEventListener('popstate', this._boundHandleClose);
+ return new Promise((resolve, reject) => {
+ super.open.apply(this);
+ if (this._isMobile()) {
+ this.dispatchEvent(
+ new CustomEvent('fullscreen-overlay-opened', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._fullScreenOpen = true;
+ }
+ this._awaitOpen(resolve, reject);
+ });
+ }
+
+ _isMobile() {
+ return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
+ }
+
+ // called after iron-overlay is closed. Does not actually close the overlay
+ _overlayClosed() {
+ window.removeEventListener('popstate', this._boundHandleClose);
+ if (this._fullScreenOpen) {
+ this.dispatchEvent(
+ new CustomEvent('fullscreen-overlay-closed', {
+ composed: true,
+ bubbles: true,
+ })
+ );
+ this._fullScreenOpen = false;
+ }
+ }
+
+ /**
+ * Override the focus stops that iron-overlay-behavior tries to find.
+ */
+ setFocusStops(stops: GrOverlayStops) {
+ this.focusableNodes = [stops.start, stops.end];
+ }
+
+ /**
+ * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
+ * opening. Eventually replace with a direct way to listen to the overlay.
+ */
+ _awaitOpen(fn: (this: GrOverlay) => void, reject: (error: Error) => void) {
+ let iters = 0;
+ const step = () => {
+ this.async(() => {
+ if (this.style.display !== 'none') {
+ fn.call(this);
+ } else if (iters++ < AWAIT_MAX_ITERS) {
+ step.call(this);
+ } else {
+ reject(new Error('gr-overlay _awaitOpen failed to resolve'));
+ }
+ }, AWAIT_STEP);
+ };
+ step.call(this);
+ }
+
+ _id() {
+ return this.getAttribute('id') || 'global';
+ }
+}
+
+export interface GrOverlayStops {
+ start: Node;
+ end: Node;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
index f3c591b..4b6ae34 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.js
@@ -32,42 +32,55 @@
element = basicFixture.instantiate();
});
- test('events are fired on fullscreen view', done => {
+ test('popstate listener is attached on open and removed on close', () => {
+ const addEventListenerStub = sinon.stub(window, 'addEventListener');
+ const removeEventListenerStub = sinon.stub(window, 'removeEventListener');
+ element.open();
+ assert.isTrue(addEventListenerStub.called);
+ assert.equal(addEventListenerStub.lastCall.args[0], 'popstate');
+ assert.equal(addEventListenerStub.lastCall.args[1],
+ element._boundHandleClose);
+ element._overlayClosed();
+ assert.isTrue(removeEventListenerStub.called);
+ assert.equal(removeEventListenerStub.lastCall.args[0], 'popstate');
+ assert.equal(removeEventListenerStub.lastCall.args[1],
+ element._boundHandleClose);
+ });
+
+ test('events are fired on fullscreen view', async () => {
sinon.stub(element, '_isMobile').returns(true);
const openHandler = sinon.stub();
const closeHandler = sinon.stub();
element.addEventListener('fullscreen-overlay-opened', openHandler);
element.addEventListener('fullscreen-overlay-closed', closeHandler);
- element.open().then(() => {
- assert.isTrue(element._isMobile.called);
- assert.isTrue(element._fullScreenOpen);
- assert.isTrue(openHandler.called);
+ await element.open();
- element._close();
- assert.isFalse(element._fullScreenOpen);
- assert.isTrue(closeHandler.called);
- done();
- });
+ assert.isTrue(element._isMobile.called);
+ assert.isTrue(element._fullScreenOpen);
+ assert.isTrue(openHandler.called);
+
+ element._overlayClosed();
+ assert.isFalse(element._fullScreenOpen);
+ assert.isTrue(closeHandler.called);
});
- test('events are not fired on desktop view', done => {
+ test('events are not fired on desktop view', async () => {
sinon.stub(element, '_isMobile').returns(false);
const openHandler = sinon.stub();
const closeHandler = sinon.stub();
element.addEventListener('fullscreen-overlay-opened', openHandler);
element.addEventListener('fullscreen-overlay-closed', closeHandler);
- element.open().then(() => {
- assert.isTrue(element._isMobile.called);
- assert.isFalse(element._fullScreenOpen);
- assert.isFalse(openHandler.called);
+ await element.open();
- element._close();
- assert.isFalse(element._fullScreenOpen);
- assert.isFalse(closeHandler.called);
- done();
- });
+ assert.isTrue(element._isMobile.called);
+ assert.isFalse(element._fullScreenOpen);
+ assert.isFalse(openHandler.called);
+
+ element._overlayClosed();
+ assert.isFalse(element._fullScreenOpen);
+ assert.isFalse(closeHandler.called);
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
deleted file mode 100644
index f191981..0000000
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
+++ /dev/null
@@ -1,79 +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/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-page-nav_html.js';
-
-/** @extends PolymerElement */
-class GrPageNav extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-page-nav'; }
-
- static get properties() {
- return {
- _headerHeight: Number,
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- this.listen(window, 'scroll', '_handleBodyScroll');
- }
-
- /** @override */
- detached() {
- super.detached();
- this.unlisten(window, 'scroll', '_handleBodyScroll');
- }
-
- _handleBodyScroll() {
- if (this._headerHeight === undefined) {
- let top = this._getOffsetTop(this);
- for (let offsetParent = this.offsetParent;
- offsetParent;
- offsetParent = this._getOffsetParent(offsetParent)) {
- top += this._getOffsetTop(offsetParent);
- }
- this._headerHeight = top;
- }
-
- this.$.nav.classList.toggle('pinned',
- this._getScrollY() >= this._headerHeight);
- }
-
- /* Functions used for test purposes */
- _getOffsetParent(element) {
- if (!element || !element.offsetParent) { return ''; }
- return element.offsetParent;
- }
-
- _getOffsetTop(element) {
- return element.offsetTop;
- }
-
- _getScrollY() {
- return window.scrollY;
- }
-}
-
-customElements.define(GrPageNav.is, GrPageNav);
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
new file mode 100644
index 0000000..e009499
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
@@ -0,0 +1,100 @@
+/**
+ * @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/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-page-nav_html';
+import {customElement, property} from '@polymer/decorators';
+
+/**
+ * Augment the interface on top of PolymerElement
+ * for gr-page-nav.
+ */
+export interface GrPageNav {
+ $: {
+ // Note: this is needed to access $.nav
+ // with dotted property access
+ nav: HTMLElement;
+ };
+}
+
+@customElement('gr-page-nav')
+export class GrPageNav extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: Number})
+ _headerHeight?: number;
+
+ private readonly bodyScrollHandler: () => void;
+
+ constructor() {
+ super();
+ this.bodyScrollHandler = () => this._handleBodyScroll();
+ }
+
+ attached() {
+ super.attached();
+ window.addEventListener('scroll', this.bodyScrollHandler);
+ }
+
+ detached() {
+ super.detached();
+ window.removeEventListener('scroll', this.bodyScrollHandler);
+ }
+
+ _handleBodyScroll() {
+ if (this._headerHeight === undefined) {
+ let top = this._getOffsetTop(this);
+ // TODO(TS): Element doesn't have offsetParent,
+ // while `offsetParent` are returning Element not HTMLElement
+ for (
+ let offsetParent = this.offsetParent as HTMLElement | undefined;
+ offsetParent;
+ offsetParent = this._getOffsetParent(offsetParent)
+ ) {
+ top += this._getOffsetTop(offsetParent);
+ }
+ this._headerHeight = top;
+ }
+
+ this.$.nav.classList.toggle(
+ 'pinned',
+ this._getScrollY() >= (this._headerHeight || 0)
+ );
+ }
+
+ /* Functions used for test purposes */
+ _getOffsetParent(element?: HTMLElement) {
+ if (!element || !('offsetParent' in element)) {
+ return undefined;
+ }
+ return element.offsetParent as HTMLElement;
+ }
+
+ _getOffsetTop(element: HTMLElement) {
+ return element.offsetTop;
+ }
+
+ _getScrollY() {
+ return window.scrollY;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
index 2e40b27..2960a1f 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.js
@@ -32,7 +32,7 @@
setup(() => {
element = basicFixture.instantiate();
- flushAsynchronousOperations();
+ flush();
});
test('header is not pinned just below top', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
deleted file mode 100644
index 746fcf8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.js
+++ /dev/null
@@ -1,129 +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 '../../../styles/shared-styles.js';
-import '../gr-icons/gr-icons.js';
-import '../gr-labeled-autocomplete/gr-labeled-autocomplete.js';
-import '../gr-rest-api-interface/gr-rest-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-repo-branch-picker_html.js';
-import {singleDecodeURL} from '../../../utils/url-util.js';
-
-const SUGGESTIONS_LIMIT = 15;
-const REF_PREFIX = 'refs/heads/';
-
-/**
- * @extends PolymerElement
- */
-class GrRepoBranchPicker extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-repo-branch-picker'; }
-
- static get properties() {
- return {
- repo: {
- type: String,
- notify: true,
- observer: '_repoChanged',
- },
- branch: {
- type: String,
- notify: true,
- },
- _branchDisabled: Boolean,
- _query: {
- type: Function,
- value() {
- return this._getRepoBranchesSuggestions.bind(this);
- },
- },
- _repoQuery: {
- type: Function,
- value() {
- return this._getRepoSuggestions.bind(this);
- },
- },
- };
- }
-
- /** @override */
- attached() {
- super.attached();
- if (this.repo) {
- this.$.repoInput.setText(this.repo);
- }
- }
-
- /** @override */
- ready() {
- super.ready();
- this._branchDisabled = !this.repo;
- }
-
- _getRepoBranchesSuggestions(input) {
- if (!this.repo) { return Promise.resolve([]); }
- if (input.startsWith(REF_PREFIX)) {
- input = input.substring(REF_PREFIX.length);
- }
- return this.$.restAPI.getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
- .then(this._branchResponseToSuggestions.bind(this));
- }
-
- _getRepoSuggestions(input) {
- return this.$.restAPI.getRepos(input, SUGGESTIONS_LIMIT)
- .then(this._repoResponseToSuggestions.bind(this));
- }
-
- _repoResponseToSuggestions(res) {
- return res.map(repo => {
- return {
- name: repo.name,
- value: singleDecodeURL(repo.id),
- };
- });
- }
-
- _branchResponseToSuggestions(res) {
- return Object.keys(res).map(key => {
- let branch = res[key].ref;
- if (branch.startsWith(REF_PREFIX)) {
- branch = branch.substring(REF_PREFIX.length);
- }
- return {name: branch, value: branch};
- });
- }
-
- _repoCommitted(e) {
- this.repo = e.detail.value;
- }
-
- _branchCommitted(e) {
- this.branch = e.detail.value;
- }
-
- _repoChanged() {
- this.$.branchInput.clear();
- this._branchDisabled = !this.repo;
- }
-}
-
-customElements.define(GrRepoBranchPicker.is, GrRepoBranchPicker);
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
new file mode 100644
index 0000000..01eada8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker.ts
@@ -0,0 +1,150 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../gr-icons/gr-icons';
+import '../gr-labeled-autocomplete/gr-labeled-autocomplete';
+import '../gr-rest-api-interface/gr-rest-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-repo-branch-picker_html';
+import {singleDecodeURL} from '../../../utils/url-util';
+import {customElement, property} from '@polymer/decorators';
+import {AutocompleteQuery} from '../gr-autocomplete/gr-autocomplete';
+import {
+ BranchName,
+ RepoName,
+ ProjectInfoWithName,
+ BranchInfo,
+} from '../../../types/common';
+import {GrLabeledAutocomplete} from '../gr-labeled-autocomplete/gr-labeled-autocomplete';
+import {RestApiService} from '../../../services/services/gr-rest-api/gr-rest-api';
+
+const SUGGESTIONS_LIMIT = 15;
+const REF_PREFIX = 'refs/heads/';
+
+export interface GrRepoBranchPicker {
+ $: {
+ repoInput: GrLabeledAutocomplete;
+ branchInput: GrLabeledAutocomplete;
+ restAPI: RestApiService & Element;
+ };
+}
+@customElement('gr-repo-branch-picker')
+export class GrRepoBranchPicker extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, notify: true, observer: '_repoChanged'})
+ repo?: RepoName;
+
+ @property({type: String, notify: true})
+ branch?: BranchName;
+
+ @property({type: Boolean})
+ _branchDisabled?: boolean;
+
+ @property({type: Object})
+ _query?: AutocompleteQuery;
+
+ @property({type: Object})
+ _repoQuery?: AutocompleteQuery;
+
+ constructor() {
+ super();
+ this._query = input => this._getRepoBranchesSuggestions(input);
+ this._repoQuery = input => this._getRepoSuggestions(input);
+ }
+
+ /** @override */
+ attached() {
+ super.attached();
+ if (this.repo) {
+ this.$.repoInput.setText(this.repo);
+ }
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ this._branchDisabled = !this.repo;
+ }
+
+ _getRepoBranchesSuggestions(input: string) {
+ if (!this.repo) {
+ return Promise.resolve([]);
+ }
+ if (input.startsWith(REF_PREFIX)) {
+ input = input.substring(REF_PREFIX.length);
+ }
+ return this.$.restAPI
+ .getRepoBranches(input, this.repo, SUGGESTIONS_LIMIT)
+ .then(res => this._branchResponseToSuggestions(res));
+ }
+
+ _getRepoSuggestions(input: string) {
+ return this.$.restAPI
+ .getRepos(input, SUGGESTIONS_LIMIT)
+ .then(res => this._repoResponseToSuggestions(res));
+ }
+
+ _repoResponseToSuggestions(res: ProjectInfoWithName[] | undefined) {
+ if (!res) return [];
+ return res.map(repo => {
+ return {
+ name: repo.name,
+ value: singleDecodeURL(repo.id),
+ };
+ });
+ }
+
+ _branchResponseToSuggestions(res: BranchInfo[] | undefined) {
+ if (!res) return [];
+ return res.map(branchInfo => {
+ let branch;
+ if (branchInfo.ref.startsWith(REF_PREFIX)) {
+ branch = branchInfo.ref.substring(REF_PREFIX.length);
+ } else {
+ branch = branchInfo.ref;
+ }
+ return {name: branch, value: branch};
+ });
+ }
+
+ _repoCommitted(e: CustomEvent<{value: string}>) {
+ this.repo = e.detail.value as RepoName;
+ }
+
+ _branchCommitted(e: CustomEvent<{value: string}>) {
+ this.branch = e.detail.value as BranchName;
+ }
+
+ _repoChanged() {
+ this.$.branchInput.clear();
+ this._branchDisabled = !this.repo;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-repo-branch-picker': GrRepoBranchPicker;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
index bfdbe41..1d8ae98 100644
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
@@ -108,19 +108,16 @@
});
});
- test('does not query when repo is unset', done => {
- element
- ._getRepoBranchesSuggestions('')
- .then(() => {
- assert.isFalse(element.$.restAPI.getRepoBranches.called);
- element.repo = 'gerrit';
- return element._getRepoBranchesSuggestions('');
- })
- .then(() => {
- assert.isTrue(element.$.restAPI.getRepoBranches.called);
- done();
- });
- });
+ test('does not query when repo is unset', () => element
+ ._getRepoBranchesSuggestions('')
+ .then(() => {
+ assert.isFalse(element.$.restAPI.getRepoBranches.called);
+ element.repo = 'gerrit';
+ return element._getRepoBranchesSuggestions('');
+ })
+ .then(() => {
+ assert.isTrue(element.$.restAPI.getRepoBranches.called);
+ }));
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
deleted file mode 100644
index a4adbac..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
+++ /dev/null
@@ -1,97 +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.
- */
-
-// Limit cache size because /change/detail responses may be large.
-const MAX_CACHE_SIZE = 30;
-
-/** @constructor */
-export function GrEtagDecorator() {
- this._etags = new Map();
- this._payloadCache = new Map();
-}
-
-/**
- * Get or upgrade fetch options to include an ETag in a request.
- *
- * @param {string} url The URL being fetched.
- * @param {!Object=} opt_options Optional options object in which to include
- * the ETag request header. If omitted, the result will be a fresh option
- * set.
- * @return {!Object}
- */
-GrEtagDecorator.prototype.getOptions = function(url, opt_options) {
- const etag = this._etags.get(url);
- if (!etag) {
- return opt_options;
- }
- const options = Object.assign({}, opt_options);
- options.headers = options.headers || new Headers();
- options.headers.set('If-None-Match', this._etags.get(url));
- return options;
-};
-
-/**
- * Handle a response to a request with ETag headers, potentially incorporating
- * its result in the payload cache.
- *
- * @param {string} url The URL of the request.
- * @param {!Response} response The response object.
- * @param {string} payload The raw, unparsed JSON contained in the response
- * body. Note: because response.text() cannot be read twice, this must be
- * provided separately.
- */
-GrEtagDecorator.prototype.collect = function(url, response, payload) {
- if (!response ||
- !response.ok ||
- response.status !== 200 ||
- response.status === 304) {
- // 304 Not Modified means etag is still valid.
- return;
- }
- this._payloadCache.set(url, payload);
- const etag = response.headers && response.headers.get('etag');
- if (!etag) {
- this._etags.delete(url);
- } else {
- this._etags.set(url, etag);
- this._truncateCache();
- }
-};
-
-/**
- * Get the cached payload for a given URL.
- *
- * @param {string} url
- * @return {string|undefined} Returns the unparsed JSON payload from the
- * cache.
- */
-GrEtagDecorator.prototype.getCachedPayload = function(url) {
- return this._payloadCache.get(url);
-};
-
-/**
- * Limit the cache size to MAX_CACHE_SIZE.
- */
-GrEtagDecorator.prototype._truncateCache = function() {
- for (const url of this._etags.keys()) {
- if (this._etags.size <= MAX_CACHE_SIZE) {
- break;
- }
- this._etags.delete(url);
- this._payloadCache.delete(url);
- }
-};
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
new file mode 100644
index 0000000..2c1ba70
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.ts
@@ -0,0 +1,98 @@
+/**
+ * @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.
+ */
+
+// Limit cache size because /change/detail responses may be large.
+const MAX_CACHE_SIZE = 30;
+
+/**
+ * Option to send with etag requests.
+ */
+export interface ETagOption {
+ headers?: Headers;
+}
+
+/**
+ * GrTagDecorator class.
+ *
+ * Defines common methods to help cache and build ETag into a request header.
+ */
+export class GrEtagDecorator {
+ _etags = new Map<string, string | null>();
+
+ _payloadCache = new Map<string, string>();
+
+ /**
+ * Get or upgrade fetch options to include an ETag in a request.
+ *
+ */
+ getOptions(url: string, options?: ETagOption) {
+ const etag = this._etags.get(url);
+ if (!etag) {
+ return options;
+ }
+ const optionsCopy: ETagOption = {...options};
+ optionsCopy.headers = optionsCopy.headers || new Headers();
+ optionsCopy.headers.set('If-None-Match', etag);
+ return optionsCopy;
+ }
+
+ /**
+ * Handle a response to a request with ETag headers, potentially incorporating
+ * its result in the payload cache.
+ *
+ *
+ * @param url The URL of the request.
+ * @param response The response object.
+ * @param payload The raw, unparsed JSON contained in the response
+ * body. Note: because response.text() cannot be read twice, this must be
+ * provided separately.
+ */
+ collect(url: string, response: Response, payload: string) {
+ if (!response || !response.ok || response.status !== 200) {
+ // 304 Not Modified means etag is still valid.
+ return;
+ }
+ this._payloadCache.set(url, payload);
+ const etag = response.headers && response.headers.get('etag');
+ if (!etag) {
+ this._etags.delete(url);
+ } else {
+ this._etags.set(url, etag);
+ this._truncateCache();
+ }
+ }
+
+ /**
+ * Get the cached payload for a given URL.
+ */
+ getCachedPayload(url: string) {
+ return this._payloadCache.get(url);
+ }
+
+ /**
+ * Limit the cache size to MAX_CACHE_SIZE.
+ */
+ _truncateCache() {
+ for (const url of this._etags.keys()) {
+ if (this._etags.size <= MAX_CACHE_SIZE) {
+ break;
+ }
+ this._etags.delete(url);
+ this._payloadCache.delete(url);
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js
index e5217a4..f4099ec 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.js
@@ -16,6 +16,7 @@
*/
import '../../../test/common-test-setup-karma.js';
+import 'lodash/lodash.js';
import {GrEtagDecorator} from './gr-etag-decorator.js';
suite('gr-etag-decorator', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
deleted file mode 100644
index f78d10b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ /dev/null
@@ -1,2876 +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.
- */
-/* NB: Order is important, because of namespaced classes. */
-
-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 {GrEtagDecorator} from './gr-etag-decorator.js';
-import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './gr-rest-apis/gr-rest-api-helper.js';
-import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
-import {parseDate} from '../../../utils/date-util.js';
-import {getBaseUrl} from '../../../utils/url-util.js';
-import {appContext} from '../../../services/app-context.js';
-import {
- getParentIndex,
- isMergeParent,
- patchNumEquals,
- SPECIAL_PATCH_SET_NUM,
-} from '../../../utils/patch-set-util.js';
-import {ListChangesOption, listChangesOptionsToHex} from '../../../utils/change-util.js';
-
-const DiffViewMode = {
- SIDE_BY_SIDE: 'SIDE_BY_SIDE',
- UNIFIED: 'UNIFIED_DIFF',
-};
-const JSON_PREFIX = ')]}\'';
-const MAX_PROJECT_RESULTS = 25;
-// This value is somewhat arbitrary and not based on research or calculations.
-const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
-
-const Requests = {
- SEND_DIFF_DRAFT: 'sendDiffDraft',
-};
-
-const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
- 'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
-const HEADER_REPORTING_BLOCK_REGEX = /^set-cookie$/i;
-
-const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
-const ANONYMIZED_REVISION_BASE_URL = ANONYMIZED_CHANGE_BASE_URL +
- '/revisions/*';
-
-let siteBasedCache = new SiteBasedCache(); // Shared across instances.
-let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances.
-let pendingRequest = {}; // Shared across instances.
-let grEtagDecorator = new GrEtagDecorator; // Shared across instances.
-let projectLookup = {}; // Shared across instances.
-
-export function _testOnlyResetGrRestApiSharedObjects() {
- for (const key in fetchPromisesCache._data) {
- if (fetchPromisesCache._data.hasOwnProperty(key)) {
- // reject already fulfilled promise does nothing
- fetchPromisesCache._data[key].reject();
- }
- }
-
- for (const key in pendingRequest) {
- if (!pendingRequest.hasOwnProperty(key)) {
- continue;
- }
- for (const req of pendingRequest[key]) {
- // reject already fulfilled promise does nothing
- req.reject();
- }
- }
-
- siteBasedCache = new SiteBasedCache();
- fetchPromisesCache = new FetchPromisesCache();
- pendingRequest = {};
- grEtagDecorator = new GrEtagDecorator;
- projectLookup = {};
- appContext.authService.clearCache();
-}
-
-/**
- * @extends PolymerElement
- */
-class GrRestApiInterface extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get is() { return 'gr-rest-api-interface'; }
- /**
- * Fired when an server error occurs.
- *
- * @event server-error
- */
-
- /**
- * Fired when a network error occurs.
- *
- * @event network-error
- */
-
- /**
- * Fired after an RPC completes.
- *
- * @event rpc-log
- */
-
- constructor() {
- super();
- this.JSON_PREFIX = JSON_PREFIX;
- }
-
- static get properties() {
- return {
- _cache: {
- type: Object,
- value: siteBasedCache, // Shared across instances.
- },
- _sharedFetchPromises: {
- type: Object,
- value: fetchPromisesCache, // Shared across instances.
- },
- _pendingRequests: {
- type: Object,
- value: pendingRequest, // Intentional to share the object across instances.
- },
- _etags: {
- type: Object,
- value: grEtagDecorator, // Share across instances.
- },
- /**
- * Used to maintain a mapping of changeNums to project names.
- */
- _projectLookup: {
- type: Object,
- value: projectLookup, // Intentional to share the object across instances.
- },
- };
- }
-
- /** @override */
- created() {
- super.created();
- this.authService = appContext.authService;
- this._initRestApiHelper();
- }
-
- _initRestApiHelper() {
- if (this._restApiHelper) {
- return;
- }
- if (this._cache && this.authService && this._sharedFetchPromises) {
- this._restApiHelper = new GrRestApiHelper(this._cache, this.authService,
- this._sharedFetchPromises, this);
- }
- }
-
- _fetchSharedCacheURL(req) {
- // Cache is shared across instances
- return this._restApiHelper.fetchCacheURL(req);
- }
-
- /**
- * @param {!Object} response
- * @return {?}
- */
- getResponseObject(response) {
- return this._restApiHelper.getResponseObject(response);
- }
-
- getConfig(noCache) {
- if (!noCache) {
- return this._fetchSharedCacheURL({
- url: '/config/server/info',
- reportUrlAsIs: true,
- });
- }
-
- return this._restApiHelper.fetchJSON({
- url: '/config/server/info',
- reportUrlAsIs: true,
- });
- }
-
- getRepo(repo, opt_errFn) {
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._fetchSharedCacheURL({
- url: '/projects/' + encodeURIComponent(repo),
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*',
- });
- }
-
- getProjectConfig(repo, opt_errFn) {
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._fetchSharedCacheURL({
- url: '/projects/' + encodeURIComponent(repo) + '/config',
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/config',
- });
- }
-
- getRepoAccess(repo) {
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._fetchSharedCacheURL({
- url: '/access/?project=' + encodeURIComponent(repo),
- anonymizedUrl: '/access/?project=*',
- });
- }
-
- getRepoDashboards(repo, opt_errFn) {
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._fetchSharedCacheURL({
- url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/dashboards?inherited',
- });
- }
-
- saveRepoConfig(repo, config, opt_errFn) {
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- const url = `/projects/${encodeURIComponent(repo)}/config`;
- this._cache.delete(url);
- return this._restApiHelper.send({
- method: 'PUT',
- url,
- body: config,
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/config',
- });
- }
-
- runRepoGC(repo, opt_errFn) {
- if (!repo) { return ''; }
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- const encodeName = encodeURIComponent(repo);
- return this._restApiHelper.send({
- method: 'POST',
- url: `/projects/${encodeName}/gc`,
- body: '',
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/gc',
- });
- }
-
- /**
- * @param {?Object} config
- * @param {function(?Response, string=)=} opt_errFn
- */
- createRepo(config, opt_errFn) {
- if (!config.name) { return ''; }
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- const encodeName = encodeURIComponent(config.name);
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/projects/${encodeName}`,
- body: config,
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*',
- });
- }
-
- /**
- * @param {?Object} config
- * @param {function(?Response, string=)=} opt_errFn
- */
- createGroup(config, opt_errFn) {
- if (!config.name) { return ''; }
- const encodeName = encodeURIComponent(config.name);
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/groups/${encodeName}`,
- body: config,
- errFn: opt_errFn,
- anonymizedUrl: '/groups/*',
- });
- }
-
- getGroupConfig(group, opt_errFn) {
- return this._restApiHelper.fetchJSON({
- url: `/groups/${encodeURIComponent(group)}/detail`,
- errFn: opt_errFn,
- anonymizedUrl: '/groups/*/detail',
- });
- }
-
- /**
- * @param {string} repo
- * @param {string} ref
- * @param {function(?Response, string=)=} opt_errFn
- */
- deleteRepoBranches(repo, ref, opt_errFn) {
- if (!repo || !ref) { return ''; }
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- const encodeName = encodeURIComponent(repo);
- const encodeRef = encodeURIComponent(ref);
- return this._restApiHelper.send({
- method: 'DELETE',
- url: `/projects/${encodeName}/branches/${encodeRef}`,
- body: '',
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/branches/*',
- });
- }
-
- /**
- * @param {string} repo
- * @param {string} ref
- * @param {function(?Response, string=)=} opt_errFn
- */
- deleteRepoTags(repo, ref, opt_errFn) {
- if (!repo || !ref) { return ''; }
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- const encodeName = encodeURIComponent(repo);
- const encodeRef = encodeURIComponent(ref);
- return this._restApiHelper.send({
- method: 'DELETE',
- url: `/projects/${encodeName}/tags/${encodeRef}`,
- body: '',
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/tags/*',
- });
- }
-
- /**
- * @param {string} name
- * @param {string} branch
- * @param {string} revision
- * @param {function(?Response, string=)=} opt_errFn
- */
- createRepoBranch(name, branch, revision, opt_errFn) {
- if (!name || !branch || !revision) { return ''; }
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- const encodeName = encodeURIComponent(name);
- const encodeBranch = encodeURIComponent(branch);
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/projects/${encodeName}/branches/${encodeBranch}`,
- body: revision,
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/branches/*',
- });
- }
-
- /**
- * @param {string} name
- * @param {string} tag
- * @param {string} revision
- * @param {function(?Response, string=)=} opt_errFn
- */
- createRepoTag(name, tag, revision, opt_errFn) {
- if (!name || !tag || !revision) { return ''; }
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- const encodeName = encodeURIComponent(name);
- const encodeTag = encodeURIComponent(tag);
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/projects/${encodeName}/tags/${encodeTag}`,
- body: revision,
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/tags/*',
- });
- }
-
- /**
- * @param {!string} groupName
- * @returns {!Promise<boolean>}
- */
- getIsGroupOwner(groupName) {
- const encodeName = encodeURIComponent(groupName);
- const req = {
- url: `/groups/?owned&g=${encodeName}`,
- anonymizedUrl: '/groups/owned&g=*',
- };
- return this._fetchSharedCacheURL(req)
- .then(configs => configs.hasOwnProperty(groupName));
- }
-
- getGroupMembers(groupName, opt_errFn) {
- const encodeName = encodeURIComponent(groupName);
- return this._restApiHelper.fetchJSON({
- url: `/groups/${encodeName}/members/`,
- errFn: opt_errFn,
- anonymizedUrl: '/groups/*/members',
- });
- }
-
- getIncludedGroup(groupName) {
- return this._restApiHelper.fetchJSON({
- url: `/groups/${encodeURIComponent(groupName)}/groups/`,
- anonymizedUrl: '/groups/*/groups',
- });
- }
-
- saveGroupName(groupId, name) {
- const encodeId = encodeURIComponent(groupId);
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/groups/${encodeId}/name`,
- body: {name},
- anonymizedUrl: '/groups/*/name',
- });
- }
-
- saveGroupOwner(groupId, ownerId) {
- const encodeId = encodeURIComponent(groupId);
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/groups/${encodeId}/owner`,
- body: {owner: ownerId},
- anonymizedUrl: '/groups/*/owner',
- });
- }
-
- saveGroupDescription(groupId, description) {
- const encodeId = encodeURIComponent(groupId);
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/groups/${encodeId}/description`,
- body: {description},
- anonymizedUrl: '/groups/*/description',
- });
- }
-
- saveGroupOptions(groupId, options) {
- const encodeId = encodeURIComponent(groupId);
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/groups/${encodeId}/options`,
- body: options,
- anonymizedUrl: '/groups/*/options',
- });
- }
-
- getGroupAuditLog(group, opt_errFn) {
- return this._fetchSharedCacheURL({
- url: '/groups/' + group + '/log.audit',
- errFn: opt_errFn,
- anonymizedUrl: '/groups/*/log.audit',
- });
- }
-
- saveGroupMembers(groupName, groupMembers) {
- const encodeName = encodeURIComponent(groupName);
- const encodeMember = encodeURIComponent(groupMembers);
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/groups/${encodeName}/members/${encodeMember}`,
- parseResponse: true,
- anonymizedUrl: '/groups/*/members/*',
- });
- }
-
- saveIncludedGroup(groupName, includedGroup, opt_errFn) {
- const encodeName = encodeURIComponent(groupName);
- const encodeIncludedGroup = encodeURIComponent(includedGroup);
- const req = {
- method: 'PUT',
- url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
- errFn: opt_errFn,
- anonymizedUrl: '/groups/*/groups/*',
- };
- return this._restApiHelper.send(req).then(response => {
- if (response.ok) {
- return this.getResponseObject(response);
- }
- });
- }
-
- deleteGroupMembers(groupName, groupMembers) {
- const encodeName = encodeURIComponent(groupName);
- const encodeMember = encodeURIComponent(groupMembers);
- return this._restApiHelper.send({
- method: 'DELETE',
- url: `/groups/${encodeName}/members/${encodeMember}`,
- anonymizedUrl: '/groups/*/members/*',
- });
- }
-
- deleteIncludedGroup(groupName, includedGroup) {
- const encodeName = encodeURIComponent(groupName);
- const encodeIncludedGroup = encodeURIComponent(includedGroup);
- return this._restApiHelper.send({
- method: 'DELETE',
- url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
- anonymizedUrl: '/groups/*/groups/*',
- });
- }
-
- getVersion() {
- return this._fetchSharedCacheURL({
- url: '/config/server/version',
- reportUrlAsIs: true,
- });
- }
-
- getDiffPreferences() {
- return this.getLoggedIn().then(loggedIn => {
- if (loggedIn) {
- return this._fetchSharedCacheURL({
- url: '/accounts/self/preferences.diff',
- reportUrlAsIs: true,
- });
- }
- // These defaults should match the defaults in
- // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
- // NOTE: There are some settings that don't apply to PolyGerrit
- // (Render mode being at least one of them).
- return Promise.resolve({
- auto_hide_diff_table_header: true,
- context: 10,
- cursor_blink_rate: 0,
- font_size: 12,
- ignore_whitespace: 'IGNORE_NONE',
- intraline_difference: true,
- line_length: 100,
- line_wrapping: false,
- show_line_endings: true,
- show_tabs: true,
- show_whitespace_errors: true,
- syntax_highlighting: true,
- tab_size: 8,
- theme: 'DEFAULT',
- });
- });
- }
-
- getEditPreferences() {
- return this.getLoggedIn().then(loggedIn => {
- if (loggedIn) {
- return this._fetchSharedCacheURL({
- url: '/accounts/self/preferences.edit',
- reportUrlAsIs: true,
- });
- }
- // These defaults should match the defaults in
- // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
- return Promise.resolve({
- auto_close_brackets: false,
- cursor_blink_rate: 0,
- hide_line_numbers: false,
- hide_top_menu: false,
- indent_unit: 2,
- indent_with_tabs: false,
- key_map_type: 'DEFAULT',
- line_length: 100,
- line_wrapping: false,
- match_brackets: true,
- show_base: false,
- show_tabs: true,
- show_whitespace_errors: true,
- syntax_highlighting: true,
- tab_size: 8,
- theme: 'DEFAULT',
- });
- });
- }
-
- /**
- * @param {?Object} prefs
- * @param {function(?Response, string=)=} opt_errFn
- */
- savePreferences(prefs, opt_errFn) {
- // Note (Issue 5142): normalize the download scheme with lower case before
- // saving.
- if (prefs.download_scheme) {
- prefs.download_scheme = prefs.download_scheme.toLowerCase();
- }
-
- return this._restApiHelper.send({
- method: 'PUT',
- url: '/accounts/self/preferences',
- body: prefs,
- errFn: opt_errFn,
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @param {?Object} prefs
- * @param {function(?Response, string=)=} opt_errFn
- */
- saveDiffPreferences(prefs, opt_errFn) {
- // Invalidate the cache.
- this._cache.delete('/accounts/self/preferences.diff');
- return this._restApiHelper.send({
- method: 'PUT',
- url: '/accounts/self/preferences.diff',
- body: prefs,
- errFn: opt_errFn,
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @param {?Object} prefs
- * @param {function(?Response, string=)=} opt_errFn
- */
- saveEditPreferences(prefs, opt_errFn) {
- // Invalidate the cache.
- this._cache.delete('/accounts/self/preferences.edit');
- return this._restApiHelper.send({
- method: 'PUT',
- url: '/accounts/self/preferences.edit',
- body: prefs,
- errFn: opt_errFn,
- reportUrlAsIs: true,
- });
- }
-
- getAccount() {
- return this._fetchSharedCacheURL({
- url: '/accounts/self/detail',
- reportUrlAsIs: true,
- errFn: resp => {
- if (!resp || resp.status === 403) {
- this._cache.delete('/accounts/self/detail');
- }
- },
- });
- }
-
- getAvatarChangeUrl() {
- return this._fetchSharedCacheURL({
- url: '/accounts/self/avatar.change.url',
- reportUrlAsIs: true,
- errFn: resp => {
- if (!resp || resp.status === 403) {
- this._cache.delete('/accounts/self/avatar.change.url');
- }
- },
- });
- }
-
- getExternalIds() {
- return this._restApiHelper.fetchJSON({
- url: '/accounts/self/external.ids',
- reportUrlAsIs: true,
- });
- }
-
- deleteAccountIdentity(id) {
- return this._restApiHelper.send({
- method: 'POST',
- url: '/accounts/self/external.ids:delete',
- body: id,
- parseResponse: true,
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @param {string} userId the ID of the user usch as an email address.
- * @return {!Promise<!Object>}
- */
- getAccountDetails(userId) {
- return this._restApiHelper.fetchJSON({
- url: `/accounts/${encodeURIComponent(userId)}/detail`,
- anonymizedUrl: '/accounts/*/detail',
- });
- }
-
- getAccountEmails() {
- return this._fetchSharedCacheURL({
- url: '/accounts/self/emails',
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @param {string} email
- * @param {function(?Response, string=)=} opt_errFn
- */
- addAccountEmail(email, opt_errFn) {
- return this._restApiHelper.send({
- method: 'PUT',
- url: '/accounts/self/emails/' + encodeURIComponent(email),
- errFn: opt_errFn,
- anonymizedUrl: '/account/self/emails/*',
- });
- }
-
- /**
- * @param {string} email
- * @param {function(?Response, string=)=} opt_errFn
- */
- deleteAccountEmail(email, opt_errFn) {
- return this._restApiHelper.send({
- method: 'DELETE',
- url: '/accounts/self/emails/' + encodeURIComponent(email),
- errFn: opt_errFn,
- anonymizedUrl: '/accounts/self/email/*',
- });
- }
-
- /**
- * @param {string} email
- * @param {function(?Response, string=)=} opt_errFn
- */
- setPreferredAccountEmail(email, opt_errFn) {
- const encodedEmail = encodeURIComponent(email);
- const req = {
- method: 'PUT',
- url: `/accounts/self/emails/${encodedEmail}/preferred`,
- errFn: opt_errFn,
- anonymizedUrl: '/accounts/self/emails/*/preferred',
- };
- return this._restApiHelper.send(req).then(() => {
- // If result of getAccountEmails is in cache, update it in the cache
- // so we don't have to invalidate it.
- const cachedEmails = this._cache.get('/accounts/self/emails');
- if (cachedEmails) {
- const emails = cachedEmails.map(entry => {
- if (entry.email === email) {
- return {email, preferred: true};
- } else {
- return {email};
- }
- });
- this._cache.set('/accounts/self/emails', emails);
- }
- });
- }
-
- /**
- * @param {?Object} obj
- */
- _updateCachedAccount(obj) {
- // If result of getAccount is in cache, update it in the cache
- // so we don't have to invalidate it.
- const cachedAccount = this._cache.get('/accounts/self/detail');
- if (cachedAccount) {
- // Replace object in cache with new object to force UI updates.
- this._cache.set('/accounts/self/detail',
- Object.assign({}, cachedAccount, obj));
- }
- }
-
- /**
- * @param {string} name
- * @param {function(?Response, string=)=} opt_errFn
- */
- setAccountName(name, opt_errFn) {
- const req = {
- method: 'PUT',
- url: '/accounts/self/name',
- body: {name},
- errFn: opt_errFn,
- parseResponse: true,
- reportUrlAsIs: true,
- };
- return this._restApiHelper.send(req)
- .then(newName => this._updateCachedAccount({name: newName}));
- }
-
- /**
- * @param {string} username
- * @param {function(?Response, string=)=} opt_errFn
- */
- setAccountUsername(username, opt_errFn) {
- const req = {
- method: 'PUT',
- url: '/accounts/self/username',
- body: {username},
- errFn: opt_errFn,
- parseResponse: true,
- reportUrlAsIs: true,
- };
- return this._restApiHelper.send(req)
- .then(newName => this._updateCachedAccount({username: newName}));
- }
-
- /**
- * @param {string} displayName
- * @param {function(?Response, string=)=} opt_errFn
- */
- setAccountDisplayName(displayName, opt_errFn) {
- const req = {
- method: 'PUT',
- url: '/accounts/self/displayname',
- body: {display_name: displayName},
- errFn: opt_errFn,
- parseResponse: true,
- reportUrlAsIs: true,
- };
- return this._restApiHelper.send(req)
- .then(newName => this._updateCachedAccount({displayName: newName}));
- }
-
- /**
- * @param {string} status
- * @param {function(?Response, string=)=} opt_errFn
- */
- setAccountStatus(status, opt_errFn) {
- const req = {
- method: 'PUT',
- url: '/accounts/self/status',
- body: {status},
- errFn: opt_errFn,
- parseResponse: true,
- reportUrlAsIs: true,
- };
- return this._restApiHelper.send(req)
- .then(newStatus => this._updateCachedAccount({status: newStatus}));
- }
-
- getAccountStatus(userId) {
- return this._restApiHelper.fetchJSON({
- url: `/accounts/${encodeURIComponent(userId)}/status`,
- anonymizedUrl: '/accounts/*/status',
- });
- }
-
- getAccountGroups() {
- return this._restApiHelper.fetchJSON({
- url: '/accounts/self/groups',
- reportUrlAsIs: true,
- });
- }
-
- getAccountAgreements() {
- return this._restApiHelper.fetchJSON({
- url: '/accounts/self/agreements',
- reportUrlAsIs: true,
- });
- }
-
- saveAccountAgreement(name) {
- return this._restApiHelper.send({
- method: 'PUT',
- url: '/accounts/self/agreements',
- body: name,
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @param {string=} opt_params
- */
- getAccountCapabilities(opt_params) {
- let queryString = '';
- if (opt_params) {
- queryString = '?q=' + opt_params
- .map(param => encodeURIComponent(param))
- .join('&q=');
- }
- return this._fetchSharedCacheURL({
- url: '/accounts/self/capabilities' + queryString,
- anonymizedUrl: '/accounts/self/capabilities?q=*',
- });
- }
-
- getLoggedIn() {
- return this.authService.authCheck();
- }
-
- getIsAdmin() {
- return this.getLoggedIn()
- .then(isLoggedIn => {
- if (isLoggedIn) {
- return this.getAccountCapabilities();
- } else {
- return Promise.resolve();
- }
- })
- .then(
- capabilities => capabilities && capabilities.administrateServer
- );
- }
-
- getDefaultPreferences() {
- return this._fetchSharedCacheURL({
- url: '/config/server/preferences',
- reportUrlAsIs: true,
- });
- }
-
- getPreferences() {
- return this.getLoggedIn().then(loggedIn => {
- if (loggedIn) {
- const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
- return this._fetchSharedCacheURL(req).then(res => {
- if (this._isNarrowScreen()) {
- // Note that this can be problematic, because the diff will stay
- // unified even after increasing the window width.
- res.default_diff_view = DiffViewMode.UNIFIED;
- } else {
- res.default_diff_view = res.diff_view;
- }
- return Promise.resolve(res);
- });
- }
-
- return Promise.resolve({
- changes_per_page: 25,
- default_diff_view: this._isNarrowScreen() ?
- DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE,
- diff_view: 'SIDE_BY_SIDE',
- size_bar_in_change_table: true,
- });
- });
- }
-
- getWatchedProjects() {
- return this._fetchSharedCacheURL({
- url: '/accounts/self/watched.projects',
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @param {string} projects
- * @param {function(?Response, string=)=} opt_errFn
- */
- saveWatchedProjects(projects, opt_errFn) {
- return this._restApiHelper.send({
- method: 'POST',
- url: '/accounts/self/watched.projects',
- body: projects,
- errFn: opt_errFn,
- parseResponse: true,
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @param {string} projects
- * @param {function(?Response, string=)=} opt_errFn
- */
- deleteWatchedProjects(projects, opt_errFn) {
- return this._restApiHelper.send({
- method: 'POST',
- url: '/accounts/self/watched.projects:delete',
- body: projects,
- errFn: opt_errFn,
- reportUrlAsIs: true,
- });
- }
-
- _isNarrowScreen() {
- return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
- }
-
- /**
- * @param {number=} opt_changesPerPage
- * @param {string|!Array<string>=} opt_query A query or an array of queries.
- * @param {number|string=} opt_offset
- * @param {!Object=} opt_options
- * @return {?Array<!Object>|?Array<!Array<!Object>>} If opt_query is an
- * array, _fetchJSON will return an array of arrays of changeInfos. If it
- * is unspecified or a string, _fetchJSON will return an array of
- * changeInfos.
- */
- getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) {
- return this.getConfig(false)
- .then(config => {
- const options = opt_options || this._getChangesOptionsHex(config);
- // Issue 4524: respect legacy token with max sortkey.
- if (opt_offset === 'n,z') {
- opt_offset = 0;
- }
- const params = {
- O: options,
- S: opt_offset || 0,
- };
- if (opt_changesPerPage) { params.n = opt_changesPerPage; }
- if (opt_query && opt_query.length > 0) {
- params.q = opt_query;
- }
- return {
- url: '/changes/',
- params,
- reportUrlAsIs: true,
- };
- })
- .then(req => this._restApiHelper.fetchJSON(req))
- .then(response => {
- const iterateOverChanges = arr => {
- for (const change of (arr || [])) {
- this._maybeInsertInLookup(change);
- }
- };
- // Response may be an array of changes OR an array of arrays of
- // changes.
- if (opt_query instanceof Array) {
- // Normalize the response to look like a multi-query response
- // when there is only one query.
- if (opt_query.length === 1) {
- response = [response];
- }
- for (const arr of response) {
- iterateOverChanges(arr);
- }
- } else {
- iterateOverChanges(response);
- }
- return response;
- });
- }
-
- /**
- * Inserts a change into _projectLookup iff it has a valid structure.
- *
- * @param {?{ _number: (number|string) }} change
- */
- _maybeInsertInLookup(change) {
- if (change && change.project && change._number) {
- this.setInProjectLookup(change._number, change.project);
- }
- }
-
- /**
- * TODO (beckysiegel) this needs to be rewritten with the optional param
- * at the end.
- *
- * @param {number|string} changeNum
- * @param {?number|string=} opt_patchNum passed as null sometimes.
- * @param {?=} endpoint
- * @return {!Promise<string>}
- */
- getChangeActionURL(changeNum, opt_patchNum, endpoint) {
- return this._changeBaseURL(changeNum, opt_patchNum)
- .then(url => url + endpoint);
- }
-
- /**
- * @param {number|string} changeNum
- * @param {function(?Response, string=)=} opt_errFn
- * @param {function()=} opt_cancelCondition
- */
- getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
- return this.getConfig(false).then(config => {
- const optionsHex = this._getChangeOptionsHex(config);
- return this._getChangeDetail(
- changeNum, optionsHex, opt_errFn, opt_cancelCondition)
- .then(GrReviewerUpdatesParser.parse);
- });
- }
-
- _getChangesOptionsHex(config) {
- const options = [
- ListChangesOption.LABELS,
- ListChangesOption.DETAILED_ACCOUNTS,
- ];
- if (config && config.change && config.change.enable_attention_set) {
- options.push(ListChangesOption.DETAILED_LABELS);
- } else {
- options.push(ListChangesOption.REVIEWED);
- }
-
- return listChangesOptionsToHex(...options);
- }
-
- _getChangeOptionsHex(config) {
- if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.changePage
- && !(config.receive && config.receive.enable_signed_push)) {
- return window.DEFAULT_DETAIL_HEXES.changePage;
- }
-
- // This list MUST be kept in sync with
- // ChangeIT#changeDetailsDoesNotRequireIndex
- const options = [
- ListChangesOption.ALL_COMMITS,
- ListChangesOption.ALL_REVISIONS,
- ListChangesOption.CHANGE_ACTIONS,
- ListChangesOption.DETAILED_LABELS,
- ListChangesOption.DOWNLOAD_COMMANDS,
- ListChangesOption.MESSAGES,
- ListChangesOption.SUBMITTABLE,
- ListChangesOption.WEB_LINKS,
- ListChangesOption.SKIP_DIFFSTAT,
- ];
- if (config.receive && config.receive.enable_signed_push) {
- options.push(ListChangesOption.PUSH_CERTIFICATES);
- }
- return listChangesOptionsToHex(...options);
- }
-
- /**
- * @param {number|string} changeNum
- * @param {function(?Response, string=)=} opt_errFn
- * @param {function()=} opt_cancelCondition
- */
- getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
- let optionsHex = '';
- if (window.DEFAULT_DETAIL_HEXES && window.DEFAULT_DETAIL_HEXES.diffPage) {
- optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
- } else {
- optionsHex = listChangesOptionsToHex(
- ListChangesOption.ALL_COMMITS,
- ListChangesOption.ALL_REVISIONS,
- ListChangesOption.SKIP_DIFFSTAT
- );
- }
- return this._getChangeDetail(changeNum, optionsHex, opt_errFn,
- opt_cancelCondition);
- }
-
- /**
- * @param {number|string} changeNum
- * @param {string|undefined} optionsHex list changes options in hex
- * @param {function(?Response, string=)=} opt_errFn
- * @param {function()=} opt_cancelCondition
- */
- _getChangeDetail(changeNum, optionsHex, opt_errFn, opt_cancelCondition) {
- return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
- const urlWithParams = this._restApiHelper
- .urlWithParams(url, optionsHex);
- const params = {O: optionsHex};
- const req = {
- url,
- errFn: opt_errFn,
- cancelCondition: opt_cancelCondition,
- params,
- fetchOptions: this._etags.getOptions(urlWithParams),
- anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
- };
- return this._restApiHelper.fetchRawJSON(req).then(response => {
- if (response && response.status === 304) {
- return Promise.resolve(this._restApiHelper.parsePrefixedJSON(
- this._etags.getCachedPayload(urlWithParams)));
- }
-
- if (response && !response.ok) {
- if (opt_errFn) {
- opt_errFn.call(null, response);
- } else {
- this.dispatchEvent(new CustomEvent('server-error', {
- detail: {request: req, response},
- composed: true, bubbles: true,
- }));
- }
- return;
- }
-
- const payloadPromise = response ?
- this._restApiHelper.readResponsePayload(response) :
- Promise.resolve(null);
-
- return payloadPromise.then(payload => {
- if (!payload) { return null; }
- this._etags.collect(urlWithParams, response, payload.raw);
- this._maybeInsertInLookup(payload.parsed);
-
- return payload.parsed;
- });
- });
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {number|string} patchNum
- */
- getChangeCommitInfo(changeNum, patchNum) {
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: '/commit?links',
- patchNum,
- reportEndpointAsIs: true,
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {Gerrit.PatchRange} patchRange
- * @param {number=} opt_parentIndex
- */
- getChangeFiles(changeNum, patchRange, opt_parentIndex) {
- let params = undefined;
- if (isMergeParent(patchRange.basePatchNum)) {
- params = {parent: getParentIndex(patchRange.basePatchNum)};
- } else if (!patchNumEquals(patchRange.basePatchNum,
- SPECIAL_PATCH_SET_NUM.PARENT)) {
- params = {base: patchRange.basePatchNum};
- }
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: '/files',
- patchNum: patchRange.patchNum,
- params,
- reportEndpointAsIs: true,
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {Gerrit.PatchRange} patchRange
- */
- getChangeEditFiles(changeNum, patchRange) {
- let endpoint = '/edit?list';
- let anonymizedEndpoint = endpoint;
- if (patchRange.basePatchNum !== 'PARENT') {
- endpoint += '&base=' + encodeURIComponent(patchRange.basePatchNum + '');
- anonymizedEndpoint += '&base=*';
- }
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint,
- anonymizedEndpoint,
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {number|string} patchNum
- * @param {string} query
- * @return {!Promise<!Object>}
- */
- queryChangeFiles(changeNum, patchNum, query) {
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: `/files?q=${encodeURIComponent(query)}`,
- patchNum,
- anonymizedEndpoint: '/files?q=*',
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {Gerrit.PatchRange} patchRange
- * @return {!Promise<!Array<!Object>>}
- */
- getChangeOrEditFiles(changeNum, patchRange) {
- if (patchNumEquals(patchRange.patchNum, SPECIAL_PATCH_SET_NUM.EDIT)) {
- return this.getChangeEditFiles(changeNum, patchRange).then(res =>
- res.files);
- }
- return this.getChangeFiles(changeNum, patchRange);
- }
-
- getChangeRevisionActions(changeNum, patchNum) {
- const req = {
- changeNum,
- endpoint: '/actions',
- patchNum,
- reportEndpointAsIs: true,
- };
- return this._getChangeURLAndFetch(req);
- }
-
- /**
- * @param {number|string} changeNum
- * @param {string} inputVal
- * @param {function(?Response, string=)=} opt_errFn
- */
- getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
- return this._getChangeSuggestedGroup('REVIEWER', changeNum, inputVal,
- opt_errFn);
- }
-
- /**
- * @param {number|string} changeNum
- * @param {string} inputVal
- * @param {function(?Response, string=)=} opt_errFn
- */
- getChangeSuggestedCCs(changeNum, inputVal, opt_errFn) {
- return this._getChangeSuggestedGroup('CC', changeNum, inputVal,
- opt_errFn);
- }
-
- _getChangeSuggestedGroup(reviewerState, changeNum, inputVal, opt_errFn) {
- // More suggestions may obscure content underneath in the reply dialog,
- // see issue 10793.
- const params = {'n': 6, 'reviewer-state': reviewerState};
- if (inputVal) { params.q = inputVal; }
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: '/suggest_reviewers',
- errFn: opt_errFn,
- params,
- reportEndpointAsIs: true,
- });
- }
-
- /**
- * @param {number|string} changeNum
- */
- getChangeIncludedIn(changeNum) {
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: '/in',
- reportEndpointAsIs: true,
- });
- }
-
- _computeFilter(filter) {
- if (filter && filter.startsWith('^')) {
- filter = '&r=' + encodeURIComponent(filter);
- } else if (filter) {
- filter = '&m=' + encodeURIComponent(filter);
- } else {
- filter = '';
- }
- return filter;
- }
-
- /**
- * @param {string} filter
- * @param {number} groupsPerPage
- * @param {number=} opt_offset
- */
- _getGroupsUrl(filter, groupsPerPage, opt_offset) {
- const offset = opt_offset || 0;
-
- return `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
- this._computeFilter(filter);
- }
-
- /**
- * @param {string} filter
- * @param {number} reposPerPage
- * @param {number=} opt_offset
- */
- _getReposUrl(filter, reposPerPage, opt_offset) {
- const defaultFilter = 'state:active OR state:read-only';
- const namePartDelimiters = /[@.\-\s\/_]/g;
- const offset = opt_offset || 0;
-
- if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
- // The query language specifies hyphens as operators. Split the string
- // by hyphens and 'AND' the parts together as 'inname:' queries.
- // If the filter includes a semicolon, the user is using a more complex
- // query so we trust them and don't do any magic under the hood.
- const originalFilter = filter;
- filter = '';
- originalFilter.split(namePartDelimiters).forEach(part => {
- if (part) {
- filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
- }
- });
- }
- // Check if filter is now empty which could be either because the user did
- // not provide it or because the user provided only a split character.
- if (!filter) {
- filter = defaultFilter;
- }
-
- filter = filter.trim();
- const encodedFilter = encodeURIComponent(filter);
-
- return `/projects/?n=${reposPerPage + 1}&S=${offset}` +
- `&query=${encodedFilter}`;
- }
-
- invalidateGroupsCache() {
- this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
- }
-
- invalidateReposCache() {
- this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
- }
-
- invalidateAccountsCache() {
- this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
- }
-
- /**
- * @param {string} filter
- * @param {number} groupsPerPage
- * @param {number=} opt_offset
- * @return {!Promise<?Object>}
- */
- getGroups(filter, groupsPerPage, opt_offset) {
- const url = this._getGroupsUrl(filter, groupsPerPage, opt_offset);
-
- return this._fetchSharedCacheURL({
- url,
- anonymizedUrl: '/groups/?*',
- });
- }
-
- /**
- * @param {string} filter
- * @param {number} reposPerPage
- * @param {number=} opt_offset
- * @return {!Promise<?Object>}
- */
- getRepos(filter, reposPerPage, opt_offset) {
- const url = this._getReposUrl(filter, reposPerPage, opt_offset);
-
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._fetchSharedCacheURL({
- url,
- anonymizedUrl: '/projects/?*',
- });
- }
-
- setRepoHead(repo, ref) {
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/projects/${encodeURIComponent(repo)}/HEAD`,
- body: {ref},
- anonymizedUrl: '/projects/*/HEAD',
- });
- }
-
- /**
- * @param {string} filter
- * @param {string} repo
- * @param {number} reposBranchesPerPage
- * @param {number=} opt_offset
- * @param {?function(?Response, string=)=} opt_errFn
- * @return {!Promise<?Object>}
- */
- getRepoBranches(filter, repo, reposBranchesPerPage, opt_offset, opt_errFn) {
- const offset = opt_offset || 0;
- const count = reposBranchesPerPage + 1;
- filter = this._computeFilter(filter);
- repo = encodeURIComponent(repo);
- const url = `/projects/${repo}/branches?n=${count}&S=${offset}${filter}`;
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._restApiHelper.fetchJSON({
- url,
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/branches?*',
- });
- }
-
- /**
- * @param {string} filter
- * @param {string} repo
- * @param {number} reposTagsPerPage
- * @param {number=} opt_offset
- * @param {?function(?Response, string=)=} opt_errFn
- * @return {!Promise<?Object>}
- */
- getRepoTags(filter, repo, reposTagsPerPage, opt_offset, opt_errFn) {
- const offset = opt_offset || 0;
- const encodedRepo = encodeURIComponent(repo);
- const n = reposTagsPerPage + 1;
- const encodedFilter = this._computeFilter(filter);
- const url = `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` +
- encodedFilter;
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._restApiHelper.fetchJSON({
- url,
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/tags',
- });
- }
-
- /**
- * @param {string} filter
- * @param {number} pluginsPerPage
- * @param {number=} opt_offset
- * @param {?function(?Response, string=)=} opt_errFn
- * @return {!Promise<?Object>}
- */
- getPlugins(filter, pluginsPerPage, opt_offset, opt_errFn) {
- const offset = opt_offset || 0;
- const encodedFilter = this._computeFilter(filter);
- const n = pluginsPerPage + 1;
- const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
- return this._restApiHelper.fetchJSON({
- url,
- errFn: opt_errFn,
- anonymizedUrl: '/plugins/?all',
- });
- }
-
- getRepoAccessRights(repoName, opt_errFn) {
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._restApiHelper.fetchJSON({
- url: `/projects/${encodeURIComponent(repoName)}/access`,
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/access',
- });
- }
-
- setRepoAccessRights(repoName, repoInfo) {
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._restApiHelper.send({
- method: 'POST',
- url: `/projects/${encodeURIComponent(repoName)}/access`,
- body: repoInfo,
- anonymizedUrl: '/projects/*/access',
- });
- }
-
- setRepoAccessRightsForReview(projectName, projectInfo) {
- return this._restApiHelper.send({
- method: 'PUT',
- url: `/projects/${encodeURIComponent(projectName)}/access:review`,
- body: projectInfo,
- parseResponse: true,
- anonymizedUrl: '/projects/*/access:review',
- });
- }
-
- /**
- * @param {string} inputVal
- * @param {number} opt_n
- * @param {function(?Response, string=)=} opt_errFn
- */
- getSuggestedGroups(inputVal, opt_n, opt_errFn) {
- const params = {s: inputVal};
- if (opt_n) { params.n = opt_n; }
- return this._restApiHelper.fetchJSON({
- url: '/groups/',
- errFn: opt_errFn,
- params,
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @param {string} inputVal
- * @param {number} opt_n
- * @param {function(?Response, string=)=} opt_errFn
- */
- getSuggestedProjects(inputVal, opt_n, opt_errFn) {
- const params = {
- m: inputVal,
- n: MAX_PROJECT_RESULTS,
- type: 'ALL',
- };
- if (opt_n) { params.n = opt_n; }
- return this._restApiHelper.fetchJSON({
- url: '/projects/',
- errFn: opt_errFn,
- params,
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @param {string} inputVal
- * @param {number} opt_n
- * @param {function(?Response, string=)=} opt_errFn
- */
- getSuggestedAccounts(inputVal, opt_n, opt_errFn) {
- if (!inputVal) {
- return Promise.resolve([]);
- }
- const params = {suggest: null, q: inputVal};
- if (opt_n) { params.n = opt_n; }
- return this._restApiHelper.fetchJSON({
- url: '/accounts/',
- errFn: opt_errFn,
- params,
- anonymizedUrl: '/accounts/?n=*',
- });
- }
-
- addChangeReviewer(changeNum, reviewerID) {
- return this._sendChangeReviewerRequest('POST', changeNum, reviewerID);
- }
-
- removeChangeReviewer(changeNum, reviewerID) {
- return this._sendChangeReviewerRequest('DELETE', changeNum, reviewerID);
- }
-
- _sendChangeReviewerRequest(method, changeNum, reviewerID) {
- return this.getChangeActionURL(changeNum, null, '/reviewers')
- .then(url => {
- let body;
- switch (method) {
- case 'POST':
- body = {reviewer: reviewerID};
- break;
- case 'DELETE':
- url += '/' + encodeURIComponent(reviewerID);
- break;
- default:
- throw Error('Unsupported HTTP method: ' + method);
- }
-
- return this._restApiHelper.send({method, url, body});
- });
- }
-
- getRelatedChanges(changeNum, patchNum) {
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: '/related',
- patchNum,
- reportEndpointAsIs: true,
- });
- }
-
- getChangesSubmittedTogether(changeNum) {
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
- reportEndpointAsIs: true,
- });
- }
-
- getChangeConflicts(changeNum) {
- const options = listChangesOptionsToHex(
- ListChangesOption.CURRENT_REVISION,
- ListChangesOption.CURRENT_COMMIT
- );
- const params = {
- O: options,
- q: 'status:open conflicts:' + changeNum,
- };
- return this._restApiHelper.fetchJSON({
- url: '/changes/',
- params,
- anonymizedUrl: '/changes/conflicts:*',
- });
- }
-
- getChangeCherryPicks(project, changeID, changeNum) {
- const options = listChangesOptionsToHex(
- ListChangesOption.CURRENT_REVISION,
- ListChangesOption.CURRENT_COMMIT
- );
- const query = [
- 'project:' + project,
- 'change:' + changeID,
- '-change:' + changeNum,
- '-is:abandoned',
- ].join(' ');
- const params = {
- O: options,
- q: query,
- };
- return this._restApiHelper.fetchJSON({
- url: '/changes/',
- params,
- anonymizedUrl: '/changes/change:*',
- });
- }
-
- getChangesWithSameTopic(topic, changeNum) {
- const options = listChangesOptionsToHex(
- ListChangesOption.LABELS,
- ListChangesOption.CURRENT_REVISION,
- ListChangesOption.CURRENT_COMMIT,
- ListChangesOption.DETAILED_LABELS
- );
- const query = [
- 'status:open',
- '-change:' + changeNum,
- `topic:"${topic}"`,
- ].join(' ');
- const params = {
- O: options,
- q: query,
- };
- return this._restApiHelper.fetchJSON({
- url: '/changes/',
- params,
- anonymizedUrl: '/changes/topic:*',
- });
- }
-
- getReviewedFiles(changeNum, patchNum) {
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: '/files?reviewed',
- patchNum,
- reportEndpointAsIs: true,
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {number|string} patchNum
- * @param {string} path
- * @param {boolean} reviewed
- * @param {function(?Response, string=)=} opt_errFn
- */
- saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn) {
- return this._getChangeURLAndSend({
- changeNum,
- method: reviewed ? 'PUT' : 'DELETE',
- patchNum,
- endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
- errFn: opt_errFn,
- anonymizedEndpoint: '/files/*/reviewed',
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {number|string} patchNum
- * @param {!Object} review
- * @param {function(?Response, string=)=} opt_errFn
- */
- saveChangeReview(changeNum, patchNum, review, opt_errFn) {
- const promises = [
- this.awaitPendingDiffDrafts(),
- this.getChangeActionURL(changeNum, patchNum, '/review'),
- ];
- return Promise.all(promises).then(([, url]) => this._restApiHelper.send({
- method: 'POST',
- url,
- body: review,
- errFn: opt_errFn,
- }));
- }
-
- getChangeEdit(changeNum, opt_download_commands) {
- const params = opt_download_commands ? {'download-commands': true} : null;
- return this.getLoggedIn().then(loggedIn => {
- if (!loggedIn) { return false; }
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: '/edit/',
- params,
- reportEndpointAsIs: true,
- }, true);
- });
- }
-
- /**
- * @param {string} project
- * @param {string} branch
- * @param {string} subject
- * @param {string=} opt_topic
- * @param {boolean=} opt_isPrivate
- * @param {boolean=} opt_workInProgress
- * @param {string=} opt_baseChange
- * @param {string=} opt_baseCommit
- */
- createChange(project, branch, subject, opt_topic, opt_isPrivate,
- opt_workInProgress, opt_baseChange, opt_baseCommit) {
- return this._restApiHelper.send({
- method: 'POST',
- url: '/changes/',
- body: {
- project,
- branch,
- subject,
- topic: opt_topic,
- is_private: opt_isPrivate,
- work_in_progress: opt_workInProgress,
- base_change: opt_baseChange,
- base_commit: opt_baseCommit,
- },
- parseResponse: true,
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {string} path
- * @param {number|string} patchNum
- */
- getFileContent(changeNum, path, patchNum) {
- // 404s indicate the file does not exist yet in the revision, so suppress
- // them.
- const suppress404s = res => {
- if (res && res.status !== 404) {
- this.dispatchEvent(new CustomEvent('server-error', {
- detail: {res},
- composed: true, bubbles: true,
- }));
- }
- return res;
- };
- const promise = patchNumEquals(patchNum, SPECIAL_PATCH_SET_NUM.EDIT) ?
- this._getFileInChangeEdit(changeNum, path) :
- this._getFileInRevision(changeNum, path, patchNum, suppress404s);
-
- return promise.then(res => {
- if (!res.ok) { return res; }
-
- // The file type (used for syntax highlighting) is identified in the
- // X-FYI-Content-Type header of the response.
- const type = res.headers.get('X-FYI-Content-Type');
- return this.getResponseObject(res).then(content => {
- return {content, type, ok: true};
- });
- });
- }
-
- /**
- * Gets a file in a specific change and revision.
- *
- * @param {number|string} changeNum
- * @param {string} path
- * @param {number|string} patchNum
- * @param {?function(?Response, string=)=} opt_errFn
- */
- _getFileInRevision(changeNum, path, patchNum, opt_errFn) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'GET',
- patchNum,
- endpoint: `/files/${encodeURIComponent(path)}/content`,
- errFn: opt_errFn,
- headers: {Accept: 'application/json'},
- anonymizedEndpoint: '/files/*/content',
- });
- }
-
- /**
- * Gets a file in a change edit.
- *
- * @param {number|string} changeNum
- * @param {string} path
- */
- _getFileInChangeEdit(changeNum, path) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'GET',
- endpoint: '/edit/' + encodeURIComponent(path),
- headers: {Accept: 'application/json'},
- anonymizedEndpoint: '/edit/*',
- });
- }
-
- rebaseChangeEdit(changeNum) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'POST',
- endpoint: '/edit:rebase',
- reportEndpointAsIs: true,
- });
- }
-
- deleteChangeEdit(changeNum) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'DELETE',
- endpoint: '/edit',
- reportEndpointAsIs: true,
- });
- }
-
- restoreFileInChangeEdit(changeNum, restore_path) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'POST',
- endpoint: '/edit',
- body: {restore_path},
- reportEndpointAsIs: true,
- });
- }
-
- renameFileInChangeEdit(changeNum, old_path, new_path) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'POST',
- endpoint: '/edit',
- body: {old_path, new_path},
- reportEndpointAsIs: true,
- });
- }
-
- deleteFileInChangeEdit(changeNum, path) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'DELETE',
- endpoint: '/edit/' + encodeURIComponent(path),
- anonymizedEndpoint: '/edit/*',
- });
- }
-
- saveChangeEdit(changeNum, path, contents) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'PUT',
- endpoint: '/edit/' + encodeURIComponent(path),
- body: contents,
- contentType: 'text/plain',
- anonymizedEndpoint: '/edit/*',
- });
- }
-
- saveFileUploadChangeEdit(changeNum, path, content) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'PUT',
- endpoint: '/edit/' + encodeURIComponent(path),
- body: {binary_content: content},
- anonymizedEndpoint: '/edit/*',
- });
- }
-
- getRobotCommentFixPreview(changeNum, patchNum, fixId) {
- return this._getChangeURLAndFetch({
- changeNum,
- patchNum,
- endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
- reportEndpointAsId: true,
- });
- }
-
- applyFixSuggestion(changeNum, patchNum, fixId) {
- return this._getChangeURLAndSend({
- method: 'POST',
- changeNum,
- patchNum,
- endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
- reportEndpointAsId: true,
- });
- }
-
- // Deprecated, prefer to use putChangeCommitMessage instead.
- saveChangeCommitMessageEdit(changeNum, message) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'PUT',
- endpoint: '/edit:message',
- body: {message},
- reportEndpointAsIs: true,
- });
- }
-
- publishChangeEdit(changeNum) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'POST',
- endpoint: '/edit:publish',
- reportEndpointAsIs: true,
- });
- }
-
- putChangeCommitMessage(changeNum, message) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'PUT',
- endpoint: '/message',
- body: {message},
- reportEndpointAsIs: true,
- });
- }
-
- deleteChangeCommitMessage(changeNum, messageId) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'DELETE',
- endpoint: '/messages/' + messageId,
- reportEndpointAsIs: true,
- });
- }
-
- saveChangeStarred(changeNum, starred) {
- // Some servers may require the project name to be provided
- // alongside the change number, so resolve the project name
- // first.
- return this.getFromProjectLookup(changeNum).then(project => {
- const url = '/accounts/self/starred.changes/' +
- (project ? encodeURIComponent(project) + '~' : '') + changeNum;
- return this._restApiHelper.send({
- method: starred ? 'PUT' : 'DELETE',
- url,
- anonymizedUrl: '/accounts/self/starred.changes/*',
- });
- });
- }
-
- saveChangeReviewed(changeNum, reviewed) {
- return this.getConfig().then(config => {
- const isAttentionSetEnabled = !!config && !!config.change
- && config.change.enable_attention_set;
- if (isAttentionSetEnabled) return Promise.resolve();
- return this._getChangeURLAndSend({
- changeNum,
- method: 'PUT',
- endpoint: reviewed ? '/reviewed' : '/unreviewed',
- });
- });
- }
-
- /**
- * Public version of the _restApiHelper.send method preserved for plugins.
- *
- * @param {string} method
- * @param {string} url
- * @param {?string|number|Object=} opt_body passed as null sometimes
- * and also apparently a number. TODO (beckysiegel) remove need for
- * number at least.
- * @param {?function(?Response, string=)=} opt_errFn
- * passed as null sometimes.
- * @param {?string=} opt_contentType
- * @param {Object=} opt_headers
- */
- send(method, url, opt_body, opt_errFn, opt_contentType,
- opt_headers) {
- return this._restApiHelper.send({
- method,
- url,
- body: opt_body,
- errFn: opt_errFn,
- contentType: opt_contentType,
- headers: opt_headers,
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {number|string} basePatchNum Negative values specify merge parent
- * index.
- * @param {number|string} patchNum
- * @param {string} path
- * @param {string=} opt_whitespace the ignore-whitespace level for the diff
- * algorithm.
- * @param {function(?Response, string=)=} opt_errFn
- */
- getDiff(changeNum, basePatchNum, patchNum, path, opt_whitespace,
- opt_errFn) {
- const params = {
- context: 'ALL',
- intraline: null,
- whitespace: opt_whitespace || 'IGNORE_NONE',
- };
- if (isMergeParent(basePatchNum)) {
- params.parent = getParentIndex(basePatchNum);
- } else if (!patchNumEquals(basePatchNum, SPECIAL_PATCH_SET_NUM.PARENT)) {
- params.base = basePatchNum;
- }
- const endpoint = `/files/${encodeURIComponent(path)}/diff`;
- const req = {
- changeNum,
- endpoint,
- patchNum,
- errFn: opt_errFn,
- params,
- anonymizedEndpoint: '/files/*/diff',
- };
-
- // Invalidate the cache if its edit patch to make sure we always get latest.
- if (patchNum === SPECIAL_PATCH_SET_NUM.EDIT) {
- if (!req.fetchOptions) req.fetchOptions = {};
- if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
- req.fetchOptions.headers.append('Cache-Control', 'no-cache');
- }
-
- return this._getChangeURLAndFetch(req);
- }
-
- /**
- * @param {number|string} changeNum
- * @param {number|string=} opt_basePatchNum
- * @param {number|string=} opt_patchNum
- * @param {string=} opt_path
- * @return {!Promise<!Object>}
- */
- getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
- return this._getDiffComments(changeNum, '/comments', opt_basePatchNum,
- opt_patchNum, opt_path);
- }
-
- /**
- * @param {number|string} changeNum
- * @param {number|string=} opt_basePatchNum
- * @param {number|string=} opt_patchNum
- * @param {string=} opt_path
- * @return {!Promise<!Object>}
- */
- getDiffRobotComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
- return this._getDiffComments(changeNum, '/robotcomments',
- opt_basePatchNum, opt_patchNum, opt_path);
- }
-
- /**
- * If the user is logged in, fetch the user's draft diff comments. If there
- * is no logged in user, the request is not made and the promise yields an
- * empty object.
- *
- * @param {number|string} changeNum
- * @param {number|string=} opt_basePatchNum
- * @param {number|string=} opt_patchNum
- * @param {string=} opt_path
- * @return {!Promise<!Object>}
- */
- getDiffDrafts(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
- return this.getLoggedIn().then(loggedIn => {
- if (!loggedIn) { return Promise.resolve({}); }
- return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
- opt_patchNum, opt_path);
- });
- }
-
- _setRange(comments, comment) {
- if (comment.in_reply_to && !comment.range) {
- for (let i = 0; i < comments.length; i++) {
- if (comments[i].id === comment.in_reply_to) {
- comment.range = comments[i].range;
- break;
- }
- }
- }
- return comment;
- }
-
- _setRanges(comments) {
- comments = comments || [];
- comments.sort(
- (a, b) => parseDate(a.updated) - parseDate(b.updated)
- );
- for (const comment of comments) {
- this._setRange(comments, comment);
- }
- return comments;
- }
-
- /**
- * @param {number|string} changeNum
- * @param {string} endpoint
- * @param {number|string=} opt_basePatchNum
- * @param {number|string=} opt_patchNum
- * @param {string=} opt_path
- * @return {!Promise<!Object>}
- */
- _getDiffComments(changeNum, endpoint, opt_basePatchNum,
- opt_patchNum, opt_path) {
- /**
- * Fetches the comments for a given patchNum.
- * Helper function to make promises more legible.
- *
- * @param {string|number=} opt_patchNum
- * @return {!Promise<!Object>} Diff comments response.
- */
- // We don't want to add accept header, since preloading of comments is
- // working only without accept header.
- const noAcceptHeader = true;
- const fetchComments = opt_patchNum => this._getChangeURLAndFetch({
- changeNum,
- endpoint,
- patchNum: opt_patchNum,
- reportEndpointAsIs: true,
- }, noAcceptHeader);
-
- if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
- return fetchComments();
- }
- function onlyParent(c) { return c.side == SPECIAL_PATCH_SET_NUM.PARENT; }
- function withoutParent(c) { return c.side != SPECIAL_PATCH_SET_NUM.PARENT; }
- function setPath(c) { c.path = opt_path; }
-
- const promises = [];
- let comments;
- let baseComments;
- let fetchPromise;
- fetchPromise = fetchComments(opt_patchNum).then(response => {
- comments = response[opt_path] || [];
- // TODO(kaspern): Implement this on in the backend so this can
- // be removed.
- // Sort comments by date so that parent ranges can be propagated
- // in a single pass.
- comments = this._setRanges(comments);
-
- if (opt_basePatchNum == SPECIAL_PATCH_SET_NUM.PARENT) {
- baseComments = comments.filter(onlyParent);
- baseComments.forEach(setPath);
- }
- comments = comments.filter(withoutParent);
-
- comments.forEach(setPath);
- });
- promises.push(fetchPromise);
-
- if (opt_basePatchNum != SPECIAL_PATCH_SET_NUM.PARENT) {
- fetchPromise = fetchComments(opt_basePatchNum).then(response => {
- baseComments = (response[opt_path] || [])
- .filter(withoutParent);
- baseComments = this._setRanges(baseComments);
- baseComments.forEach(setPath);
- });
- promises.push(fetchPromise);
- }
-
- return Promise.all(promises).then(() => Promise.resolve({
- baseComments,
- comments,
- }));
- }
-
- /**
- * @param {number|string} changeNum
- * @param {string} endpoint
- * @param {number|string=} opt_patchNum
- */
- _getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum) {
- return this._changeBaseURL(changeNum, opt_patchNum)
- .then(url => url + endpoint);
- }
-
- saveDiffDraft(changeNum, patchNum, draft) {
- return this._sendDiffDraftRequest('PUT', changeNum, patchNum, draft);
- }
-
- deleteDiffDraft(changeNum, patchNum, draft) {
- return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft);
- }
-
- /**
- * @returns {boolean} Whether there are pending diff draft sends.
- */
- hasPendingDiffDrafts() {
- const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
- return promises && promises.length;
- }
-
- /**
- * @returns {!Promise<undefined>} A promise that resolves when all pending
- * diff draft sends have resolved.
- */
- awaitPendingDiffDrafts() {
- return Promise.all(this._pendingRequests[Requests.SEND_DIFF_DRAFT] || [])
- .then(() => {
- this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
- });
- }
-
- _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
- const isCreate = !draft.id && method === 'PUT';
- let endpoint = '/drafts';
- let anonymizedEndpoint = endpoint;
- if (draft.id) {
- endpoint += '/' + draft.id;
- anonymizedEndpoint += '/*';
- }
- let body;
- if (method === 'PUT') {
- body = draft;
- }
-
- if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
- this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
- }
-
- const req = {
- changeNum,
- method,
- patchNum,
- endpoint,
- body,
- anonymizedEndpoint,
- };
-
- const promise = this._getChangeURLAndSend(req);
- this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
-
- if (isCreate) {
- return this._failForCreate200(promise);
- }
-
- return promise;
- }
-
- getCommitInfo(project, commit) {
- return this._restApiHelper.fetchJSON({
- url: '/projects/' + encodeURIComponent(project) +
- '/commits/' + encodeURIComponent(commit),
- anonymizedUrl: '/projects/*/comments/*',
- });
- }
-
- _fetchB64File(url) {
- return this._restApiHelper.fetch({url: getBaseUrl() + url})
- .then(response => {
- if (!response.ok) {
- return Promise.reject(new Error(response.statusText));
- }
- const type = response.headers.get('X-FYI-Content-Type');
- return response.text()
- .then(text => {
- return {body: text, type};
- });
- });
- }
-
- /**
- * @param {string} changeId
- * @param {string|number} patchNum
- * @param {string} path
- * @param {number=} opt_parentIndex
- */
- getB64FileContents(changeId, patchNum, path, opt_parentIndex) {
- const parent = typeof opt_parentIndex === 'number' ?
- '?parent=' + opt_parentIndex : '';
- return this._changeBaseURL(changeId, patchNum).then(url => {
- url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
- return this._fetchB64File(url);
- });
- }
-
- getImagesForDiff(changeNum, diff, patchRange) {
- let promiseA;
- let promiseB;
-
- if (diff.meta_a && diff.meta_a.content_type.startsWith('image/')) {
- if (patchRange.basePatchNum === 'PARENT') {
- // Note: we only attempt to get the image from the first parent.
- promiseA = this.getB64FileContents(changeNum, patchRange.patchNum,
- diff.meta_a.name, 1);
- } else {
- promiseA = this.getB64FileContents(changeNum,
- patchRange.basePatchNum, diff.meta_a.name);
- }
- } else {
- promiseA = Promise.resolve(null);
- }
-
- if (diff.meta_b && diff.meta_b.content_type.startsWith('image/')) {
- promiseB = this.getB64FileContents(changeNum, patchRange.patchNum,
- diff.meta_b.name);
- } else {
- promiseB = Promise.resolve(null);
- }
-
- return Promise.all([promiseA, promiseB]).then(results => {
- const baseImage = results[0];
- const revisionImage = results[1];
-
- // Sometimes the server doesn't send back the content type.
- if (baseImage) {
- baseImage._expectedType = diff.meta_a.content_type;
- baseImage._name = diff.meta_a.name;
- }
- if (revisionImage) {
- revisionImage._expectedType = diff.meta_b.content_type;
- revisionImage._name = diff.meta_b.name;
- }
-
- return {baseImage, revisionImage};
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {?number|string=} opt_patchNum passed as null sometimes.
- * @param {string=} opt_project
- * @return {!Promise<string>}
- */
- _changeBaseURL(changeNum, opt_patchNum, opt_project) {
- // TODO(kaspern): For full slicer migration, app should warn with a call
- // stack every time _changeBaseURL is called without a project.
- const projectPromise = opt_project ?
- Promise.resolve(opt_project) :
- this.getFromProjectLookup(changeNum);
- return projectPromise.then(project => {
- let url = `/changes/${encodeURIComponent(project)}~${changeNum}`;
- if (opt_patchNum) {
- url += `/revisions/${opt_patchNum}`;
- }
- return url;
- });
- }
-
- addToAttentionSet(changeNum, user, reason) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'POST',
- endpoint: '/attention',
- body: {user, reason},
- reportUrlAsIs: true,
- });
- }
-
- removeFromAttentionSet(changeNum, user, reason) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'DELETE',
- endpoint: `/attention/${user}`,
- anonymizedEndpoint: '/attention/*',
- body: {reason},
- });
- }
-
- /**
- * @suppress {checkTypes}
- * Resulted in error: Promise.prototype.then does not match formal
- * parameter.
- */
- setChangeTopic(changeNum, topic) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'PUT',
- endpoint: '/topic',
- body: {topic},
- parseResponse: true,
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @suppress {checkTypes}
- * Resulted in error: Promise.prototype.then does not match formal
- * parameter.
- */
- setChangeHashtag(changeNum, hashtag) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'POST',
- endpoint: '/hashtags',
- body: hashtag,
- parseResponse: true,
- reportUrlAsIs: true,
- });
- }
-
- deleteAccountHttpPassword() {
- return this._restApiHelper.send({
- method: 'DELETE',
- url: '/accounts/self/password.http',
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @suppress {checkTypes}
- * Resulted in error: Promise.prototype.then does not match formal
- * parameter.
- */
- generateAccountHttpPassword() {
- return this._restApiHelper.send({
- method: 'PUT',
- url: '/accounts/self/password.http',
- body: {generate: true},
- parseResponse: true,
- reportUrlAsIs: true,
- });
- }
-
- getAccountSSHKeys() {
- return this._fetchSharedCacheURL({
- url: '/accounts/self/sshkeys',
- reportUrlAsIs: true,
- });
- }
-
- addAccountSSHKey(key) {
- const req = {
- method: 'POST',
- url: '/accounts/self/sshkeys',
- body: key,
- contentType: 'text/plain',
- reportUrlAsIs: true,
- };
- return this._restApiHelper.send(req)
- .then(response => {
- if (response.status < 200 && response.status >= 300) {
- return Promise.reject(new Error('error'));
- }
- return this.getResponseObject(response);
- })
- .then(obj => {
- if (!obj.valid) { return Promise.reject(new Error('error')); }
- return obj;
- });
- }
-
- deleteAccountSSHKey(id) {
- return this._restApiHelper.send({
- method: 'DELETE',
- url: '/accounts/self/sshkeys/' + id,
- anonymizedUrl: '/accounts/self/sshkeys/*',
- });
- }
-
- getAccountGPGKeys() {
- return this._restApiHelper.fetchJSON({
- url: '/accounts/self/gpgkeys',
- reportUrlAsIs: true,
- });
- }
-
- addAccountGPGKey(key) {
- const req = {
- method: 'POST',
- url: '/accounts/self/gpgkeys',
- body: key,
- reportUrlAsIs: true,
- };
- return this._restApiHelper.send(req)
- .then(response => {
- if (response.status < 200 && response.status >= 300) {
- return Promise.reject(new Error('error'));
- }
- return this.getResponseObject(response);
- })
- .then(obj => {
- if (!obj) { return Promise.reject(new Error('error')); }
- return obj;
- });
- }
-
- deleteAccountGPGKey(id) {
- return this._restApiHelper.send({
- method: 'DELETE',
- url: '/accounts/self/gpgkeys/' + id,
- anonymizedUrl: '/accounts/self/gpgkeys/*',
- });
- }
-
- deleteVote(changeNum, account, label) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'DELETE',
- endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
- anonymizedEndpoint: '/reviewers/*/votes/*',
- });
- }
-
- setDescription(changeNum, patchNum, desc) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'PUT', patchNum,
- endpoint: '/description',
- body: {description: desc},
- reportUrlAsIs: true,
- });
- }
-
- confirmEmail(token) {
- const req = {
- method: 'PUT',
- url: '/config/server/email.confirm',
- body: {token},
- reportUrlAsIs: true,
- };
- return this._restApiHelper.send(req).then(response => {
- if (response.status === 204) {
- return 'Email confirmed successfully.';
- }
- return null;
- });
- }
-
- getCapabilities(opt_errFn) {
- return this._restApiHelper.fetchJSON({
- url: '/config/server/capabilities',
- errFn: opt_errFn,
- reportUrlAsIs: true,
- });
- }
-
- getTopMenus(opt_errFn) {
- return this._fetchSharedCacheURL({
- url: '/config/server/top-menus',
- errFn: opt_errFn,
- reportUrlAsIs: true,
- });
- }
-
- setAssignee(changeNum, assignee) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'PUT',
- endpoint: '/assignee',
- body: {assignee},
- reportUrlAsIs: true,
- });
- }
-
- deleteAssignee(changeNum) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'DELETE',
- endpoint: '/assignee',
- reportUrlAsIs: true,
- });
- }
-
- probePath(path) {
- return fetch(new Request(path, {method: 'HEAD'}))
- .then(response => response.ok);
- }
-
- /**
- * @param {number|string} changeNum
- * @param {number|string=} opt_message
- */
- startWorkInProgress(changeNum, opt_message) {
- const body = {};
- if (opt_message) {
- body.message = opt_message;
- }
- const req = {
- changeNum,
- method: 'POST',
- endpoint: '/wip',
- body,
- reportUrlAsIs: true,
- };
- return this._getChangeURLAndSend(req).then(response => {
- if (response.status === 204) {
- return 'Change marked as Work In Progress.';
- }
- });
- }
-
- /**
- * @param {number|string} changeNum
- * @param {number|string=} opt_body
- * @param {function(?Response, string=)=} opt_errFn
- */
- startReview(changeNum, opt_body, opt_errFn) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'POST',
- endpoint: '/ready',
- body: opt_body,
- errFn: opt_errFn,
- reportUrlAsIs: true,
- });
- }
-
- /**
- * @suppress {checkTypes}
- * Resulted in error: Promise.prototype.then does not match formal
- * parameter.
- */
- deleteComment(changeNum, patchNum, commentID, reason) {
- return this._getChangeURLAndSend({
- changeNum,
- method: 'POST',
- patchNum,
- endpoint: `/comments/${commentID}/delete`,
- body: {reason},
- parseResponse: true,
- anonymizedEndpoint: '/comments/*/delete',
- });
- }
-
- /**
- * Given a changeNum, gets the change.
- *
- * @param {number|string} changeNum
- * @param {function(?Response, string=)=} opt_errFn
- * @return {!Promise<?Object>} The change
- */
- getChange(changeNum, opt_errFn) {
- // Cannot use _changeBaseURL, as this function is used by _projectLookup.
- return this._restApiHelper.fetchJSON({
- url: `/changes/?q=change:${changeNum}`,
- errFn: opt_errFn,
- anonymizedUrl: '/changes/?q=change:*',
- }).then(res => {
- if (!res || !res.length) { return null; }
- return res[0];
- });
- }
-
- /**
- * @param {string|number} changeNum
- * @param {string=} project
- */
- setInProjectLookup(changeNum, project) {
- if (this._projectLookup[changeNum] &&
- this._projectLookup[changeNum] !== project) {
- console.warn('Change set with multiple project nums.' +
- 'One of them must be invalid.');
- }
- this._projectLookup[changeNum] = project;
- }
-
- /**
- * Checks in _projectLookup for the changeNum. If it exists, returns the
- * project. If not, calls the restAPI to get the change, populates
- * _projectLookup with the project for that change, and returns the project.
- *
- * @param {string|number} changeNum
- * @return {!Promise<string|undefined>}
- */
- getFromProjectLookup(changeNum) {
- const project = this._projectLookup[changeNum];
- if (project) { return Promise.resolve(project); }
-
- const onError = response => {
- // Fire a page error so that the visual 404 is displayed.
- this.dispatchEvent(new CustomEvent('page-error', {
- detail: {response},
- composed: true, bubbles: true,
- }));
- };
-
- return this.getChange(changeNum, onError).then(change => {
- if (!change || !change.project) { return; }
- this.setInProjectLookup(changeNum, change.project);
- return change.project;
- });
- }
-
- /**
- * Alias for _changeBaseURL.then(send).
- *
- * @todo(beckysiegel) clean up comments
- * @param {Gerrit.ChangeSendRequest} req
- * @return {!Promise<!Object>}
- */
- _getChangeURLAndSend(req) {
- const anonymizedBaseUrl = req.patchNum ?
- ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
- const anonymizedEndpoint = req.reportEndpointAsIs ?
- req.endpoint : req.anonymizedEndpoint;
-
- return this._changeBaseURL(req.changeNum, req.patchNum)
- .then(url => this._restApiHelper.send({
- method: req.method,
- url: url + req.endpoint,
- body: req.body,
- errFn: req.errFn,
- contentType: req.contentType,
- headers: req.headers,
- parseResponse: req.parseResponse,
- anonymizedUrl: anonymizedEndpoint ?
- (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
- }));
- }
-
- /**
- * Alias for _changeBaseURL.then(_fetchJSON).
- *
- * @param {Gerrit.ChangeFetchRequest} req
- * @return {!Promise<!Object>}
- */
- _getChangeURLAndFetch(req, noAcceptHeader) {
- const anonymizedEndpoint = req.reportEndpointAsIs ?
- req.endpoint : req.anonymizedEndpoint;
- const anonymizedBaseUrl = req.patchNum ?
- ANONYMIZED_REVISION_BASE_URL : ANONYMIZED_CHANGE_BASE_URL;
- return this._changeBaseURL(req.changeNum, req.patchNum)
- .then(url => this._restApiHelper.fetchJSON({
- url: url + req.endpoint,
- errFn: req.errFn,
- params: req.params,
- fetchOptions: req.fetchOptions,
- anonymizedUrl: anonymizedEndpoint ?
- (anonymizedBaseUrl + anonymizedEndpoint) : undefined,
- }, noAcceptHeader));
- }
-
- /**
- * Execute a change action or revision action on a change.
- *
- * @param {number} changeNum
- * @param {string} method
- * @param {string} endpoint
- * @param {string|number|undefined} opt_patchNum
- * @param {Object=} opt_payload
- * @param {?function(?Response, string=)=} opt_errFn
- * @return {Promise}
- */
- executeChangeAction(changeNum, method, endpoint, opt_patchNum, opt_payload,
- opt_errFn) {
- return this._getChangeURLAndSend({
- changeNum,
- method,
- patchNum: opt_patchNum,
- endpoint,
- body: opt_payload,
- errFn: opt_errFn,
- });
- }
-
- /**
- * Get blame information for the given diff.
- *
- * @param {string|number} changeNum
- * @param {string|number} patchNum
- * @param {string} path
- * @param {boolean=} opt_base If true, requests blame for the base of the
- * diff, rather than the revision.
- * @return {!Promise<!Object>}
- */
- getBlame(changeNum, patchNum, path, opt_base) {
- const encodedPath = encodeURIComponent(path);
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: `/files/${encodedPath}/blame`,
- patchNum,
- params: opt_base ? {base: 't'} : undefined,
- anonymizedEndpoint: '/files/*/blame',
- });
- }
-
- /**
- * Modify the given create draft request promise so that it fails and throws
- * an error if the response bears HTTP status 200 instead of HTTP 201.
- *
- * @see Issue 7763
- * @param {Promise} promise The original promise.
- * @return {Promise} The modified promise.
- */
- _failForCreate200(promise) {
- return promise.then(result => {
- if (result.status === 200) {
- // Read the response headers into an object representation.
- const headers = Array.from(result.headers.entries())
- .reduce((obj, [key, val]) => {
- if (!HEADER_REPORTING_BLOCK_REGEX.test(key)) {
- obj[key] = val;
- }
- return obj;
- }, {});
- const err = new Error([
- CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
- JSON.stringify(headers),
- ].join('\n'));
- // Throw the error so that it is caught by gr-reporting.
- throw err;
- }
- return result;
- });
- }
-
- /**
- * Fetch a project dashboard definition.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
- *
- * @param {string} project
- * @param {string} dashboard
- * @param {function(?Response, string=)=} opt_errFn
- * passed as null sometimes.
- * @return {!Promise<!Object>}
- */
- getDashboard(project, dashboard, opt_errFn) {
- const url = '/projects/' + encodeURIComponent(project) + '/dashboards/' +
- encodeURIComponent(dashboard);
- return this._fetchSharedCacheURL({
- url,
- errFn: opt_errFn,
- anonymizedUrl: '/projects/*/dashboards/*',
- });
- }
-
- /**
- * @param {string} filter
- * @return {!Promise<?Object>}
- */
- getDocumentationSearches(filter) {
- filter = filter.trim();
- const encodedFilter = encodeURIComponent(filter);
-
- // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
- // supports it.
- return this._fetchSharedCacheURL({
- url: `/Documentation/?q=${encodedFilter}`,
- anonymizedUrl: '/Documentation/?*',
- });
- }
-
- getMergeable(changeNum) {
- return this._getChangeURLAndFetch({
- changeNum,
- endpoint: '/revisions/current/mergeable',
- parseResponse: true,
- reportEndpointAsIs: true,
- });
- }
-
- deleteDraftComments(query) {
- return this._restApiHelper.send({
- method: 'POST',
- url: '/accounts/self/drafts:delete',
- body: {query},
- });
- }
-}
-
-customElements.define(GrRestApiInterface.is, GrRestApiInterface);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
new file mode 100644
index 0000000..1a89008
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.ts
@@ -0,0 +1,3632 @@
+/**
+ * @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.
+ */
+/* NB: Order is important, because of namespaced classes. */
+
+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 {GrEtagDecorator} from './gr-etag-decorator';
+import {
+ FetchJSONRequest,
+ FetchParams,
+ FetchPromisesCache,
+ GrRestApiHelper,
+ SendJSONRequest,
+ SendRequest,
+ SiteBasedCache,
+} from './gr-rest-apis/gr-rest-api-helper';
+import {
+ GrReviewerUpdatesParser,
+ ParsedChangeInfo,
+} from './gr-reviewer-updates-parser';
+import {parseDate} from '../../../utils/date-util';
+import {getBaseUrl} from '../../../utils/url-util';
+import {appContext} from '../../../services/app-context';
+import {
+ getParentIndex,
+ isMergeParent,
+ patchNumEquals,
+} from '../../../utils/patch-set-util';
+import {
+ ListChangesOption,
+ listChangesOptionsToHex,
+} from '../../../utils/change-util';
+import {assertNever, hasOwnProperty} from '../../../utils/common-util';
+import {customElement, property} from '@polymer/decorators';
+import {AuthRequestInit, AuthService} from '../../../services/gr-auth/gr-auth';
+import {
+ AccountCapabilityInfo,
+ AccountDetailInfo,
+ AccountExternalIdInfo,
+ AccountId,
+ AccountInfo,
+ AssigneeInput,
+ Base64File,
+ Base64FileContent,
+ Base64ImageFile,
+ BranchInfo,
+ BranchName,
+ ChangeId,
+ ChangeInfo,
+ ChangeMessageId,
+ CommentInfo,
+ CommentInput,
+ CommitId,
+ CommitInfo,
+ ConfigInfo,
+ ConfigInput,
+ DashboardId,
+ DashboardInfo,
+ DeleteDraftCommentsInput,
+ DiffInfo,
+ DiffPreferenceInput,
+ DiffPreferencesInfo,
+ EditPatchSetNum,
+ EditPreferencesInfo,
+ EncodedGroupId,
+ GitRef,
+ GpgKeyId,
+ GroupId,
+ GroupInfo,
+ GroupInput,
+ GroupOptionsInput,
+ HashtagsInput,
+ ImagesForDiff,
+ NameToProjectInfoMap,
+ ParentPatchSetNum,
+ ParsedJSON,
+ PatchRange,
+ PatchSetNum,
+ PathToCommentsInfoMap,
+ PathToRobotCommentsInfoMap,
+ PreferencesInfo,
+ PreferencesInput,
+ ProjectAccessInfoMap,
+ ProjectAccessInput,
+ ProjectInfo,
+ ProjectInput,
+ ProjectWatchInfo,
+ RepoName,
+ ReviewInput,
+ ServerInfo,
+ SshKeyInfo,
+ UrlEncodedCommentId,
+ EditInfo,
+ FileNameToFileInfoMap,
+ SuggestedReviewerInfo,
+ GroupNameToGroupInfoMap,
+ GroupAuditEventInfo,
+ RequestPayload,
+ Password,
+ ContributorAgreementInput,
+ ContributorAgreementInfo,
+ BranchInput,
+ IncludedInInfo,
+ TagInput,
+ PluginInfo,
+ GpgKeyInfo,
+ GpgKeysInput,
+ DocResult,
+ EmailInfo,
+ ProjectAccessInfo,
+ CapabilityInfoMap,
+ ProjectInfoWithName,
+ TagInfo,
+ RelatedChangesInfo,
+ SubmittedTogetherInfo,
+ NumericChangeId,
+ EmailAddress,
+ FixId,
+ FilePathToDiffInfoMap,
+ ChangeViewChangeInfo,
+ BlameInfo,
+ ActionNameToActionInfoMap,
+ RevisionId,
+ GroupName,
+ Hashtag,
+ TopMenuEntryInfo,
+ MergeableInfo,
+} from '../../../types/common';
+import {
+ CancelConditionCallback,
+ ErrorCallback,
+ RestApiService,
+ GetDiffCommentsOutput,
+ GetDiffRobotCommentsOutput,
+} from '../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ CommentSide,
+ DiffViewMode,
+ HttpMethod,
+ IgnoreWhitespaceType,
+ ReviewerState,
+} from '../../../constants/constants';
+
+const JSON_PREFIX = ")]}'";
+const MAX_PROJECT_RESULTS = 25;
+// This value is somewhat arbitrary and not based on research or calculations.
+const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 850;
+
+const Requests = {
+ SEND_DIFF_DRAFT: 'sendDiffDraft',
+};
+
+const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
+ 'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
+const HEADER_REPORTING_BLOCK_REGEX = /^set-cookie$/i;
+
+const ANONYMIZED_CHANGE_BASE_URL = '/changes/*~*';
+const ANONYMIZED_REVISION_BASE_URL =
+ ANONYMIZED_CHANGE_BASE_URL + '/revisions/*';
+
+let siteBasedCache = new SiteBasedCache(); // Shared across instances.
+let fetchPromisesCache = new FetchPromisesCache(); // Shared across instances.
+let pendingRequest: {[promiseName: string]: Array<Promise<unknown>>} = {}; // Shared across instances.
+let grEtagDecorator = new GrEtagDecorator(); // Shared across instances.
+let projectLookup: {[changeNum: string]: RepoName} = {}; // Shared across instances.
+
+interface FetchChangeJSON {
+ reportEndpointAsIs?: boolean;
+ endpoint: string;
+ anonymizedEndpoint?: string;
+ revision?: RevisionId;
+ changeNum: NumericChangeId;
+ errFn?: ErrorCallback;
+ params?: FetchParams;
+ fetchOptions?: AuthRequestInit;
+ // TODO(TS): The following properties are not used, however some methods
+ // set them to true. They should be either changed to reportEndpointAsIs: true
+ // or deleted. This should be done carefully case by case.
+ reportEndpointAsId?: true;
+}
+
+interface SendChangeRequestBase {
+ patchNum?: PatchSetNum;
+ reportEndpointAsIs?: boolean;
+ endpoint: string;
+ anonymizedEndpoint?: string;
+ changeNum: NumericChangeId;
+ method: HttpMethod | undefined;
+ errFn?: ErrorCallback;
+ headers?: Record<string, string>;
+ contentType?: string;
+ body?: string | object;
+
+ // TODO(TS): The following properties are not used, however some methods
+ // set them to true. They should be either changed to reportEndpointAsIs: true
+ // or deleted. This should be done carefully case by case.
+ reportUrlAsIs?: true;
+ reportEndpointAsId?: true;
+}
+
+interface SendRawChangeRequest extends SendChangeRequestBase {
+ parseResponse?: false | null;
+}
+
+interface SendJSONChangeRequest extends SendChangeRequestBase {
+ parseResponse: true;
+}
+
+interface QueryChangesParams {
+ [paramName: string]: string | undefined | number | string[];
+ O?: string; // options
+ S: number; // start
+ n?: number; // changes per page
+ q?: string | string[]; // query/queries
+}
+
+interface QueryAccountsParams {
+ [paramName: string]: string | undefined | null | number;
+ suggest: null;
+ q: string;
+ n?: number;
+}
+
+interface QueryGroupsParams {
+ [paramName: string]: string | undefined | null | number;
+ s: string;
+ n?: number;
+}
+
+interface QuerySuggestedReviewersParams {
+ [paramName: string]: string | undefined | null | number;
+ n: number;
+ q?: string;
+ 'reviewer-state': ReviewerState;
+}
+
+interface GetDiffParams {
+ [paramName: string]: string | undefined | null | number | boolean;
+ context?: number | 'ALL';
+ intraline?: boolean | null;
+ whitespace?: IgnoreWhitespaceType;
+ parent?: number;
+ base?: PatchSetNum;
+}
+
+type SendChangeRequest = SendRawChangeRequest | SendJSONChangeRequest;
+
+export function _testOnlyResetGrRestApiSharedObjects() {
+ // TODO(TS): The commented code below didn't do anything.
+ // It is impossible to reject an existing promise. Should be rewritten in a
+ // different way
+ // const fetchPromisesCacheData = fetchPromisesCache.testOnlyGetData();
+ // for (const key in fetchPromisesCacheData) {
+ // if (hasOwnProperty(fetchPromisesCacheData, key)) {
+ // // reject already fulfilled promise does nothing
+ // fetchPromisesCacheData[key]!.reject();
+ // }
+ // }
+ //
+ // for (const key in pendingRequest) {
+ // if (!hasOwnProperty(pendingRequest, key)) {
+ // continue;
+ // }
+ // for (const req of pendingRequest[key]) {
+ // // reject already fulfilled promise does nothing
+ // req.reject();
+ // }
+ // }
+
+ siteBasedCache = new SiteBasedCache();
+ fetchPromisesCache = new FetchPromisesCache();
+ pendingRequest = {};
+ grEtagDecorator = new GrEtagDecorator();
+ projectLookup = {};
+ appContext.authService.clearCache();
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-rest-api-interface': GrRestApiInterface;
+ }
+}
+
+@customElement('gr-rest-api-interface')
+export class GrRestApiInterface
+ extends GestureEventListeners(LegacyElementMixin(PolymerElement))
+ implements RestApiService {
+ readonly JSON_PREFIX = JSON_PREFIX;
+ /**
+ * Fired when an server error occurs.
+ *
+ * @event server-error
+ */
+
+ /**
+ * Fired when a network error occurs.
+ *
+ * @event network-error
+ */
+
+ /**
+ * Fired after an RPC completes.
+ *
+ * @event rpc-log
+ */
+
+ @property({type: Object})
+ readonly _cache = siteBasedCache; // Shared across instances.
+
+ @property({type: Object})
+ readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.
+
+ @property({type: Object})
+ readonly _pendingRequests = pendingRequest; // Shared across instances.
+
+ @property({type: Object})
+ readonly _etags = grEtagDecorator; // Shared across instances.
+
+ @property({type: Object})
+ readonly _projectLookup = projectLookup; // Shared across instances.
+
+ // The value is set in created, before any other actions
+ private authService: AuthService;
+
+ // The value is set in created, before any other actions
+ private readonly _restApiHelper: GrRestApiHelper;
+
+ constructor() {
+ super();
+ this.authService = appContext.authService;
+ this._restApiHelper = new GrRestApiHelper(
+ this._cache,
+ this.authService,
+ this._sharedFetchPromises,
+ this
+ );
+ }
+
+ _fetchSharedCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
+ // Cache is shared across instances
+ return this._restApiHelper.fetchCacheURL(req);
+ }
+
+ getResponseObject(response: Response): Promise<ParsedJSON> {
+ return this._restApiHelper.getResponseObject(response);
+ }
+
+ getConfig(noCache?: boolean): Promise<ServerInfo | undefined> {
+ if (!noCache) {
+ return this._fetchSharedCacheURL({
+ url: '/config/server/info',
+ reportUrlAsIs: true,
+ }) as Promise<ServerInfo | undefined>;
+ }
+
+ return this._restApiHelper.fetchJSON({
+ url: '/config/server/info',
+ reportUrlAsIs: true,
+ }) as Promise<ServerInfo | undefined>;
+ }
+
+ getRepo(
+ repo: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<ProjectInfo | undefined> {
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return this._fetchSharedCacheURL({
+ url: '/projects/' + encodeURIComponent(repo),
+ errFn,
+ anonymizedUrl: '/projects/*',
+ }) as Promise<ProjectInfo | undefined>;
+ }
+
+ getProjectConfig(
+ repo: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<ConfigInfo | undefined> {
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return this._fetchSharedCacheURL({
+ url: '/projects/' + encodeURIComponent(repo) + '/config',
+ errFn,
+ anonymizedUrl: '/projects/*/config',
+ }) as Promise<ConfigInfo | undefined>;
+ }
+
+ getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined> {
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return this._fetchSharedCacheURL({
+ url: '/access/?project=' + encodeURIComponent(repo),
+ anonymizedUrl: '/access/?project=*',
+ }) as Promise<ProjectAccessInfoMap | undefined>;
+ }
+
+ getRepoDashboards(
+ repo: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<DashboardInfo[] | undefined> {
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return this._fetchSharedCacheURL({
+ url: `/projects/${encodeURIComponent(repo)}/dashboards?inherited`,
+ errFn,
+ anonymizedUrl: '/projects/*/dashboards?inherited',
+ }) as Promise<DashboardInfo[] | undefined>;
+ }
+
+ saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
+
+ saveRepoConfig(
+ repo: RepoName,
+ config: ConfigInput,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ saveRepoConfig(
+ repo: RepoName,
+ config: ConfigInput,
+ errFn?: ErrorCallback
+ ): Promise<Response | undefined> {
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ const url = `/projects/${encodeURIComponent(repo)}/config`;
+ this._cache.delete(url);
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url,
+ body: config,
+ errFn,
+ anonymizedUrl: '/projects/*/config',
+ });
+ }
+
+ runRepoGC(repo: RepoName): Promise<Response>;
+
+ runRepoGC(
+ repo: RepoName,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ runRepoGC(repo: RepoName, errFn?: ErrorCallback) {
+ if (!repo) {
+ // TODO(TS): fix return value
+ return '';
+ }
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ const encodeName = encodeURIComponent(repo);
+ return this._restApiHelper.send({
+ method: HttpMethod.POST,
+ url: `/projects/${encodeName}/gc`,
+ body: '',
+ errFn,
+ anonymizedUrl: '/projects/*/gc',
+ });
+ }
+
+ createRepo(config: ProjectInput & {name: RepoName}): Promise<Response>;
+
+ createRepo(
+ config: ProjectInput & {name: RepoName},
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ createRepo(config: ProjectInput, errFn?: ErrorCallback) {
+ if (!config.name) {
+ // TODO(TS): Fix return value
+ return '';
+ }
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ const encodeName = encodeURIComponent(config.name);
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/projects/${encodeName}`,
+ body: config,
+ errFn,
+ anonymizedUrl: '/projects/*',
+ });
+ }
+
+ createGroup(config: GroupInput & {name: string}): Promise<Response>;
+
+ createGroup(
+ config: GroupInput & {name: string},
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ createGroup(config: GroupInput, errFn?: ErrorCallback) {
+ if (!config.name) {
+ // TODO(TS): Fix return value
+ return '';
+ }
+ const encodeName = encodeURIComponent(config.name);
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/groups/${encodeName}`,
+ body: config,
+ errFn,
+ anonymizedUrl: '/groups/*',
+ });
+ }
+
+ getGroupConfig(
+ group: GroupId | GroupName,
+ errFn?: ErrorCallback
+ ): Promise<GroupInfo | undefined> {
+ return this._restApiHelper.fetchJSON({
+ url: `/groups/${encodeURIComponent(group)}/detail`,
+ errFn,
+ anonymizedUrl: '/groups/*/detail',
+ }) as Promise<GroupInfo | undefined>;
+ }
+
+ deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
+
+ deleteRepoBranches(
+ repo: RepoName,
+ ref: GitRef,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ deleteRepoBranches(repo: RepoName, ref: GitRef, errFn?: ErrorCallback) {
+ if (!repo || !ref) {
+ // TODO(TS): fix return value
+ return '';
+ }
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ const encodeName = encodeURIComponent(repo);
+ const encodeRef = encodeURIComponent(ref);
+ return this._restApiHelper.send({
+ method: HttpMethod.DELETE,
+ url: `/projects/${encodeName}/branches/${encodeRef}`,
+ body: '',
+ errFn,
+ anonymizedUrl: '/projects/*/branches/*',
+ });
+ }
+
+ deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
+
+ deleteRepoTags(
+ repo: RepoName,
+ ref: GitRef,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ deleteRepoTags(repo: RepoName, ref: GitRef, errFn?: ErrorCallback) {
+ if (!repo || !ref) {
+ // TODO(TS): fix return type
+ return '';
+ }
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ const encodeName = encodeURIComponent(repo);
+ const encodeRef = encodeURIComponent(ref);
+ return this._restApiHelper.send({
+ method: HttpMethod.DELETE,
+ url: `/projects/${encodeName}/tags/${encodeRef}`,
+ body: '',
+ errFn,
+ anonymizedUrl: '/projects/*/tags/*',
+ });
+ }
+
+ createRepoBranch(
+ name: RepoName,
+ branch: BranchName,
+ revision: BranchInput
+ ): Promise<Response>;
+
+ createRepoBranch(
+ name: RepoName,
+ branch: BranchName,
+ revision: BranchInput,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ createRepoBranch(
+ name: RepoName,
+ branch: BranchName,
+ revision: BranchInput,
+ errFn?: ErrorCallback
+ ) {
+ if (!name || !branch || !revision) {
+ // TODO(TS) fix return type
+ return '';
+ }
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ const encodeName = encodeURIComponent(name);
+ const encodeBranch = encodeURIComponent(branch);
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/projects/${encodeName}/branches/${encodeBranch}`,
+ body: revision,
+ errFn,
+ anonymizedUrl: '/projects/*/branches/*',
+ });
+ }
+
+ createRepoTag(
+ name: RepoName,
+ tag: string,
+ revision: TagInput
+ ): Promise<Response>;
+
+ createRepoTag(
+ name: RepoName,
+ tag: string,
+ revision: TagInput,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ createRepoTag(
+ name: RepoName,
+ tag: string,
+ revision: TagInput,
+ errFn?: ErrorCallback
+ ) {
+ if (!name || !tag || !revision) {
+ // TODO(TS): Fix return value
+ return '';
+ }
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ const encodeName = encodeURIComponent(name);
+ const encodeTag = encodeURIComponent(tag);
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/projects/${encodeName}/tags/${encodeTag}`,
+ body: revision,
+ errFn,
+ anonymizedUrl: '/projects/*/tags/*',
+ });
+ }
+
+ getIsGroupOwner(groupName: GroupName): Promise<boolean> {
+ const encodeName = encodeURIComponent(groupName);
+ const req = {
+ url: `/groups/?owned&g=${encodeName}`,
+ anonymizedUrl: '/groups/owned&g=*',
+ };
+ return this._fetchSharedCacheURL(req).then(configs =>
+ hasOwnProperty(configs, groupName)
+ );
+ }
+
+ getGroupMembers(
+ groupName: GroupId | GroupName,
+ errFn?: ErrorCallback
+ ): Promise<AccountInfo[] | undefined> {
+ const encodeName = encodeURIComponent(groupName);
+ return this._restApiHelper.fetchJSON({
+ url: `/groups/${encodeName}/members/`,
+ errFn,
+ anonymizedUrl: '/groups/*/members',
+ }) as Promise<AccountInfo[] | undefined>;
+ }
+
+ getIncludedGroup(
+ groupName: GroupId | GroupName
+ ): Promise<GroupInfo[] | undefined> {
+ return this._restApiHelper.fetchJSON({
+ url: `/groups/${encodeURIComponent(groupName)}/groups/`,
+ anonymizedUrl: '/groups/*/groups',
+ }) as Promise<GroupInfo[] | undefined>;
+ }
+
+ saveGroupName(groupId: GroupId | GroupName, name: string): Promise<Response> {
+ const encodeId = encodeURIComponent(groupId);
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/groups/${encodeId}/name`,
+ body: {name},
+ anonymizedUrl: '/groups/*/name',
+ });
+ }
+
+ saveGroupOwner(
+ groupId: GroupId | GroupName,
+ ownerId: string
+ ): Promise<Response> {
+ const encodeId = encodeURIComponent(groupId);
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/groups/${encodeId}/owner`,
+ body: {owner: ownerId},
+ anonymizedUrl: '/groups/*/owner',
+ });
+ }
+
+ saveGroupDescription(
+ groupId: GroupId | GroupName,
+ description: string
+ ): Promise<Response> {
+ const encodeId = encodeURIComponent(groupId);
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/groups/${encodeId}/description`,
+ body: {description},
+ anonymizedUrl: '/groups/*/description',
+ });
+ }
+
+ saveGroupOptions(
+ groupId: GroupId | GroupName,
+ options: GroupOptionsInput
+ ): Promise<Response> {
+ const encodeId = encodeURIComponent(groupId);
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/groups/${encodeId}/options`,
+ body: options,
+ anonymizedUrl: '/groups/*/options',
+ });
+ }
+
+ getGroupAuditLog(
+ group: EncodedGroupId,
+ errFn?: ErrorCallback
+ ): Promise<GroupAuditEventInfo[] | undefined> {
+ return this._fetchSharedCacheURL({
+ url: `/groups/${group}/log.audit`,
+ errFn,
+ anonymizedUrl: '/groups/*/log.audit',
+ }) as Promise<GroupAuditEventInfo[] | undefined>;
+ }
+
+ saveGroupMember(
+ groupName: GroupId | GroupName,
+ groupMember: AccountId
+ ): Promise<AccountInfo> {
+ const encodeName = encodeURIComponent(groupName);
+ const encodeMember = encodeURIComponent(`${groupMember}`);
+ return (this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/groups/${encodeName}/members/${encodeMember}`,
+ parseResponse: true,
+ anonymizedUrl: '/groups/*/members/*',
+ }) as unknown) as Promise<AccountInfo>;
+ }
+
+ saveIncludedGroup(
+ groupName: GroupId | GroupName,
+ includedGroup: GroupId,
+ errFn?: ErrorCallback
+ ): Promise<GroupInfo | undefined> {
+ const encodeName = encodeURIComponent(groupName);
+ const encodeIncludedGroup = encodeURIComponent(includedGroup);
+ const req = {
+ method: HttpMethod.PUT,
+ url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+ errFn,
+ anonymizedUrl: '/groups/*/groups/*',
+ };
+ return this._restApiHelper.send(req).then(response => {
+ if (response?.ok) {
+ return (this.getResponseObject(response) as unknown) as Promise<
+ GroupInfo
+ >;
+ }
+ return undefined;
+ });
+ }
+
+ deleteGroupMember(
+ groupName: GroupId | GroupName,
+ groupMember: AccountId
+ ): Promise<Response> {
+ const encodeName = encodeURIComponent(groupName);
+ const encodeMember = encodeURIComponent(`${groupMember}`);
+ return this._restApiHelper.send({
+ method: HttpMethod.DELETE,
+ url: `/groups/${encodeName}/members/${encodeMember}`,
+ anonymizedUrl: '/groups/*/members/*',
+ });
+ }
+
+ deleteIncludedGroup(
+ groupName: GroupId,
+ includedGroup: GroupId | GroupName
+ ): Promise<Response> {
+ const encodeName = encodeURIComponent(groupName);
+ const encodeIncludedGroup = encodeURIComponent(includedGroup);
+ return this._restApiHelper.send({
+ method: HttpMethod.DELETE,
+ url: `/groups/${encodeName}/groups/${encodeIncludedGroup}`,
+ anonymizedUrl: '/groups/*/groups/*',
+ });
+ }
+
+ getVersion(): Promise<string | undefined> {
+ return this._fetchSharedCacheURL({
+ url: '/config/server/version',
+ reportUrlAsIs: true,
+ }) as Promise<string | undefined>;
+ }
+
+ getDiffPreferences(): Promise<DiffPreferencesInfo | undefined> {
+ return this.getLoggedIn().then(loggedIn => {
+ if (loggedIn) {
+ return this._fetchSharedCacheURL({
+ url: '/accounts/self/preferences.diff',
+ reportUrlAsIs: true,
+ }) as Promise<DiffPreferencesInfo | undefined>;
+ }
+ const anonymousResult: DiffPreferencesInfo = {
+ auto_hide_diff_table_header: true,
+ context: 10,
+ cursor_blink_rate: 0,
+ font_size: 12,
+ ignore_whitespace: IgnoreWhitespaceType.IGNORE_NONE,
+ intraline_difference: true,
+ line_length: 100,
+ line_wrapping: false,
+ show_line_endings: true,
+ show_tabs: true,
+ show_whitespace_errors: true,
+ syntax_highlighting: true,
+ tab_size: 8,
+ theme: 'DEFAULT',
+ };
+ // These defaults should match the defaults in
+ // java/com/google/gerrit/extensions/client/DiffPreferencesInfo.java
+ // NOTE: There are some settings that don't apply to PolyGerrit
+ // (Render mode being at least one of them).
+ return Promise.resolve(anonymousResult);
+ });
+ }
+
+ getEditPreferences(): Promise<EditPreferencesInfo | undefined> {
+ return this.getLoggedIn().then(loggedIn => {
+ if (loggedIn) {
+ return this._fetchSharedCacheURL({
+ url: '/accounts/self/preferences.edit',
+ reportUrlAsIs: true,
+ }) as Promise<EditPreferencesInfo | undefined>;
+ }
+ const result: EditPreferencesInfo = {
+ auto_close_brackets: false,
+ cursor_blink_rate: 0,
+ hide_line_numbers: false,
+ hide_top_menu: false,
+ indent_unit: 2,
+ indent_with_tabs: false,
+ key_map_type: 'DEFAULT',
+ line_length: 100,
+ line_wrapping: false,
+ match_brackets: true,
+ show_base: false,
+ show_tabs: true,
+ show_whitespace_errors: true,
+ syntax_highlighting: true,
+ tab_size: 8,
+ theme: 'DEFAULT',
+ };
+ // These defaults should match the defaults in
+ // java/com/google/gerrit/extensions/client/EditPreferencesInfo.java
+ return Promise.resolve(result);
+ });
+ }
+
+ savePreferences(prefs: PreferencesInput): Promise<Response>;
+
+ savePreferences(
+ prefs: PreferencesInput,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ savePreferences(prefs: PreferencesInput, errFn?: ErrorCallback) {
+ // Note (Issue 5142): normalize the download scheme with lower case before
+ // saving.
+ if (prefs.download_scheme) {
+ prefs.download_scheme = prefs.download_scheme.toLowerCase();
+ }
+
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: '/accounts/self/preferences',
+ body: prefs,
+ errFn,
+ reportUrlAsIs: true,
+ });
+ }
+
+ saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response>;
+
+ saveDiffPreferences(
+ prefs: DiffPreferenceInput,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ saveDiffPreferences(prefs: DiffPreferenceInput, errFn?: ErrorCallback) {
+ // Invalidate the cache.
+ this._cache.delete('/accounts/self/preferences.diff');
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: '/accounts/self/preferences.diff',
+ body: prefs,
+ errFn,
+ reportUrlAsIs: true,
+ });
+ }
+
+ saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response>;
+
+ saveEditPreferences(
+ prefs: EditPreferencesInfo,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ saveEditPreferences(prefs: EditPreferencesInfo, errFn?: ErrorCallback) {
+ // Invalidate the cache.
+ this._cache.delete('/accounts/self/preferences.edit');
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: '/accounts/self/preferences.edit',
+ body: prefs,
+ errFn,
+ reportUrlAsIs: true,
+ });
+ }
+
+ getAccount(): Promise<AccountDetailInfo | undefined> {
+ return this._fetchSharedCacheURL({
+ url: '/accounts/self/detail',
+ reportUrlAsIs: true,
+ errFn: resp => {
+ if (!resp || resp.status === 403) {
+ this._cache.delete('/accounts/self/detail');
+ }
+ },
+ }) as Promise<AccountDetailInfo | undefined>;
+ }
+
+ getAvatarChangeUrl() {
+ return this._fetchSharedCacheURL({
+ url: '/accounts/self/avatar.change.url',
+ reportUrlAsIs: true,
+ errFn: resp => {
+ if (!resp || resp.status === 403) {
+ this._cache.delete('/accounts/self/avatar.change.url');
+ }
+ },
+ }) as Promise<string | undefined>;
+ }
+
+ getExternalIds() {
+ return this._restApiHelper.fetchJSON({
+ url: '/accounts/self/external.ids',
+ reportUrlAsIs: true,
+ }) as Promise<AccountExternalIdInfo[] | undefined>;
+ }
+
+ deleteAccountIdentity(id: string[]) {
+ return this._restApiHelper.send({
+ method: HttpMethod.POST,
+ url: '/accounts/self/external.ids:delete',
+ body: id,
+ parseResponse: true,
+ reportUrlAsIs: true,
+ }) as Promise<unknown>;
+ }
+
+ getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined> {
+ return this._restApiHelper.fetchJSON({
+ url: `/accounts/${encodeURIComponent(userId)}/detail`,
+ anonymizedUrl: '/accounts/*/detail',
+ }) as Promise<AccountDetailInfo | undefined>;
+ }
+
+ getAccountEmails() {
+ return this._fetchSharedCacheURL({
+ url: '/accounts/self/emails',
+ reportUrlAsIs: true,
+ }) as Promise<EmailInfo[] | undefined>;
+ }
+
+ addAccountEmail(email: string): Promise<Response>;
+
+ addAccountEmail(
+ email: string,
+ errFn?: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ addAccountEmail(email: string, errFn?: ErrorCallback) {
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: '/accounts/self/emails/' + encodeURIComponent(email),
+ errFn,
+ anonymizedUrl: '/account/self/emails/*',
+ });
+ }
+
+ deleteAccountEmail(email: string): Promise<Response>;
+
+ deleteAccountEmail(
+ email: string,
+ errFn?: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ deleteAccountEmail(email: string, errFn?: ErrorCallback) {
+ return this._restApiHelper.send({
+ method: HttpMethod.DELETE,
+ url: '/accounts/self/emails/' + encodeURIComponent(email),
+ errFn,
+ anonymizedUrl: '/accounts/self/email/*',
+ });
+ }
+
+ setPreferredAccountEmail(
+ email: string,
+ errFn?: ErrorCallback
+ ): Promise<void> {
+ // TODO(TS): add correct error handling
+ const encodedEmail = encodeURIComponent(email);
+ const req = {
+ method: HttpMethod.PUT,
+ url: `/accounts/self/emails/${encodedEmail}/preferred`,
+ errFn,
+ anonymizedUrl: '/accounts/self/emails/*/preferred',
+ };
+ return this._restApiHelper.send(req).then(() => {
+ // If result of getAccountEmails is in cache, update it in the cache
+ // so we don't have to invalidate it.
+ const cachedEmails = this._cache.get('/accounts/self/emails');
+ if (cachedEmails) {
+ const emails = cachedEmails.map(entry => {
+ if (entry.email === email) {
+ return {email, preferred: true};
+ } else {
+ return {email};
+ }
+ });
+ this._cache.set('/accounts/self/emails', emails);
+ }
+ });
+ }
+
+ _updateCachedAccount(obj: Partial<AccountDetailInfo>): void {
+ // If result of getAccount is in cache, update it in the cache
+ // so we don't have to invalidate it.
+ const cachedAccount = this._cache.get('/accounts/self/detail');
+ if (cachedAccount) {
+ // Replace object in cache with new object to force UI updates.
+ this._cache.set('/accounts/self/detail', {...cachedAccount, ...obj});
+ }
+ }
+
+ setAccountName(name: string, errFn?: ErrorCallback): Promise<void> {
+ // TODO(TS): add correct error handling
+ const req: SendJSONRequest = {
+ method: HttpMethod.PUT,
+ url: '/accounts/self/name',
+ body: {name},
+ errFn,
+ parseResponse: true,
+ reportUrlAsIs: true,
+ };
+ return this._restApiHelper
+ .send(req)
+ .then(newName =>
+ this._updateCachedAccount({name: (newName as unknown) as string})
+ );
+ }
+
+ setAccountUsername(username: string, errFn?: ErrorCallback): Promise<void> {
+ // TODO(TS): add correct error handling
+ const req: SendJSONRequest = {
+ method: HttpMethod.PUT,
+ url: '/accounts/self/username',
+ body: {username},
+ errFn,
+ parseResponse: true,
+ reportUrlAsIs: true,
+ };
+ return this._restApiHelper
+ .send(req)
+ .then(newName =>
+ this._updateCachedAccount({username: (newName as unknown) as string})
+ );
+ }
+
+ setAccountDisplayName(
+ displayName: string,
+ errFn?: ErrorCallback
+ ): Promise<void> {
+ // TODO(TS): add correct error handling
+ const req: SendJSONRequest = {
+ method: HttpMethod.PUT,
+ url: '/accounts/self/displayname',
+ body: {display_name: displayName},
+ errFn,
+ parseResponse: true,
+ reportUrlAsIs: true,
+ };
+ return this._restApiHelper.send(req).then(newName =>
+ this._updateCachedAccount({
+ display_name: (newName as unknown) as string,
+ })
+ );
+ }
+
+ setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void> {
+ // TODO(TS): add correct error handling
+ const req: SendJSONRequest = {
+ method: HttpMethod.PUT,
+ url: '/accounts/self/status',
+ body: {status},
+ errFn,
+ parseResponse: true,
+ reportUrlAsIs: true,
+ };
+ return this._restApiHelper
+ .send(req)
+ .then(newStatus =>
+ this._updateCachedAccount({status: (newStatus as unknown) as string})
+ );
+ }
+
+ getAccountStatus(userId: AccountId) {
+ return this._restApiHelper.fetchJSON({
+ url: `/accounts/${encodeURIComponent(userId)}/status`,
+ anonymizedUrl: '/accounts/*/status',
+ }) as Promise<string | undefined>;
+ }
+
+ // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#list-groups
+ getAccountGroups() {
+ return this._restApiHelper.fetchJSON({
+ url: '/accounts/self/groups',
+ reportUrlAsIs: true,
+ }) as Promise<GroupInfo[] | undefined>;
+ }
+
+ getAccountAgreements() {
+ return this._restApiHelper.fetchJSON({
+ url: '/accounts/self/agreements',
+ reportUrlAsIs: true,
+ }) as Promise<ContributorAgreementInfo[] | undefined>;
+ }
+
+ saveAccountAgreement(name: ContributorAgreementInput): Promise<Response> {
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: '/accounts/self/agreements',
+ body: name,
+ reportUrlAsIs: true,
+ });
+ }
+
+ getAccountCapabilities(
+ params?: string[]
+ ): Promise<AccountCapabilityInfo | undefined> {
+ let queryString = '';
+ if (params) {
+ queryString =
+ '?q=' + params.map(param => encodeURIComponent(param)).join('&q=');
+ }
+ return this._fetchSharedCacheURL({
+ url: '/accounts/self/capabilities' + queryString,
+ anonymizedUrl: '/accounts/self/capabilities?q=*',
+ }) as Promise<AccountCapabilityInfo | undefined>;
+ }
+
+ getLoggedIn() {
+ return this.authService.authCheck();
+ }
+
+ getIsAdmin() {
+ return this.getLoggedIn()
+ .then(isLoggedIn => {
+ if (isLoggedIn) {
+ return this.getAccountCapabilities();
+ } else {
+ return;
+ }
+ })
+ .then(
+ (capabilities: AccountCapabilityInfo | undefined) =>
+ capabilities && capabilities.administrateServer
+ );
+ }
+
+ getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
+ return this._fetchSharedCacheURL({
+ url: '/config/server/preferences',
+ reportUrlAsIs: true,
+ }) as Promise<PreferencesInfo | undefined>;
+ }
+
+ getPreferences(): Promise<PreferencesInfo | undefined> {
+ return this.getLoggedIn().then(loggedIn => {
+ if (loggedIn) {
+ const req = {url: '/accounts/self/preferences', reportUrlAsIs: true};
+ return this._fetchSharedCacheURL(req).then(res => {
+ if (!res) {
+ return res;
+ }
+ const prefInfo = (res as unknown) as PreferencesInfo;
+ if (this._isNarrowScreen()) {
+ // Note that this can be problematic, because the diff will stay
+ // unified even after increasing the window width.
+ prefInfo.default_diff_view = DiffViewMode.UNIFIED;
+ } else {
+ prefInfo.default_diff_view = prefInfo.diff_view;
+ }
+ return prefInfo;
+ });
+ }
+
+ // TODO(TS): Many properties are omitted here, but they are required.
+ // Add default values for missed properties
+ const anonymousPrefs = {
+ changes_per_page: 25,
+ default_diff_view: this._isNarrowScreen()
+ ? DiffViewMode.UNIFIED
+ : DiffViewMode.SIDE_BY_SIDE,
+ diff_view: DiffViewMode.SIDE_BY_SIDE,
+ size_bar_in_change_table: true,
+ } as PreferencesInfo;
+
+ return anonymousPrefs;
+ });
+ }
+
+ getWatchedProjects() {
+ return (this._fetchSharedCacheURL({
+ url: '/accounts/self/watched.projects',
+ reportUrlAsIs: true,
+ }) as unknown) as Promise<ProjectWatchInfo[] | undefined>;
+ }
+
+ saveWatchedProjects(
+ projects: ProjectWatchInfo[],
+ errFn?: ErrorCallback
+ ): Promise<ProjectWatchInfo[]> {
+ return (this._restApiHelper.send({
+ method: HttpMethod.POST,
+ url: '/accounts/self/watched.projects',
+ body: projects,
+ errFn,
+ parseResponse: true,
+ reportUrlAsIs: true,
+ }) as unknown) as Promise<ProjectWatchInfo[]>;
+ }
+
+ deleteWatchedProjects(
+ projects: ProjectWatchInfo[]
+ ): Promise<Response | undefined>;
+
+ deleteWatchedProjects(
+ projects: ProjectWatchInfo[],
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ deleteWatchedProjects(projects: ProjectWatchInfo[], errFn?: ErrorCallback) {
+ return this._restApiHelper.send({
+ method: HttpMethod.POST,
+ url: '/accounts/self/watched.projects:delete',
+ body: projects,
+ errFn,
+ reportUrlAsIs: true,
+ });
+ }
+
+ _isNarrowScreen() {
+ return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
+ }
+
+ getChanges(
+ changesPerPage?: number,
+ query?: string,
+ offset?: 'n,z' | number,
+ options?: string
+ ): Promise<ChangeInfo[] | undefined>;
+
+ getChanges(
+ changesPerPage?: number,
+ query?: string[],
+ offset?: 'n,z' | number,
+ options?: string
+ ): Promise<ChangeInfo[][] | undefined>;
+
+ /**
+ * @return If opt_query is an
+ * array, _fetchJSON will return an array of arrays of changeInfos. If it
+ * is unspecified or a string, _fetchJSON will return an array of
+ * changeInfos.
+ */
+ getChanges(
+ changesPerPage?: number,
+ query?: string | string[],
+ offset?: 'n,z' | number,
+ options?: string
+ ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined> {
+ return this.getConfig(false)
+ .then(config => {
+ // TODO(TS): config can be null/undefined. Need some checks
+ options = options || this._getChangesOptionsHex(config);
+ // Issue 4524: respect legacy token with max sortkey.
+ if (offset === 'n,z') {
+ offset = 0;
+ }
+ const params: QueryChangesParams = {
+ O: options,
+ S: offset || 0,
+ };
+ if (changesPerPage) {
+ params.n = changesPerPage;
+ }
+ if (query && query.length > 0) {
+ params.q = query;
+ }
+ return {
+ url: '/changes/',
+ params,
+ reportUrlAsIs: true,
+ };
+ })
+ .then(
+ req =>
+ this._restApiHelper.fetchJSON(req, true) as Promise<
+ ChangeInfo[] | ChangeInfo[][] | undefined
+ >
+ )
+ .then(response => {
+ if (!response) {
+ return;
+ }
+ const iterateOverChanges = (arr: ChangeInfo[]) => {
+ for (const change of arr) {
+ this._maybeInsertInLookup(change);
+ }
+ };
+ // Response may be an array of changes OR an array of arrays of
+ // changes.
+ if (query instanceof Array) {
+ // Normalize the response to look like a multi-query response
+ // when there is only one query.
+ const responseArray: Array<ChangeInfo[]> =
+ query.length === 1
+ ? [response as ChangeInfo[]]
+ : (response as ChangeInfo[][]);
+ for (const arr of responseArray) {
+ iterateOverChanges(arr);
+ }
+ return responseArray;
+ } else {
+ iterateOverChanges(response as ChangeInfo[]);
+ return response as ChangeInfo[];
+ }
+ });
+ }
+
+ /**
+ * Inserts a change into _projectLookup iff it has a valid structure.
+ */
+ _maybeInsertInLookup(change: ChangeInfo): void {
+ if (change?.project && change._number) {
+ this.setInProjectLookup(change._number, change.project);
+ }
+ }
+
+ getChangeActionURL(
+ changeNum: NumericChangeId,
+ revisionId: RevisionId | undefined,
+ endpoint: string
+ ): Promise<string> {
+ return this._changeBaseURL(changeNum, revisionId).then(
+ url => url + endpoint
+ );
+ }
+
+ getChangeDetail(
+ changeNum: NumericChangeId,
+ errFn?: ErrorCallback,
+ cancelCondition?: CancelConditionCallback
+ ): Promise<ParsedChangeInfo | null | undefined> {
+ return this.getConfig(false).then(config => {
+ const optionsHex = this._getChangeOptionsHex(config);
+ return this._getChangeDetail(
+ changeNum,
+ optionsHex,
+ errFn,
+ cancelCondition
+ ).then(detail =>
+ // detail has ChangeViewChangeInfo type because the optionsHex always
+ // includes ALL_REVISIONS flag.
+ GrReviewerUpdatesParser.parse(detail as ChangeViewChangeInfo)
+ );
+ });
+ }
+
+ _getChangesOptionsHex(config?: ServerInfo) {
+ if (
+ window.DEFAULT_DETAIL_HEXES &&
+ window.DEFAULT_DETAIL_HEXES.dashboardPage
+ ) {
+ return window.DEFAULT_DETAIL_HEXES.dashboardPage;
+ }
+ const options = [
+ ListChangesOption.LABELS,
+ ListChangesOption.DETAILED_ACCOUNTS,
+ ];
+ if (config?.change && config.change.enable_attention_set) {
+ options.push(ListChangesOption.DETAILED_LABELS);
+ } else {
+ options.push(ListChangesOption.REVIEWED);
+ }
+
+ return listChangesOptionsToHex(...options);
+ }
+
+ _getChangeOptionsHex(config?: ServerInfo) {
+ if (
+ window.DEFAULT_DETAIL_HEXES &&
+ window.DEFAULT_DETAIL_HEXES.changePage &&
+ (!config || !(config.receive && config.receive.enable_signed_push))
+ ) {
+ return window.DEFAULT_DETAIL_HEXES.changePage;
+ }
+
+ // This list MUST be kept in sync with
+ // ChangeIT#changeDetailsDoesNotRequireIndex
+ const options = [
+ ListChangesOption.ALL_COMMITS,
+ ListChangesOption.ALL_REVISIONS,
+ ListChangesOption.CHANGE_ACTIONS,
+ ListChangesOption.DETAILED_LABELS,
+ ListChangesOption.DOWNLOAD_COMMANDS,
+ ListChangesOption.MESSAGES,
+ ListChangesOption.SUBMITTABLE,
+ ListChangesOption.WEB_LINKS,
+ ListChangesOption.SKIP_DIFFSTAT,
+ ];
+ if (config?.receive?.enable_signed_push) {
+ options.push(ListChangesOption.PUSH_CERTIFICATES);
+ }
+ return listChangesOptionsToHex(...options);
+ }
+
+ getDiffChangeDetail(
+ changeNum: NumericChangeId,
+ errFn?: ErrorCallback,
+ cancelCondition?: CancelConditionCallback
+ ) {
+ let optionsHex = '';
+ if (window.DEFAULT_DETAIL_HEXES?.diffPage) {
+ optionsHex = window.DEFAULT_DETAIL_HEXES.diffPage;
+ } else {
+ optionsHex = listChangesOptionsToHex(
+ ListChangesOption.ALL_COMMITS,
+ ListChangesOption.ALL_REVISIONS,
+ ListChangesOption.SKIP_DIFFSTAT
+ );
+ }
+ return this._getChangeDetail(changeNum, optionsHex, errFn, cancelCondition);
+ }
+
+ /**
+ * @param optionsHex list changes options in hex
+ */
+ _getChangeDetail(
+ changeNum: NumericChangeId,
+ optionsHex: string,
+ errFn?: ErrorCallback,
+ cancelCondition?: CancelConditionCallback
+ ): Promise<ChangeInfo | undefined | null> {
+ return this.getChangeActionURL(changeNum, undefined, '/detail').then(
+ url => {
+ const params: FetchParams = {O: optionsHex};
+ const urlWithParams = this._restApiHelper.urlWithParams(url, params);
+ const req: FetchJSONRequest = {
+ url,
+ errFn,
+ cancelCondition,
+ params,
+ fetchOptions: this._etags.getOptions(urlWithParams),
+ anonymizedUrl: '/changes/*~*/detail?O=' + optionsHex,
+ };
+ return this._restApiHelper.fetchRawJSON(req).then(response => {
+ if (response?.status === 304) {
+ return (this._restApiHelper.parsePrefixedJSON(
+ // urlWithParams already cached
+ this._etags.getCachedPayload(urlWithParams)!
+ ) as unknown) as ChangeInfo;
+ }
+
+ if (response && !response.ok) {
+ if (errFn) {
+ errFn.call(null, response);
+ } else {
+ this.dispatchEvent(
+ new CustomEvent('server-error', {
+ detail: {request: req, response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ return undefined;
+ }
+
+ if (!response) {
+ return Promise.resolve(null);
+ }
+
+ return this._restApiHelper
+ .readResponsePayload(response)
+ .then(payload => {
+ if (!payload) {
+ return null;
+ }
+ this._etags.collect(urlWithParams, response, payload.raw);
+ // TODO(TS): Why it is always change info?
+ this._maybeInsertInLookup(
+ (payload.parsed as unknown) as ChangeInfo
+ );
+
+ return (payload.parsed as unknown) as ChangeInfo;
+ });
+ });
+ }
+ );
+ }
+
+ getChangeCommitInfo(changeNum: NumericChangeId, patchNum: PatchSetNum) {
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/commit?links',
+ revision: patchNum,
+ reportEndpointAsIs: true,
+ }) as Promise<CommitInfo | undefined>;
+ }
+
+ getChangeFiles(
+ changeNum: NumericChangeId,
+ patchRange: PatchRange
+ ): Promise<FileNameToFileInfoMap | undefined> {
+ let params = undefined;
+ if (isMergeParent(patchRange.basePatchNum)) {
+ params = {parent: getParentIndex(patchRange.basePatchNum)};
+ } else if (!patchNumEquals(patchRange.basePatchNum, ParentPatchSetNum)) {
+ params = {base: patchRange.basePatchNum};
+ }
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/files',
+ revision: patchRange.patchNum,
+ params,
+ reportEndpointAsIs: true,
+ }) as Promise<FileNameToFileInfoMap | undefined>;
+ }
+
+ // TODO(TS): The output type is unclear
+ getChangeEditFiles(
+ changeNum: NumericChangeId,
+ patchRange: PatchRange
+ ): Promise<{files: FileNameToFileInfoMap} | undefined> {
+ let endpoint = '/edit?list';
+ let anonymizedEndpoint = endpoint;
+ if (patchRange.basePatchNum !== ParentPatchSetNum) {
+ endpoint += '&base=' + encodeURIComponent(`${patchRange.basePatchNum}`);
+ anonymizedEndpoint += '&base=*';
+ }
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint,
+ anonymizedEndpoint,
+ }) as Promise<{files: FileNameToFileInfoMap} | undefined>;
+ }
+
+ queryChangeFiles(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ query: string
+ ) {
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: `/files?q=${encodeURIComponent(query)}`,
+ revision: patchNum,
+ anonymizedEndpoint: '/files?q=*',
+ }) as Promise<string[] | undefined>;
+ }
+
+ getChangeOrEditFiles(
+ changeNum: NumericChangeId,
+ patchRange: PatchRange
+ ): Promise<FileNameToFileInfoMap | undefined> {
+ if (patchNumEquals(patchRange.patchNum, EditPatchSetNum)) {
+ return this.getChangeEditFiles(changeNum, patchRange).then(
+ res => res && res.files
+ );
+ }
+ return this.getChangeFiles(changeNum, patchRange);
+ }
+
+ getChangeRevisionActions(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<ActionNameToActionInfoMap | undefined> {
+ const req: FetchChangeJSON = {
+ changeNum,
+ endpoint: '/actions',
+ revision: patchNum,
+ reportEndpointAsIs: true,
+ };
+ return this._getChangeURLAndFetch(req) as Promise<
+ ActionNameToActionInfoMap | undefined
+ >;
+ }
+
+ getChangeSuggestedReviewers(
+ changeNum: NumericChangeId,
+ inputVal: string,
+ errFn?: ErrorCallback
+ ) {
+ return this._getChangeSuggestedGroup(
+ ReviewerState.REVIEWER,
+ changeNum,
+ inputVal,
+ errFn
+ );
+ }
+
+ getChangeSuggestedCCs(
+ changeNum: NumericChangeId,
+ inputVal: string,
+ errFn?: ErrorCallback
+ ) {
+ return this._getChangeSuggestedGroup(
+ ReviewerState.CC,
+ changeNum,
+ inputVal,
+ errFn
+ );
+ }
+
+ _getChangeSuggestedGroup(
+ reviewerState: ReviewerState,
+ changeNum: NumericChangeId,
+ inputVal: string,
+ errFn?: ErrorCallback
+ ): Promise<SuggestedReviewerInfo[] | undefined> {
+ // More suggestions may obscure content underneath in the reply dialog,
+ // see issue 10793.
+ const params: QuerySuggestedReviewersParams = {
+ n: 6,
+ 'reviewer-state': reviewerState,
+ };
+ if (inputVal) {
+ params.q = inputVal;
+ }
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/suggest_reviewers',
+ errFn,
+ params,
+ reportEndpointAsIs: true,
+ }) as Promise<SuggestedReviewerInfo[] | undefined>;
+ }
+
+ getChangeIncludedIn(
+ changeNum: NumericChangeId
+ ): Promise<IncludedInInfo | undefined> {
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/in',
+ reportEndpointAsIs: true,
+ }) as Promise<IncludedInInfo | undefined>;
+ }
+
+ _computeFilter(filter: string) {
+ if (filter?.startsWith('^')) {
+ filter = '&r=' + encodeURIComponent(filter);
+ } else if (filter) {
+ filter = '&m=' + encodeURIComponent(filter);
+ } else {
+ filter = '';
+ }
+ return filter;
+ }
+
+ _getGroupsUrl(filter: string, groupsPerPage: number, offset?: number) {
+ offset = offset || 0;
+
+ return (
+ `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
+ this._computeFilter(filter)
+ );
+ }
+
+ _getReposUrl(
+ filter: string | undefined,
+ reposPerPage: number,
+ offset?: number
+ ) {
+ const defaultFilter = 'state:active OR state:read-only';
+ const namePartDelimiters = /[@.\-\s/_]/g;
+ offset = offset || 0;
+
+ if (filter && !filter.includes(':') && filter.match(namePartDelimiters)) {
+ // The query language specifies hyphens as operators. Split the string
+ // by hyphens and 'AND' the parts together as 'inname:' queries.
+ // If the filter includes a semicolon, the user is using a more complex
+ // query so we trust them and don't do any magic under the hood.
+ const originalFilter = filter;
+ filter = '';
+ originalFilter.split(namePartDelimiters).forEach(part => {
+ if (part) {
+ filter += (filter === '' ? 'inname:' : ' AND inname:') + part;
+ }
+ });
+ }
+ // Check if filter is now empty which could be either because the user did
+ // not provide it or because the user provided only a split character.
+ if (!filter) {
+ filter = defaultFilter;
+ }
+
+ filter = filter.trim();
+ const encodedFilter = encodeURIComponent(filter);
+
+ return (
+ `/projects/?n=${reposPerPage + 1}&S=${offset}` + `&query=${encodedFilter}`
+ );
+ }
+
+ invalidateGroupsCache() {
+ this._restApiHelper.invalidateFetchPromisesPrefix('/groups/?');
+ }
+
+ invalidateReposCache() {
+ this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
+ }
+
+ invalidateAccountsCache() {
+ this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
+ }
+
+ getGroups(filter: string, groupsPerPage: number, offset?: number) {
+ const url = this._getGroupsUrl(filter, groupsPerPage, offset);
+
+ return this._fetchSharedCacheURL({
+ url,
+ anonymizedUrl: '/groups/?*',
+ }) as Promise<GroupNameToGroupInfoMap | undefined>;
+ }
+
+ getRepos(
+ filter: string | undefined,
+ reposPerPage: number,
+ offset?: number
+ ): Promise<ProjectInfoWithName[] | undefined> {
+ const url = this._getReposUrl(filter, reposPerPage, offset);
+
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return this._fetchSharedCacheURL({
+ url, // The url contains query,so the response is an array, not map
+ anonymizedUrl: '/projects/?*',
+ }) as Promise<ProjectInfoWithName[] | undefined>;
+ }
+
+ setRepoHead(repo: RepoName, ref: GitRef) {
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/projects/${encodeURIComponent(repo)}/HEAD`,
+ body: {ref},
+ anonymizedUrl: '/projects/*/HEAD',
+ });
+ }
+
+ getRepoBranches(
+ filter: string,
+ repo: RepoName,
+ reposBranchesPerPage: number,
+ offset?: number,
+ errFn?: ErrorCallback
+ ): Promise<BranchInfo[] | undefined> {
+ offset = offset || 0;
+ const count = reposBranchesPerPage + 1;
+ filter = this._computeFilter(filter);
+ const encodedRepo = encodeURIComponent(repo);
+ const url = `/projects/${encodedRepo}/branches?n=${count}&S=${offset}${filter}`;
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return this._restApiHelper.fetchJSON({
+ url,
+ errFn,
+ anonymizedUrl: '/projects/*/branches?*',
+ }) as Promise<BranchInfo[] | undefined>;
+ }
+
+ getRepoTags(
+ filter: string,
+ repo: RepoName,
+ reposTagsPerPage: number,
+ offset?: number,
+ errFn?: ErrorCallback
+ ) {
+ offset = offset || 0;
+ const encodedRepo = encodeURIComponent(repo);
+ const n = reposTagsPerPage + 1;
+ const encodedFilter = this._computeFilter(filter);
+ const url =
+ `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + encodedFilter;
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return (this._restApiHelper.fetchJSON({
+ url,
+ errFn,
+ anonymizedUrl: '/projects/*/tags',
+ }) as unknown) as Promise<TagInfo[]>;
+ }
+
+ getPlugins(
+ filter: string,
+ pluginsPerPage: number,
+ offset?: number,
+ errFn?: ErrorCallback
+ ): Promise<{[pluginName: string]: PluginInfo} | undefined> {
+ offset = offset || 0;
+ const encodedFilter = this._computeFilter(filter);
+ const n = pluginsPerPage + 1;
+ const url = `/plugins/?all&n=${n}&S=${offset}${encodedFilter}`;
+ return this._restApiHelper.fetchJSON({
+ url,
+ errFn,
+ anonymizedUrl: '/plugins/?all',
+ });
+ }
+
+ getRepoAccessRights(
+ repoName: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<ProjectAccessInfo | undefined> {
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return this._restApiHelper.fetchJSON({
+ url: `/projects/${encodeURIComponent(repoName)}/access`,
+ errFn,
+ anonymizedUrl: '/projects/*/access',
+ }) as Promise<ProjectAccessInfo | undefined>;
+ }
+
+ setRepoAccessRights(
+ repoName: RepoName,
+ repoInfo: ProjectAccessInput
+ ): Promise<Response> {
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return this._restApiHelper.send({
+ method: HttpMethod.POST,
+ url: `/projects/${encodeURIComponent(repoName)}/access`,
+ body: repoInfo,
+ anonymizedUrl: '/projects/*/access',
+ });
+ }
+
+ setRepoAccessRightsForReview(
+ projectName: RepoName,
+ projectInfo: ProjectAccessInput
+ ): Promise<ChangeInfo> {
+ return (this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: `/projects/${encodeURIComponent(projectName)}/access:review`,
+ body: projectInfo,
+ parseResponse: true,
+ anonymizedUrl: '/projects/*/access:review',
+ }) as unknown) as Promise<ChangeInfo>;
+ }
+
+ getSuggestedGroups(
+ inputVal: string,
+ n?: number,
+ errFn?: ErrorCallback
+ ): Promise<GroupNameToGroupInfoMap | undefined> {
+ const params: QueryGroupsParams = {s: inputVal};
+ if (n) {
+ params.n = n;
+ }
+ return this._restApiHelper.fetchJSON({
+ url: '/groups/',
+ errFn,
+ params,
+ reportUrlAsIs: true,
+ }) as Promise<GroupNameToGroupInfoMap | undefined>;
+ }
+
+ getSuggestedProjects(
+ inputVal: string,
+ n?: number,
+ errFn?: ErrorCallback
+ ): Promise<NameToProjectInfoMap | undefined> {
+ const params = {
+ m: inputVal,
+ n: MAX_PROJECT_RESULTS,
+ type: 'ALL',
+ };
+ if (n) {
+ params.n = n;
+ }
+ return this._restApiHelper.fetchJSON({
+ url: '/projects/',
+ errFn,
+ params,
+ reportUrlAsIs: true,
+ });
+ }
+
+ getSuggestedAccounts(
+ inputVal: string,
+ n?: number,
+ errFn?: ErrorCallback
+ ): Promise<AccountInfo[] | undefined> {
+ if (!inputVal) {
+ return Promise.resolve([]);
+ }
+ const params: QueryAccountsParams = {suggest: null, q: inputVal};
+ if (n) {
+ params.n = n;
+ }
+ return this._restApiHelper.fetchJSON({
+ url: '/accounts/',
+ errFn,
+ params,
+ anonymizedUrl: '/accounts/?n=*',
+ }) as Promise<AccountInfo[] | undefined>;
+ }
+
+ addChangeReviewer(
+ changeNum: NumericChangeId,
+ reviewerID: AccountId | EmailAddress | GroupId
+ ) {
+ return this._sendChangeReviewerRequest(
+ HttpMethod.POST,
+ changeNum,
+ reviewerID
+ );
+ }
+
+ removeChangeReviewer(
+ changeNum: NumericChangeId,
+ reviewerID: AccountId | EmailAddress | GroupId
+ ) {
+ return this._sendChangeReviewerRequest(
+ HttpMethod.DELETE,
+ changeNum,
+ reviewerID
+ );
+ }
+
+ _sendChangeReviewerRequest(
+ method: HttpMethod.POST | HttpMethod.DELETE,
+ changeNum: NumericChangeId,
+ reviewerID: AccountId | EmailAddress | GroupId
+ ) {
+ return this.getChangeActionURL(changeNum, undefined, '/reviewers').then(
+ url => {
+ let body;
+ switch (method) {
+ case HttpMethod.POST:
+ body = {reviewer: reviewerID};
+ break;
+ case HttpMethod.DELETE:
+ url += '/' + encodeURIComponent(reviewerID);
+ break;
+ default:
+ assertNever(method, `Unsupported HTTP method: ${method}`);
+ }
+
+ return this._restApiHelper.send({method, url, body});
+ }
+ );
+ }
+
+ getRelatedChanges(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<RelatedChangesInfo | undefined> {
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/related',
+ revision: patchNum,
+ reportEndpointAsIs: true,
+ }) as Promise<RelatedChangesInfo | undefined>;
+ }
+
+ getChangesSubmittedTogether(
+ changeNum: NumericChangeId
+ ): Promise<SubmittedTogetherInfo | undefined> {
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/submitted_together?o=NON_VISIBLE_CHANGES',
+ reportEndpointAsIs: true,
+ }) as Promise<SubmittedTogetherInfo | undefined>;
+ }
+
+ getChangeConflicts(
+ changeNum: NumericChangeId
+ ): Promise<ChangeInfo[] | undefined> {
+ const options = listChangesOptionsToHex(
+ ListChangesOption.CURRENT_REVISION,
+ ListChangesOption.CURRENT_COMMIT
+ );
+ const params = {
+ O: options,
+ q: `status:open conflicts:${changeNum}`,
+ };
+ return this._restApiHelper.fetchJSON({
+ url: '/changes/',
+ params,
+ anonymizedUrl: '/changes/conflicts:*',
+ }) as Promise<ChangeInfo[] | undefined>;
+ }
+
+ getChangeCherryPicks(
+ project: RepoName,
+ changeID: ChangeId,
+ changeNum: NumericChangeId
+ ): Promise<ChangeInfo[] | undefined> {
+ const options = listChangesOptionsToHex(
+ ListChangesOption.CURRENT_REVISION,
+ ListChangesOption.CURRENT_COMMIT
+ );
+ const query = [
+ `project:${project}`,
+ `change:${changeID}`,
+ `-change:${changeNum}`,
+ '-is:abandoned',
+ ].join(' ');
+ const params = {
+ O: options,
+ q: query,
+ };
+ return this._restApiHelper.fetchJSON({
+ url: '/changes/',
+ params,
+ anonymizedUrl: '/changes/change:*',
+ }) as Promise<ChangeInfo[] | undefined>;
+ }
+
+ getChangesWithSameTopic(
+ topic: string,
+ changeNum: NumericChangeId
+ ): Promise<ChangeInfo[] | undefined> {
+ const options = listChangesOptionsToHex(
+ ListChangesOption.LABELS,
+ ListChangesOption.CURRENT_REVISION,
+ ListChangesOption.CURRENT_COMMIT,
+ ListChangesOption.DETAILED_LABELS
+ );
+ const query = [
+ 'status:open',
+ `-change:${changeNum}`,
+ `topic:"${topic}"`,
+ ].join(' ');
+ const params = {
+ O: options,
+ q: query,
+ };
+ return this._restApiHelper.fetchJSON({
+ url: '/changes/',
+ params,
+ anonymizedUrl: '/changes/topic:*',
+ }) as Promise<ChangeInfo[] | undefined>;
+ }
+
+ getReviewedFiles(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<string[] | undefined> {
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/files?reviewed',
+ revision: patchNum,
+ reportEndpointAsIs: true,
+ }) as Promise<string[] | undefined>;
+ }
+
+ saveFileReviewed(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ path: string,
+ reviewed: boolean
+ ): Promise<Response>;
+
+ saveFileReviewed(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ path: string,
+ reviewed: boolean,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ saveFileReviewed(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ path: string,
+ reviewed: boolean,
+ errFn?: ErrorCallback
+ ) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: reviewed ? HttpMethod.PUT : HttpMethod.DELETE,
+ patchNum,
+ endpoint: `/files/${encodeURIComponent(path)}/reviewed`,
+ errFn,
+ anonymizedEndpoint: '/files/*/reviewed',
+ });
+ }
+
+ saveChangeReview(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ review: ReviewInput
+ ): Promise<Response>;
+
+ saveChangeReview(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ review: ReviewInput,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ saveChangeReview(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ review: ReviewInput,
+ errFn?: ErrorCallback
+ ) {
+ const promises: [Promise<void>, Promise<string>] = [
+ this.awaitPendingDiffDrafts(),
+ this.getChangeActionURL(changeNum, patchNum, '/review'),
+ ];
+ return Promise.all(promises).then(([, url]) =>
+ this._restApiHelper.send({
+ method: HttpMethod.POST,
+ url,
+ body: review,
+ errFn,
+ })
+ );
+ }
+
+ getChangeEdit(
+ changeNum: NumericChangeId,
+ downloadCommands?: boolean
+ ): Promise<false | EditInfo | undefined> {
+ const params = downloadCommands ? {'download-commands': true} : undefined;
+ return this.getLoggedIn().then(loggedIn => {
+ if (!loggedIn) {
+ return Promise.resolve(false);
+ }
+ return this._getChangeURLAndFetch(
+ {
+ changeNum,
+ endpoint: '/edit/',
+ params,
+ reportEndpointAsIs: true,
+ },
+ true
+ ) as Promise<EditInfo | false | undefined>;
+ });
+ }
+
+ createChange(
+ project: RepoName,
+ branch: BranchName,
+ subject: string,
+ topic?: string,
+ isPrivate?: boolean,
+ workInProgress?: boolean,
+ baseChange?: ChangeId,
+ baseCommit?: string
+ ) {
+ return (this._restApiHelper.send({
+ method: HttpMethod.POST,
+ url: '/changes/',
+ body: {
+ project,
+ branch,
+ subject,
+ topic,
+ is_private: isPrivate,
+ work_in_progress: workInProgress,
+ base_change: baseChange,
+ base_commit: baseCommit,
+ },
+ parseResponse: true,
+ reportUrlAsIs: true,
+ }) as unknown) as Promise<ChangeInfo | undefined>;
+ }
+
+ getFileContent(
+ changeNum: NumericChangeId,
+ path: string,
+ patchNum: PatchSetNum
+ ): Promise<Response | Base64FileContent | undefined> {
+ // 404s indicate the file does not exist yet in the revision, so suppress
+ // them.
+ const suppress404s: ErrorCallback = res => {
+ if (res?.status !== 404) {
+ this.dispatchEvent(
+ new CustomEvent('server-error', {
+ detail: {res},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ return res;
+ };
+ const promise = patchNumEquals(patchNum, EditPatchSetNum)
+ ? this._getFileInChangeEdit(changeNum, path)
+ : this._getFileInRevision(changeNum, path, patchNum, suppress404s);
+
+ return promise.then(res => {
+ if (!res || !res.ok) {
+ return res;
+ }
+
+ // The file type (used for syntax highlighting) is identified in the
+ // X-FYI-Content-Type header of the response.
+ const type = res.headers.get('X-FYI-Content-Type');
+ return this.getResponseObject(res).then(content => {
+ const strContent = (content as unknown) as string | null;
+ return {content: strContent, type, ok: true};
+ });
+ });
+ }
+
+ /**
+ * Gets a file in a specific change and revision.
+ */
+ _getFileInRevision(
+ changeNum: NumericChangeId,
+ path: string,
+ patchNum: PatchSetNum,
+ errFn?: ErrorCallback
+ ) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.GET,
+ patchNum,
+ endpoint: `/files/${encodeURIComponent(path)}/content`,
+ errFn,
+ headers: {Accept: 'application/json'},
+ anonymizedEndpoint: '/files/*/content',
+ });
+ }
+
+ /**
+ * Gets a file in a change edit.
+ */
+ _getFileInChangeEdit(changeNum: NumericChangeId, path: string) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.GET,
+ endpoint: '/edit/' + encodeURIComponent(path),
+ headers: {Accept: 'application/json'},
+ anonymizedEndpoint: '/edit/*',
+ });
+ }
+
+ rebaseChangeEdit(changeNum: NumericChangeId) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.POST,
+ endpoint: '/edit:rebase',
+ reportEndpointAsIs: true,
+ });
+ }
+
+ deleteChangeEdit(changeNum: NumericChangeId) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.DELETE,
+ endpoint: '/edit',
+ reportEndpointAsIs: true,
+ });
+ }
+
+ restoreFileInChangeEdit(changeNum: NumericChangeId, restore_path: string) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.POST,
+ endpoint: '/edit',
+ body: {restore_path},
+ reportEndpointAsIs: true,
+ });
+ }
+
+ renameFileInChangeEdit(
+ changeNum: NumericChangeId,
+ old_path: string,
+ new_path: string
+ ) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.POST,
+ endpoint: '/edit',
+ body: {old_path, new_path},
+ reportEndpointAsIs: true,
+ });
+ }
+
+ deleteFileInChangeEdit(changeNum: NumericChangeId, path: string) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.DELETE,
+ endpoint: '/edit/' + encodeURIComponent(path),
+ anonymizedEndpoint: '/edit/*',
+ });
+ }
+
+ saveChangeEdit(changeNum: NumericChangeId, path: string, contents: string) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.PUT,
+ endpoint: '/edit/' + encodeURIComponent(path),
+ body: contents,
+ contentType: 'text/plain',
+ anonymizedEndpoint: '/edit/*',
+ });
+ }
+
+ saveFileUploadChangeEdit(
+ changeNum: NumericChangeId,
+ path: string,
+ content: string
+ ) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.PUT,
+ endpoint: '/edit/' + encodeURIComponent(path),
+ body: {binary_content: content},
+ anonymizedEndpoint: '/edit/*',
+ });
+ }
+
+ getRobotCommentFixPreview(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ fixId: FixId
+ ): Promise<FilePathToDiffInfoMap | undefined> {
+ return this._getChangeURLAndFetch({
+ changeNum,
+ revision: patchNum,
+ endpoint: `/fixes/${encodeURIComponent(fixId)}/preview`,
+ reportEndpointAsId: true,
+ }) as Promise<FilePathToDiffInfoMap | undefined>;
+ }
+
+ applyFixSuggestion(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ fixId: string
+ ): Promise<Response> {
+ return this._getChangeURLAndSend({
+ method: HttpMethod.POST,
+ changeNum,
+ patchNum,
+ endpoint: `/fixes/${encodeURIComponent(fixId)}/apply`,
+ reportEndpointAsId: true,
+ });
+ }
+
+ // Deprecated, prefer to use putChangeCommitMessage instead.
+ saveChangeCommitMessageEdit(changeNum: NumericChangeId, message: string) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.PUT,
+ endpoint: '/edit:message',
+ body: {message},
+ reportEndpointAsIs: true,
+ });
+ }
+
+ publishChangeEdit(changeNum: NumericChangeId) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.POST,
+ endpoint: '/edit:publish',
+ reportEndpointAsIs: true,
+ });
+ }
+
+ putChangeCommitMessage(changeNum: NumericChangeId, message: string) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.PUT,
+ endpoint: '/message',
+ body: {message},
+ reportEndpointAsIs: true,
+ });
+ }
+
+ deleteChangeCommitMessage(
+ changeNum: NumericChangeId,
+ messageId: ChangeMessageId
+ ) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.DELETE,
+ endpoint: `/messages/${messageId}`,
+ reportEndpointAsIs: true,
+ });
+ }
+
+ saveChangeStarred(
+ changeNum: NumericChangeId,
+ starred: boolean
+ ): Promise<Response> {
+ // Some servers may require the project name to be provided
+ // alongside the change number, so resolve the project name
+ // first.
+ return this.getFromProjectLookup(changeNum).then(project => {
+ const encodedRepoName = project ? encodeURIComponent(project) + '~' : '';
+ const url = `/accounts/self/starred.changes/${encodedRepoName}${changeNum}`;
+ return this._restApiHelper.send({
+ method: starred ? HttpMethod.PUT : HttpMethod.DELETE,
+ url,
+ anonymizedUrl: '/accounts/self/starred.changes/*',
+ });
+ });
+ }
+
+ saveChangeReviewed(
+ changeNum: NumericChangeId,
+ reviewed: boolean
+ ): Promise<Response | undefined> {
+ return this.getConfig().then(config => {
+ const isAttentionSetEnabled =
+ !!config && !!config.change && config.change.enable_attention_set;
+ if (isAttentionSetEnabled) return;
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.PUT,
+ endpoint: reviewed ? '/reviewed' : '/unreviewed',
+ });
+ });
+ }
+
+ send(
+ method: HttpMethod,
+ url: string,
+ body?: RequestPayload,
+ errFn?: undefined,
+ contentType?: string,
+ headers?: Record<string, string>
+ ): Promise<Response>;
+
+ send(
+ method: HttpMethod,
+ url: string,
+ body: RequestPayload | undefined,
+ errFn: ErrorCallback,
+ contentType?: string,
+ headers?: Record<string, string>
+ ): Promise<Response | undefined>;
+
+ /**
+ * Public version of the _restApiHelper.send method preserved for plugins.
+ *
+ * @param body passed as null sometimes
+ * and also apparently a number. TODO (beckysiegel) remove need for
+ * number at least.
+ */
+ send(
+ method: HttpMethod,
+ url: string,
+ body?: RequestPayload,
+ errFn?: ErrorCallback,
+ contentType?: string,
+ headers?: Record<string, string>
+ ): Promise<Response | undefined> {
+ return this._restApiHelper.send({
+ method,
+ url,
+ body,
+ errFn,
+ contentType,
+ headers,
+ });
+ }
+
+ /**
+ * @param basePatchNum Negative values specify merge parent
+ * index.
+ * @param whitespace the ignore-whitespace level for the diff
+ * algorithm.
+ */
+ getDiff(
+ changeNum: NumericChangeId,
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ path: string,
+ whitespace?: IgnoreWhitespaceType,
+ errFn?: ErrorCallback
+ ) {
+ const params: GetDiffParams = {
+ context: 'ALL',
+ intraline: null,
+ whitespace: whitespace || IgnoreWhitespaceType.IGNORE_NONE,
+ };
+ if (isMergeParent(basePatchNum)) {
+ params.parent = getParentIndex(basePatchNum);
+ } else if (!patchNumEquals(basePatchNum, ParentPatchSetNum)) {
+ // TODO (TS): fix as PatchSetNum in the condition above
+ params.base = basePatchNum;
+ }
+ const endpoint = `/files/${encodeURIComponent(path)}/diff`;
+ const req: FetchChangeJSON = {
+ changeNum,
+ endpoint,
+ revision: patchNum,
+ errFn,
+ params,
+ anonymizedEndpoint: '/files/*/diff',
+ };
+
+ // Invalidate the cache if its edit patch to make sure we always get latest.
+ if (patchNum === EditPatchSetNum) {
+ if (!req.fetchOptions) req.fetchOptions = {};
+ if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
+ req.fetchOptions.headers.append('Cache-Control', 'no-cache');
+ }
+
+ return this._getChangeURLAndFetch(req) as Promise<DiffInfo | undefined>;
+ }
+
+ getDiffComments(
+ changeNum: NumericChangeId
+ ): Promise<PathToCommentsInfoMap | undefined>;
+
+ getDiffComments(
+ changeNum: NumericChangeId,
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ path: string
+ ): Promise<GetDiffCommentsOutput>;
+
+ getDiffComments(
+ changeNum: NumericChangeId,
+ basePatchNum?: PatchSetNum,
+ patchNum?: PatchSetNum,
+ path?: string
+ ) {
+ if (!basePatchNum && !patchNum && !path) {
+ return this._getDiffComments(changeNum, '/comments');
+ }
+ return this._getDiffComments(
+ changeNum,
+ '/comments',
+ basePatchNum,
+ patchNum,
+ path
+ );
+ }
+
+ getDiffRobotComments(
+ changeNum: NumericChangeId
+ ): Promise<PathToRobotCommentsInfoMap | undefined>;
+
+ getDiffRobotComments(
+ changeNum: NumericChangeId,
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ path: string
+ ): Promise<GetDiffRobotCommentsOutput>;
+
+ getDiffRobotComments(
+ changeNum: NumericChangeId,
+ basePatchNum?: PatchSetNum,
+ patchNum?: PatchSetNum,
+ path?: string
+ ) {
+ if (!basePatchNum && !patchNum && !path) {
+ return this._getDiffComments(changeNum, '/robotcomments');
+ }
+
+ return this._getDiffComments(
+ changeNum,
+ '/robotcomments',
+ basePatchNum,
+ patchNum,
+ path
+ );
+ }
+
+ /**
+ * If the user is logged in, fetch the user's draft diff comments. If there
+ * is no logged in user, the request is not made and the promise yields an
+ * empty object.
+ */
+ getDiffDrafts(
+ changeNum: NumericChangeId
+ ): Promise<PathToCommentsInfoMap | undefined>;
+
+ getDiffDrafts(
+ changeNum: NumericChangeId,
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ path: string
+ ): Promise<GetDiffCommentsOutput>;
+
+ getDiffDrafts(
+ changeNum: NumericChangeId,
+ basePatchNum?: PatchSetNum,
+ patchNum?: PatchSetNum,
+ path?: string
+ ) {
+ return this.getLoggedIn().then(loggedIn => {
+ if (!loggedIn) {
+ return {};
+ }
+ if (!basePatchNum && !patchNum && !path) {
+ return this._getDiffComments(changeNum, '/drafts');
+ }
+ return this._getDiffComments(
+ changeNum,
+ '/drafts',
+ basePatchNum,
+ patchNum,
+ path
+ );
+ });
+ }
+
+ _setRange(comments: CommentInfo[], comment: CommentInfo) {
+ if (comment.in_reply_to && !comment.range) {
+ for (let i = 0; i < comments.length; i++) {
+ if (comments[i].id === comment.in_reply_to) {
+ comment.range = comments[i].range;
+ break;
+ }
+ }
+ }
+ return comment;
+ }
+
+ _setRanges(comments?: CommentInfo[]) {
+ comments = comments || [];
+ comments.sort(
+ (a, b) => parseDate(a.updated).valueOf() - parseDate(b.updated).valueOf()
+ );
+ for (const comment of comments) {
+ this._setRange(comments, comment);
+ }
+ return comments;
+ }
+
+ _getDiffComments(
+ changeNum: NumericChangeId,
+ endpoint: '/comments' | '/drafts'
+ ): Promise<PathToCommentsInfoMap | undefined>;
+
+ _getDiffComments(
+ changeNum: NumericChangeId,
+ endpoint: '/robotcomments'
+ ): Promise<PathToRobotCommentsInfoMap | undefined>;
+
+ _getDiffComments(
+ changeNum: NumericChangeId,
+ endpoint: '/comments' | '/drafts',
+ basePatchNum?: PatchSetNum,
+ patchNum?: PatchSetNum,
+ path?: string
+ ): Promise<GetDiffCommentsOutput>;
+
+ _getDiffComments(
+ changeNum: NumericChangeId,
+ endpoint: '/robotcomments',
+ basePatchNum?: PatchSetNum,
+ patchNum?: PatchSetNum,
+ path?: string
+ ): Promise<GetDiffRobotCommentsOutput>;
+
+ _getDiffComments(
+ changeNum: NumericChangeId,
+ endpoint: string,
+ basePatchNum?: PatchSetNum,
+ patchNum?: PatchSetNum,
+ path?: string
+ ): Promise<
+ | GetDiffCommentsOutput
+ | GetDiffRobotCommentsOutput
+ | PathToCommentsInfoMap
+ | PathToRobotCommentsInfoMap
+ | undefined
+ > {
+ /**
+ * Fetches the comments for a given patchNum.
+ * Helper function to make promises more legible.
+ */
+ // We don't want to add accept header, since preloading of comments is
+ // working only without accept header.
+ const noAcceptHeader = true;
+ const fetchComments = (patchNum?: PatchSetNum) =>
+ this._getChangeURLAndFetch(
+ {
+ changeNum,
+ endpoint,
+ revision: patchNum,
+ reportEndpointAsIs: true,
+ },
+ noAcceptHeader
+ ) as Promise<
+ PathToCommentsInfoMap | PathToRobotCommentsInfoMap | undefined
+ >;
+
+ if (!basePatchNum && !patchNum && !path) {
+ return fetchComments();
+ }
+ function onlyParent(c: CommentInfo) {
+ return c.side === CommentSide.PARENT;
+ }
+ function withoutParent(c: CommentInfo) {
+ return c.side !== CommentSide.PARENT;
+ }
+ function setPath(c: CommentInfo) {
+ c.path = path;
+ }
+
+ const promises = [];
+ let comments: CommentInfo[];
+ let baseComments: CommentInfo[];
+ let fetchPromise;
+ fetchPromise = fetchComments(patchNum).then(response => {
+ comments = (response && path && response[path]) || [];
+ // TODO(kaspern): Implement this on in the backend so this can
+ // be removed.
+ // Sort comments by date so that parent ranges can be propagated
+ // in a single pass.
+ comments = this._setRanges(comments);
+
+ if (basePatchNum === ParentPatchSetNum) {
+ baseComments = comments.filter(onlyParent);
+ baseComments.forEach(setPath);
+ }
+ comments = comments.filter(withoutParent);
+
+ comments.forEach(setPath);
+ });
+ promises.push(fetchPromise);
+
+ if (basePatchNum !== ParentPatchSetNum) {
+ fetchPromise = fetchComments(basePatchNum).then(response => {
+ baseComments = ((response && path && response[path]) || []).filter(
+ withoutParent
+ );
+ baseComments = this._setRanges(baseComments);
+ baseComments.forEach(setPath);
+ });
+ promises.push(fetchPromise);
+ }
+
+ return Promise.all(promises).then(() =>
+ Promise.resolve({
+ baseComments,
+ comments,
+ })
+ );
+ }
+
+ _getDiffCommentsFetchURL(
+ changeNum: NumericChangeId,
+ endpoint: string,
+ patchNum?: RevisionId
+ ) {
+ return this._changeBaseURL(changeNum, patchNum).then(url => url + endpoint);
+ }
+
+ getPortedComments(
+ changeNum: NumericChangeId,
+ revision: RevisionId
+ ): Promise<PathToCommentsInfoMap | undefined> {
+ // maintaining a custom error function so that errors do not surface in UI
+ const errFn: ErrorCallback = (response?: Response | null) => {
+ if (response)
+ console.info(`Fetching ported comments failed, ${response.status}`);
+ };
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/ported_comments/',
+ revision,
+ errFn,
+ });
+ }
+
+ getPortedDrafts(
+ changeNum: NumericChangeId,
+ revision: RevisionId
+ ): Promise<PathToCommentsInfoMap | undefined> {
+ // maintaining a custom error function so that errors do not surface in UI
+ const errFn: ErrorCallback = (response?: Response | null) => {
+ if (response)
+ console.info(`Fetching ported drafts failed, ${response.status}`);
+ };
+ return this.getLoggedIn().then(loggedIn => {
+ if (!loggedIn) return {};
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/ported_drafts/',
+ revision,
+ errFn,
+ });
+ });
+ }
+
+ saveDiffDraft(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ draft: CommentInput
+ ) {
+ return this._sendDiffDraftRequest(
+ HttpMethod.PUT,
+ changeNum,
+ patchNum,
+ draft
+ );
+ }
+
+ deleteDiffDraft(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ draft: {id: UrlEncodedCommentId}
+ ) {
+ return this._sendDiffDraftRequest(
+ HttpMethod.DELETE,
+ changeNum,
+ patchNum,
+ draft
+ );
+ }
+
+ /**
+ * @returns Whether there are pending diff draft sends.
+ */
+ hasPendingDiffDrafts(): number {
+ const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
+ return promises && promises.length;
+ }
+
+ /**
+ * @returns A promise that resolves when all pending
+ * diff draft sends have resolved.
+ */
+ awaitPendingDiffDrafts(): Promise<void> {
+ return Promise.all(
+ this._pendingRequests[Requests.SEND_DIFF_DRAFT] || []
+ ).then(() => {
+ this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
+ });
+ }
+
+ _sendDiffDraftRequest(
+ method: HttpMethod.PUT,
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ draft: CommentInput
+ ): Promise<Response>;
+
+ _sendDiffDraftRequest(
+ method: HttpMethod.GET | HttpMethod.DELETE,
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ draft: {id?: UrlEncodedCommentId}
+ ): Promise<Response>;
+
+ _sendDiffDraftRequest(
+ method: HttpMethod,
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ draft: CommentInput | {id: UrlEncodedCommentId}
+ ): Promise<Response> {
+ const isCreate = !draft.id && method === HttpMethod.PUT;
+ let endpoint = '/drafts';
+ let anonymizedEndpoint = endpoint;
+ if (draft.id) {
+ endpoint += `/${draft.id}`;
+ anonymizedEndpoint += '/*';
+ }
+ let body;
+ if (method === HttpMethod.PUT) {
+ body = draft;
+ }
+
+ if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
+ this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
+ }
+
+ const req = {
+ changeNum,
+ method,
+ patchNum,
+ endpoint,
+ body,
+ anonymizedEndpoint,
+ };
+
+ const promise = this._getChangeURLAndSend(req);
+ this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
+
+ if (isCreate) {
+ return this._failForCreate200(promise);
+ }
+
+ return promise;
+ }
+
+ getCommitInfo(
+ project: RepoName,
+ commit: CommitId
+ ): Promise<CommitInfo | undefined> {
+ return this._restApiHelper.fetchJSON({
+ url:
+ '/projects/' +
+ encodeURIComponent(project) +
+ '/commits/' +
+ encodeURIComponent(commit),
+ anonymizedUrl: '/projects/*/comments/*',
+ }) as Promise<CommitInfo | undefined>;
+ }
+
+ _fetchB64File(url: string): Promise<Base64File> {
+ return this._restApiHelper
+ .fetch({url: getBaseUrl() + url})
+ .then(response => {
+ if (!response.ok) {
+ return Promise.reject(new Error(response.statusText));
+ }
+ const type = response.headers.get('X-FYI-Content-Type');
+ return response.text().then(text => {
+ return {body: text, type};
+ });
+ });
+ }
+
+ getB64FileContents(
+ changeId: NumericChangeId,
+ patchNum: RevisionId,
+ path: string,
+ parentIndex?: number
+ ) {
+ const parent =
+ typeof parentIndex === 'number' ? `?parent=${parentIndex}` : '';
+ return this._changeBaseURL(changeId, patchNum).then(url => {
+ url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
+ return this._fetchB64File(url);
+ });
+ }
+
+ getImagesForDiff(
+ changeNum: NumericChangeId,
+ diff: DiffInfo,
+ patchRange: PatchRange
+ ): Promise<ImagesForDiff> {
+ let promiseA;
+ let promiseB;
+
+ if (diff.meta_a?.content_type.startsWith('image/')) {
+ if (patchRange.basePatchNum === ParentPatchSetNum) {
+ // Note: we only attempt to get the image from the first parent.
+ promiseA = this.getB64FileContents(
+ changeNum,
+ patchRange.patchNum,
+ diff.meta_a.name,
+ 1
+ );
+ } else {
+ promiseA = this.getB64FileContents(
+ changeNum,
+ patchRange.basePatchNum,
+ diff.meta_a.name
+ );
+ }
+ } else {
+ promiseA = Promise.resolve(null);
+ }
+
+ if (diff.meta_b?.content_type.startsWith('image/')) {
+ promiseB = this.getB64FileContents(
+ changeNum,
+ patchRange.patchNum,
+ diff.meta_b.name
+ );
+ } else {
+ promiseB = Promise.resolve(null);
+ }
+
+ return Promise.all([promiseA, promiseB]).then(results => {
+ // Sometimes the server doesn't send back the content type.
+ const baseImage: Base64ImageFile | null = results[0]
+ ? {
+ ...results[0],
+ _expectedType: diff.meta_a.content_type,
+ _name: diff.meta_a.name,
+ }
+ : null;
+ const revisionImage: Base64ImageFile | null = results[1]
+ ? {
+ ...results[1],
+ _expectedType: diff.meta_b.content_type,
+ _name: diff.meta_b.name,
+ }
+ : null;
+ const imagesForDiff: ImagesForDiff = {baseImage, revisionImage};
+ return imagesForDiff;
+ });
+ }
+
+ _changeBaseURL(
+ changeNum: NumericChangeId,
+ revisionId?: RevisionId,
+ project?: RepoName
+ ): Promise<string> {
+ // TODO(kaspern): For full slicer migration, app should warn with a call
+ // stack every time _changeBaseURL is called without a project.
+ const projectPromise = project
+ ? Promise.resolve(project)
+ : this.getFromProjectLookup(changeNum);
+ return projectPromise.then(project => {
+ // TODO(TS): unclear why project can't be null here. Fix it
+ let url = `/changes/${encodeURIComponent(
+ project as RepoName
+ )}~${changeNum}`;
+ if (revisionId) {
+ url += `/revisions/${revisionId}`;
+ }
+ return url;
+ });
+ }
+
+ addToAttentionSet(
+ changeNum: NumericChangeId,
+ user: AccountId | undefined | null,
+ reason: string
+ ) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.POST,
+ endpoint: '/attention',
+ body: {user, reason},
+ reportUrlAsIs: true,
+ });
+ }
+
+ removeFromAttentionSet(
+ changeNum: NumericChangeId,
+ user: AccountId,
+ reason: string
+ ) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.DELETE,
+ endpoint: `/attention/${user}`,
+ anonymizedEndpoint: '/attention/*',
+ body: {reason},
+ });
+ }
+
+ setChangeTopic(
+ changeNum: NumericChangeId,
+ topic: string | null
+ ): Promise<string> {
+ return (this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.PUT,
+ endpoint: '/topic',
+ body: {topic},
+ parseResponse: true,
+ reportUrlAsIs: true,
+ }) as unknown) as Promise<string>;
+ }
+
+ setChangeHashtag(
+ changeNum: NumericChangeId,
+ hashtag: HashtagsInput
+ ): Promise<Hashtag[]> {
+ return (this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.POST,
+ endpoint: '/hashtags',
+ body: hashtag,
+ parseResponse: true,
+ reportUrlAsIs: true,
+ }) as unknown) as Promise<Hashtag[]>;
+ }
+
+ deleteAccountHttpPassword() {
+ return this._restApiHelper.send({
+ method: HttpMethod.DELETE,
+ url: '/accounts/self/password.http',
+ reportUrlAsIs: true,
+ });
+ }
+
+ generateAccountHttpPassword(): Promise<Password> {
+ return (this._restApiHelper.send({
+ method: HttpMethod.PUT,
+ url: '/accounts/self/password.http',
+ body: {generate: true},
+ parseResponse: true,
+ reportUrlAsIs: true,
+ }) as Promise<unknown>) as Promise<Password>;
+ }
+
+ getAccountSSHKeys() {
+ return (this._fetchSharedCacheURL({
+ url: '/accounts/self/sshkeys',
+ reportUrlAsIs: true,
+ }) as Promise<unknown>) as Promise<SshKeyInfo[] | undefined>;
+ }
+
+ addAccountSSHKey(key: string): Promise<SshKeyInfo> {
+ const req = {
+ method: HttpMethod.POST,
+ url: '/accounts/self/sshkeys',
+ body: key,
+ contentType: 'text/plain',
+ reportUrlAsIs: true,
+ };
+ return this._restApiHelper
+ .send(req)
+ .then((response: Response | undefined) => {
+ if (!response || (response.status < 200 && response.status >= 300)) {
+ return Promise.reject(new Error('error'));
+ }
+ return (this.getResponseObject(response) as unknown) as Promise<
+ SshKeyInfo
+ >;
+ })
+ .then(obj => {
+ if (!obj || !obj.valid) {
+ return Promise.reject(new Error('error'));
+ }
+ return obj;
+ });
+ }
+
+ deleteAccountSSHKey(id: string) {
+ return this._restApiHelper.send({
+ method: HttpMethod.DELETE,
+ url: '/accounts/self/sshkeys/' + id,
+ anonymizedUrl: '/accounts/self/sshkeys/*',
+ });
+ }
+
+ getAccountGPGKeys() {
+ return (this._restApiHelper.fetchJSON({
+ url: '/accounts/self/gpgkeys',
+ reportUrlAsIs: true,
+ }) as Promise<unknown>) as Promise<Record<string, GpgKeyInfo>>;
+ }
+
+ addAccountGPGKey(key: GpgKeysInput) {
+ const req = {
+ method: HttpMethod.POST,
+ url: '/accounts/self/gpgkeys',
+ body: key,
+ reportUrlAsIs: true,
+ };
+ return this._restApiHelper
+ .send(req)
+ .then(response => {
+ if (!response || (response.status < 200 && response.status >= 300)) {
+ return Promise.reject(new Error('error'));
+ }
+ return this.getResponseObject(response);
+ })
+ .then(obj => {
+ if (!obj) {
+ return Promise.reject(new Error('error'));
+ }
+ return obj;
+ });
+ }
+
+ deleteAccountGPGKey(id: GpgKeyId) {
+ return this._restApiHelper.send({
+ method: HttpMethod.DELETE,
+ url: `/accounts/self/gpgkeys/${id}`,
+ anonymizedUrl: '/accounts/self/gpgkeys/*',
+ });
+ }
+
+ deleteVote(changeNum: NumericChangeId, account: AccountId, label: string) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.DELETE,
+ endpoint: `/reviewers/${account}/votes/${encodeURIComponent(label)}`,
+ anonymizedEndpoint: '/reviewers/*/votes/*',
+ });
+ }
+
+ setDescription(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ desc: string
+ ) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.PUT,
+ patchNum,
+ endpoint: '/description',
+ body: {description: desc},
+ reportUrlAsIs: true,
+ });
+ }
+
+ confirmEmail(token: string): Promise<string | null> {
+ const req = {
+ method: HttpMethod.PUT,
+ url: '/config/server/email.confirm',
+ body: {token},
+ reportUrlAsIs: true,
+ };
+ return this._restApiHelper.send(req).then(response => {
+ if (response?.status === 204) {
+ return 'Email confirmed successfully.';
+ }
+ return null;
+ });
+ }
+
+ getCapabilities(
+ errFn?: ErrorCallback
+ ): Promise<CapabilityInfoMap | undefined> {
+ return this._restApiHelper.fetchJSON({
+ url: '/config/server/capabilities',
+ errFn,
+ reportUrlAsIs: true,
+ }) as Promise<CapabilityInfoMap | undefined>;
+ }
+
+ getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined> {
+ return this._fetchSharedCacheURL({
+ url: '/config/server/top-menus',
+ errFn,
+ reportUrlAsIs: true,
+ }) as Promise<TopMenuEntryInfo[] | undefined>;
+ }
+
+ setAssignee(
+ changeNum: NumericChangeId,
+ assignee: AccountId
+ ): Promise<Response> {
+ const body: AssigneeInput = {assignee};
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.PUT,
+ endpoint: '/assignee',
+ body,
+ reportUrlAsIs: true,
+ });
+ }
+
+ deleteAssignee(changeNum: NumericChangeId): Promise<Response> {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.DELETE,
+ endpoint: '/assignee',
+ reportUrlAsIs: true,
+ });
+ }
+
+ probePath(path: string) {
+ return fetch(new Request(path, {method: HttpMethod.HEAD})).then(
+ response => response.ok
+ );
+ }
+
+ startWorkInProgress(
+ changeNum: NumericChangeId,
+ message?: string
+ ): Promise<string | undefined> {
+ const body = message ? {message} : {};
+ const req: SendRawChangeRequest = {
+ changeNum,
+ method: HttpMethod.POST,
+ endpoint: '/wip',
+ body,
+ reportUrlAsIs: true,
+ };
+ return this._getChangeURLAndSend(req).then(response => {
+ if (response?.status === 204) {
+ return 'Change marked as Work In Progress.';
+ }
+ return undefined;
+ });
+ }
+
+ startReview(
+ changeNum: NumericChangeId,
+ body?: RequestPayload,
+ errFn?: ErrorCallback
+ ) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.POST,
+ endpoint: '/ready',
+ body,
+ errFn,
+ reportUrlAsIs: true,
+ });
+ }
+
+ deleteComment(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ commentID: UrlEncodedCommentId,
+ reason: string
+ ) {
+ return (this._getChangeURLAndSend({
+ changeNum,
+ method: HttpMethod.POST,
+ patchNum,
+ endpoint: `/comments/${commentID}/delete`,
+ body: {reason},
+ parseResponse: true,
+ anonymizedEndpoint: '/comments/*/delete',
+ }) as unknown) as Promise<CommentInfo>;
+ }
+
+ /**
+ * Given a changeNum, gets the change.
+ */
+ getChange(
+ changeNum: ChangeId | NumericChangeId,
+ errFn: ErrorCallback
+ ): Promise<ChangeInfo | null> {
+ // Cannot use _changeBaseURL, as this function is used by _projectLookup.
+ return this._restApiHelper
+ .fetchJSON({
+ url: `/changes/?q=change:${changeNum}`,
+ errFn,
+ anonymizedUrl: '/changes/?q=change:*',
+ })
+ .then(res => {
+ const changeInfos = res as ChangeInfo[] | undefined;
+ if (!changeInfos || !changeInfos.length) {
+ return null;
+ }
+ return changeInfos[0];
+ });
+ }
+
+ setInProjectLookup(changeNum: NumericChangeId, project: RepoName) {
+ if (
+ this._projectLookup[changeNum] &&
+ this._projectLookup[changeNum] !== project
+ ) {
+ console.warn(
+ 'Change set with multiple project nums.' +
+ 'One of them must be invalid.'
+ );
+ }
+ this._projectLookup[changeNum] = project;
+ }
+
+ /**
+ * Checks in _projectLookup for the changeNum. If it exists, returns the
+ * project. If not, calls the restAPI to get the change, populates
+ * _projectLookup with the project for that change, and returns the project.
+ */
+ getFromProjectLookup(
+ changeNum: NumericChangeId
+ ): Promise<RepoName | undefined> {
+ const project = this._projectLookup[`${changeNum}`];
+ if (project) {
+ return Promise.resolve(project);
+ }
+
+ const onError = (response?: Response | null) => {
+ // Fire a page error so that the visual 404 is displayed.
+ this.dispatchEvent(
+ new CustomEvent('page-error', {
+ detail: {response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ };
+
+ return this.getChange(changeNum, onError).then(change => {
+ if (!change || !change.project) {
+ return;
+ }
+ this.setInProjectLookup(changeNum, change.project);
+ return change.project;
+ });
+ }
+
+ // if errFn is not set, then only Response possible
+ _getChangeURLAndSend(
+ req: SendRawChangeRequest & {errFn?: undefined}
+ ): Promise<Response>;
+
+ _getChangeURLAndSend(
+ req: SendRawChangeRequest
+ ): Promise<Response | undefined>;
+
+ _getChangeURLAndSend(req: SendJSONChangeRequest): Promise<ParsedJSON>;
+
+ /**
+ * Alias for _changeBaseURL.then(send).
+ */
+ _getChangeURLAndSend(
+ req: SendChangeRequest
+ ): Promise<ParsedJSON | Response | undefined> {
+ const anonymizedBaseUrl = req.patchNum
+ ? ANONYMIZED_REVISION_BASE_URL
+ : ANONYMIZED_CHANGE_BASE_URL;
+ const anonymizedEndpoint = req.reportEndpointAsIs
+ ? req.endpoint
+ : req.anonymizedEndpoint;
+
+ return this._changeBaseURL(req.changeNum, req.patchNum).then(url => {
+ const request: SendRequest = {
+ method: req.method,
+ url: url + req.endpoint,
+ body: req.body,
+ errFn: req.errFn,
+ contentType: req.contentType,
+ headers: req.headers,
+ parseResponse: req.parseResponse,
+ anonymizedUrl: anonymizedEndpoint
+ ? `${anonymizedBaseUrl}${anonymizedEndpoint}`
+ : undefined,
+ };
+ return this._restApiHelper.send(request);
+ });
+ }
+
+ /**
+ * Alias for _changeBaseURL.then(_fetchJSON).
+ */
+ _getChangeURLAndFetch(
+ req: FetchChangeJSON,
+ noAcceptHeader?: boolean
+ ): Promise<ParsedJSON | undefined> {
+ const anonymizedEndpoint = req.reportEndpointAsIs
+ ? req.endpoint
+ : req.anonymizedEndpoint;
+ const anonymizedBaseUrl = req.revision
+ ? ANONYMIZED_REVISION_BASE_URL
+ : ANONYMIZED_CHANGE_BASE_URL;
+ return this._changeBaseURL(req.changeNum, req.revision).then(url =>
+ this._restApiHelper.fetchJSON(
+ {
+ url: url + req.endpoint,
+ errFn: req.errFn,
+ params: req.params,
+ fetchOptions: req.fetchOptions,
+ anonymizedUrl: anonymizedEndpoint
+ ? anonymizedBaseUrl + anonymizedEndpoint
+ : undefined,
+ },
+ noAcceptHeader
+ )
+ );
+ }
+
+ executeChangeAction(
+ changeNum: NumericChangeId,
+ method: HttpMethod | undefined,
+ endpoint: string,
+ patchNum?: PatchSetNum,
+ payload?: RequestPayload
+ ): Promise<Response>;
+
+ executeChangeAction(
+ changeNum: NumericChangeId,
+ method: HttpMethod | undefined,
+ endpoint: string,
+ patchNum: PatchSetNum | undefined,
+ payload: RequestPayload | undefined,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ /**
+ * Execute a change action or revision action on a change.
+ */
+ executeChangeAction(
+ changeNum: NumericChangeId,
+ method: HttpMethod | undefined,
+ endpoint: string,
+ patchNum?: PatchSetNum,
+ payload?: RequestPayload,
+ errFn?: ErrorCallback
+ ) {
+ return this._getChangeURLAndSend({
+ changeNum,
+ method,
+ patchNum,
+ endpoint,
+ body: payload,
+ errFn,
+ });
+ }
+
+ /**
+ * Get blame information for the given diff.
+ *
+ * @param base If true, requests blame for the base of the
+ * diff, rather than the revision.
+ */
+ getBlame(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ path: string,
+ base?: boolean
+ ) {
+ const encodedPath = encodeURIComponent(path);
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: `/files/${encodedPath}/blame`,
+ revision: patchNum,
+ params: base ? {base: 't'} : undefined,
+ anonymizedEndpoint: '/files/*/blame',
+ }) as Promise<BlameInfo[] | undefined>;
+ }
+
+ /**
+ * Modify the given create draft request promise so that it fails and throws
+ * an error if the response bears HTTP status 200 instead of HTTP 201.
+ *
+ * @see Issue 7763
+ * @param promise The original promise.
+ * @return The modified promise.
+ */
+ _failForCreate200(promise: Promise<Response>): Promise<Response> {
+ return promise.then(result => {
+ if (result.status === 200) {
+ // Read the response headers into an object representation.
+ const headers = Array.from(result.headers.entries()).reduce(
+ (obj, [key, val]) => {
+ if (!HEADER_REPORTING_BLOCK_REGEX.test(key)) {
+ obj[key] = val;
+ }
+ return obj;
+ },
+ {} as Record<string, string>
+ );
+ const err = new Error(
+ [
+ CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
+ JSON.stringify(headers),
+ ].join('\n')
+ );
+ // Throw the error so that it is caught by gr-reporting.
+ throw err;
+ }
+ return result;
+ });
+ }
+
+ /**
+ * Fetch a project dashboard definition.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#get-dashboard
+ */
+ getDashboard(
+ project: RepoName,
+ dashboard: DashboardId,
+ errFn?: ErrorCallback
+ ): Promise<DashboardInfo | undefined> {
+ const url =
+ '/projects/' +
+ encodeURIComponent(project) +
+ '/dashboards/' +
+ encodeURIComponent(dashboard);
+ return this._fetchSharedCacheURL({
+ url,
+ errFn,
+ anonymizedUrl: '/projects/*/dashboards/*',
+ }) as Promise<DashboardInfo | undefined>;
+ }
+
+ getDocumentationSearches(filter: string): Promise<DocResult[] | undefined> {
+ filter = filter.trim();
+ const encodedFilter = encodeURIComponent(filter);
+
+ // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
+ // supports it.
+ return this._fetchSharedCacheURL({
+ url: `/Documentation/?q=${encodedFilter}`,
+ anonymizedUrl: '/Documentation/?*',
+ }) as Promise<DocResult[] | undefined>;
+ }
+
+ getMergeable(changeNum: NumericChangeId) {
+ return this._getChangeURLAndFetch({
+ changeNum,
+ endpoint: '/revisions/current/mergeable',
+ reportEndpointAsIs: true,
+ }) as Promise<MergeableInfo | undefined>;
+ }
+
+ deleteDraftComments(query: string): Promise<Response> {
+ const body: DeleteDraftCommentsInput = {query};
+ return this._restApiHelper.send({
+ method: HttpMethod.POST,
+ url: '/accounts/self/drafts:delete',
+ body,
+ });
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
index aadde88..d75c186 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.js
@@ -21,6 +21,7 @@
import {GrReviewerUpdatesParser} from './gr-reviewer-updates-parser.js';
import {ListChangesOption} from '../../../utils/change-util.js';
import {appContext} from '../../../services/app-context.js';
+import {createChange} from '../../../test/test-data-generators.js';
const basicFixture = fixtureFromElement('gr-rest-api-interface');
@@ -54,7 +55,7 @@
window.CANONICAL_PATH = originalCanonicalPath;
});
- test('parent diff comments are properly grouped', done => {
+ test('parent diff comments are properly grouped', () => {
sinon.stub(element._restApiHelper, 'fetchJSON')
.callsFake(() => Promise.resolve({
'/COMMIT_MSG': [],
@@ -70,7 +71,7 @@
},
],
}));
- element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
+ return element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
obj => {
assert.equal(obj.baseComments.length, 1);
assert.deepEqual(obj.baseComments[0], {
@@ -85,7 +86,6 @@
path: 'sieve.go',
updated: '2017-02-03 22:32:28.000000000',
});
- done();
});
});
@@ -194,10 +194,10 @@
assert.deepEqual(element._setRanges(comments), expectedResult);
});
- test('differing patch diff comments are properly grouped', done => {
+ test('differing patch diff comments are properly grouped', () => {
sinon.stub(element, 'getFromProjectLookup')
.returns(Promise.resolve('test'));
- sinon.stub(element._restApiHelper, 'fetchJSON').callsFake( request => {
+ sinon.stub(element._restApiHelper, 'fetchJSON').callsFake(request => {
const url = request.url;
if (url === '/changes/test~42/revisions/1') {
return Promise.resolve({
@@ -235,7 +235,7 @@
});
}
});
- element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
+ return element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
obj => {
assert.equal(obj.baseComments.length, 1);
assert.deepEqual(obj.baseComments[0], {
@@ -254,26 +254,26 @@
path: 'sieve.go',
updated: '2017-02-04 22:33:28.000000000',
});
- done();
});
});
- test('server error', done => {
+ test('server error', () => {
const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
window.fetch.returns(Promise.resolve({ok: false}));
const serverErrorEventPromise = new Promise(resolve => {
element.addEventListener('server-error', resolve);
});
- element._restApiHelper.fetchJSON({}).then(response => {
+ return Promise.all([element._restApiHelper.fetchJSON({}).then(response => {
assert.isUndefined(response);
assert.isTrue(getResponseObjectStub.notCalled);
- serverErrorEventPromise.then(() => done());
- });
+ }), serverErrorEventPromise]);
});
test('legacy n,z key in change url is replaced', async () => {
- sinon.stub(element, 'getConfig').callsFake( async () => { return {}; });
+ sinon.stub(element, 'getConfig').callsFake(async () => {
+ return {};
+ });
const stub = sinon.stub(element._restApiHelper, 'fetchJSON')
.returns(Promise.resolve([]));
await element.getChanges(1, null, 'n,z');
@@ -289,49 +289,42 @@
assert.isFalse(element._restApiHelper._cache.has(cacheKey));
});
- test('getAccount when resp is null does not add anything to the cache',
- done => {
- const cacheKey = '/accounts/self/detail';
- const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
- .callsFake(() => Promise.resolve());
+ test('getAccount when resp is null does not add to cache', async () => {
+ const cacheKey = '/accounts/self/detail';
+ const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
+ .callsFake(() => Promise.resolve());
- element.getAccount().then(() => {
- assert.isTrue(stub.called);
- assert.isFalse(element._restApiHelper._cache.has(cacheKey));
- done();
- });
+ await element.getAccount();
+ assert.isTrue(stub.called);
+ assert.isFalse(element._restApiHelper._cache.has(cacheKey));
- element._restApiHelper._cache.set(cacheKey, 'fake cache');
- stub.lastCall.args[0].errFn();
- });
+ element._restApiHelper._cache.set(cacheKey, 'fake cache');
+ stub.lastCall.args[0].errFn();
+ });
- test('getAccount does not add to the cache when resp.status is 403',
- done => {
- const cacheKey = '/accounts/self/detail';
- const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
- .callsFake(() => Promise.resolve());
+ test('getAccount does not add to cache when status is 403', async () => {
+ const cacheKey = '/accounts/self/detail';
+ const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL')
+ .callsFake(() => Promise.resolve());
- element.getAccount().then(() => {
- assert.isTrue(stub.called);
- assert.isFalse(element._restApiHelper._cache.has(cacheKey));
- done();
- });
- element._cache.set(cacheKey, 'fake cache');
- stub.lastCall.args[0].errFn({status: 403});
- });
+ await element.getAccount();
+ assert.isTrue(stub.called);
+ assert.isFalse(element._restApiHelper._cache.has(cacheKey));
- test('getAccount when resp is successful', done => {
+ element._cache.set(cacheKey, 'fake cache');
+ stub.lastCall.args[0].errFn({status: 403});
+ });
+
+ test('getAccount when resp is successful', async () => {
const cacheKey = '/accounts/self/detail';
const stub = sinon.stub(element._restApiHelper, 'fetchCacheURL').callsFake(
() => Promise.resolve());
- element.getAccount().then(response => {
- assert.isTrue(stub.called);
- assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
- done();
- });
- element._restApiHelper._cache.set(cacheKey, 'fake cache');
+ await element.getAccount();
+ element._restApiHelper._cache.set(cacheKey, 'fake cache');
+ assert.isTrue(stub.called);
+ assert.equal(element._restApiHelper._cache.get(cacheKey), 'fake cache');
stub.lastCall.args[0].errFn({});
});
@@ -346,61 +339,57 @@
};
test('getPreferences returns correctly on small screens logged in',
- done => {
+ () => {
const testJSON = {diff_view: 'SIDE_BY_SIDE'};
const loggedIn = true;
const smallScreen = true;
preferenceSetup(testJSON, loggedIn, smallScreen);
- element.getPreferences().then(obj => {
+ return element.getPreferences().then(obj => {
assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
- done();
});
});
test('getPreferences returns correctly on small screens not logged in',
- done => {
+ () => {
const testJSON = {diff_view: 'SIDE_BY_SIDE'};
const loggedIn = false;
const smallScreen = true;
preferenceSetup(testJSON, loggedIn, smallScreen);
- element.getPreferences().then(obj => {
+ return element.getPreferences().then(obj => {
assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
- done();
});
});
test('getPreferences returns correctly on larger screens logged in',
- done => {
+ () => {
const testJSON = {diff_view: 'UNIFIED_DIFF'};
const loggedIn = true;
const smallScreen = false;
preferenceSetup(testJSON, loggedIn, smallScreen);
- element.getPreferences().then(obj => {
+ return element.getPreferences().then(obj => {
assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
assert.equal(obj.diff_view, 'UNIFIED_DIFF');
- done();
});
});
test('getPreferences returns correctly on larger screens not logged in',
- done => {
+ () => {
const testJSON = {diff_view: 'UNIFIED_DIFF'};
const loggedIn = false;
const smallScreen = false;
preferenceSetup(testJSON, loggedIn, smallScreen);
- element.getPreferences().then(obj => {
+ return element.getPreferences().then(obj => {
assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
- done();
});
});
@@ -411,10 +400,10 @@
assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
});
- test('getDiffPreferences returns correct defaults', done => {
+ test('getDiffPreferences returns correct defaults', () => {
sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
- element.getDiffPreferences().then(obj => {
+ return element.getDiffPreferences().then(obj => {
assert.equal(obj.auto_hide_diff_table_header, true);
assert.equal(obj.context, 10);
assert.equal(obj.cursor_blink_rate, 0);
@@ -429,7 +418,6 @@
assert.equal(obj.syntax_highlighting, true);
assert.equal(obj.tab_size, 8);
assert.equal(obj.theme, 'DEFAULT');
- done();
});
});
@@ -440,10 +428,10 @@
assert.equal(sendStub.lastCall.args[0].body.show_tabs, false);
});
- test('getEditPreferences returns correct defaults', done => {
+ test('getEditPreferences returns correct defaults', () => {
sinon.stub(element, 'getLoggedIn').callsFake(() => Promise.resolve(false));
- element.getEditPreferences().then(obj => {
+ return element.getEditPreferences().then(obj => {
assert.equal(obj.auto_close_brackets, false);
assert.equal(obj.cursor_blink_rate, 0);
assert.equal(obj.hide_line_numbers, false);
@@ -460,7 +448,6 @@
assert.equal(obj.syntax_highlighting, true);
assert.equal(obj.tab_size, 8);
assert.equal(obj.theme, 'DEFAULT');
- done();
});
});
@@ -492,9 +479,9 @@
'/accounts/self/status');
assert.deepEqual(sendStub.lastCall.args[0].body,
{status: 'OOO'});
- assert.deepEqual(element._restApiHelper
- ._cache.get('/accounts/self/detail'),
- {status: 'OOO'});
+ assert.deepEqual(
+ element._restApiHelper._cache.get('/accounts/self/detail'),
+ {status: 'OOO'});
});
});
@@ -513,7 +500,9 @@
assert.equal(obj.sendDiffDraft.length, 2);
assert.isTrue(!!element.hasPendingDiffDrafts());
- for (const promise of obj.sendDiffDraft) { promise.resolve(); }
+ for (const promise of obj.sendDiffDraft) {
+ promise.resolve();
+ }
return element.awaitPendingDiffDrafts().then(() => {
assert.equal(obj.sendDiffDraft.length, 0);
@@ -544,41 +533,36 @@
});
});
- test('_failForCreate200 fails on 200', done => {
+ test('_failForCreate200 fails on 200', () => {
const result = {
ok: true,
status: 200,
- headers: {entries: () => [
- ['Set-CoOkiE', 'secret'],
- ['Innocuous', 'hello'],
- ]},
+ headers: {
+ entries: () => [
+ ['Set-CoOkiE', 'secret'],
+ ['Innocuous', 'hello'],
+ ],
+ },
};
- element._failForCreate200(Promise.resolve(result))
+ return element._failForCreate200(Promise.resolve(result))
.then(() => {
- assert.isTrue(false, 'Promise should not resolve');
+ assert.fail('Error expected.');
})
.catch(e => {
assert.isOk(e);
assert.include(e.message, 'Saving draft resulted in HTTP 200');
assert.include(e.message, 'hello');
assert.notInclude(e.message, 'secret');
- done();
});
});
- test('_failForCreate200 does not fail on 201', done => {
+ test('_failForCreate200 does not fail on 201', () => {
const result = {
ok: true,
status: 201,
headers: {entries: () => []},
};
- element._failForCreate200(Promise.resolve(result))
- .then(() => {
- done();
- })
- .catch(e => {
- assert.isTrue(false, 'Promise should not fail');
- });
+ return element._failForCreate200(Promise.resolve(result));
});
});
});
@@ -710,7 +694,7 @@
assert.equal(fetchStub.lastCall.args[0].changeNum, '42');
assert.equal(fetchStub.lastCall.args[0].endpoint,
'/files?q=test%2Fpath.js');
- assert.equal(fetchStub.lastCall.args[0].patchNum, 'edit');
+ assert.equal(fetchStub.lastCall.args[0].revision, 'edit');
});
});
@@ -900,11 +884,15 @@
suite('change detail options', () => {
setup(() => {
sinon.stub(element, '_getChangeDetail').callsFake(
- async (changeNum, options) => { return {changeNum, options}; });
+ async (changeNum, options) => {
+ return {changeNum, options};
+ });
});
test('signed pushes disabled', async () => {
- sinon.stub(element, 'getConfig').callsFake( async () => { return {}; });
+ sinon.stub(element, 'getConfig').callsFake(async () => {
+ return {};
+ });
const {changeNum, options} = await element.getChangeDetail(123);
assert.strictEqual(123, changeNum);
assert.isNotOk(
@@ -912,7 +900,7 @@
});
test('signed pushes enabled', async () => {
- sinon.stub(element, 'getConfig').callsFake( async () => {
+ sinon.stub(element, 'getConfig').callsFake(async () => {
return {receive: {enable_signed_push: true}};
});
const {changeNum, options} = await element.getChangeDetail(123);
@@ -935,8 +923,7 @@
const changeNum = 4321;
element._projectLookup[changeNum] = 'test';
const expectedUrl =
- window.CANONICAL_PATH + '/changes/test~4321/detail?'+
- '0=5&1=1&2=6&3=7&4=1&5=4';
+ window.CANONICAL_PATH + '/changes/test~4321/detail?O=516714';
sinon.stub(element._etags, 'getOptions');
sinon.stub(element._etags, 'collect');
return element._getChangeDetail(changeNum, '516714').then(() => {
@@ -979,7 +966,6 @@
let requestUrl;
let mockResponseSerial;
let collectSpy;
- let getPayloadSpy;
setup(() => {
requestUrl = '/foo/bar';
@@ -991,10 +977,10 @@
sinon.stub(element, 'getChangeActionURL')
.returns(Promise.resolve(requestUrl));
collectSpy = sinon.spy(element._etags, 'collect');
- getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
});
test('contributes to cache', () => {
+ const getPayloadSpy = sinon.spy(element._etags, 'getCachedPayload');
sinon.stub(element._restApiHelper, 'fetchRawJSON')
.returns(Promise.resolve({
text: () => Promise.resolve(mockResponseSerial),
@@ -1011,16 +997,18 @@
});
test('uses cache on HTTP 304', () => {
+ const getPayloadStub = sinon.stub(element._etags, 'getCachedPayload');
+ getPayloadStub.returns(mockResponseSerial);
sinon.stub(element._restApiHelper, 'fetchRawJSON')
.returns(Promise.resolve({
- text: () => Promise.resolve(mockResponseSerial),
+ text: () => Promise.resolve(''),
status: 304,
ok: true,
}));
- return element._getChangeDetail(123, {}).then(detail => {
+ return element._getChangeDetail(123, '').then(detail => {
assert.isFalse(collectSpy.called);
- assert.isTrue(getPayloadSpy.calledOnce);
+ assert.isTrue(getPayloadStub.calledOnce);
});
});
});
@@ -1103,7 +1091,7 @@
element._projectLookup = {1: 'test'};
const fetchStub = sinon.stub(element._restApiHelper, 'fetchJSON')
.returns(Promise.resolve());
- const req = {changeNum: 1, endpoint: '/test', patchNum: 1};
+ const req = {changeNum: 1, endpoint: '/test', revision: 1};
return element._getChangeURLAndFetch(req).then(() => {
assert.equal(fetchStub.lastCall.args[0].url,
'/changes/test~1/revisions/1/test');
@@ -1180,7 +1168,7 @@
const range = {basePatchNum: 'PARENT', patchNum: 2};
return element.getChangeFiles(123, range).then(() => {
assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+ assert.equal(fetchStub.lastCall.args[0].revision, 2);
assert.isNotOk(fetchStub.lastCall.args[0].params);
});
});
@@ -1191,7 +1179,7 @@
const range = {basePatchNum: 4, patchNum: 5};
return element.getChangeFiles(123, range).then(() => {
assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+ assert.equal(fetchStub.lastCall.args[0].revision, 5);
assert.isOk(fetchStub.lastCall.args[0].params);
assert.equal(fetchStub.lastCall.args[0].params.base, 4);
assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
@@ -1204,7 +1192,7 @@
const range = {basePatchNum: -3, patchNum: 5};
return element.getChangeFiles(123, range).then(() => {
assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+ assert.equal(fetchStub.lastCall.args[0].revision, 5);
assert.isOk(fetchStub.lastCall.args[0].params);
assert.isNotOk(fetchStub.lastCall.args[0].params.base);
assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
@@ -1218,7 +1206,7 @@
.returns(Promise.resolve());
return element.getDiff(123, 'PARENT', 2, 'foo/bar.baz').then(() => {
assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].patchNum, 2);
+ assert.equal(fetchStub.lastCall.args[0].revision, 2);
assert.isOk(fetchStub.lastCall.args[0].params);
assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
assert.isNotOk(fetchStub.lastCall.args[0].params.base);
@@ -1230,7 +1218,7 @@
.returns(Promise.resolve());
return element.getDiff(123, 4, 5, 'foo/bar.baz').then(() => {
assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+ assert.equal(fetchStub.lastCall.args[0].revision, 5);
assert.isOk(fetchStub.lastCall.args[0].params);
assert.isNotOk(fetchStub.lastCall.args[0].params.parent);
assert.equal(fetchStub.lastCall.args[0].params.base, 4);
@@ -1242,7 +1230,7 @@
.returns(Promise.resolve());
return element.getDiff(123, -3, 5, 'foo/bar.baz').then(() => {
assert.isTrue(fetchStub.calledOnce);
- assert.equal(fetchStub.lastCall.args[0].patchNum, 5);
+ assert.equal(fetchStub.lastCall.args[0].revision, 5);
assert.isOk(fetchStub.lastCall.args[0].params);
assert.isNotOk(fetchStub.lastCall.args[0].params.base);
assert.equal(fetchStub.lastCall.args[0].params.parent, 3);
@@ -1289,21 +1277,24 @@
return Promise.all([edit, normal]);
});
- test('getFileContent suppresses 404s', done => {
+ test('getFileContent suppresses 404s', () => {
const res = {status: 404};
- const handler = e => {
- assert.isFalse(e.detail.res.status === 404);
- done();
- };
- element.addEventListener('server-error', handler);
+ const spy = sinon.spy();
+ element.addEventListener('server-error', spy);
sinon.stub(appContext.authService, 'fetch').returns(Promise.resolve(res));
sinon.stub(element, '_changeBaseURL').returns(Promise.resolve(''));
- element.getFileContent('1', 'tst/path', '1').then(() => {
- flushAsynchronousOperations();
+ return element.getFileContent('1', 'tst/path', '1')
+ .then(() => {
+ flush();
+ assert.isFalse(spy.called);
- res.status = 500;
- element.getFileContent('1', 'tst/path', '1');
- });
+ res.status = 500;
+ return element.getFileContent('1', 'tst/path', '1');
+ })
+ .then(() => {
+ assert.isTrue(spy.called);
+ assert.notEqual(spy.lastCall.args[0].detail.res.status, 404);
+ });
});
test('getChangeFilesOrEditFiles is edit-sensitive', () => {
@@ -1349,10 +1340,32 @@
element._restApiHelper
._logCall({url: 'url', anonymizedUrl: 'not url'}, 100, 200);
- flushAsynchronousOperations();
+ flush();
assert.isTrue(handler.calledOnce);
});
+ test('ported comment errors do not trigger error dialog', () => {
+ const change = createChange();
+ const dispatchStub = sinon.stub(element._restApiHelper, 'dispatchEvent');
+ sinon.stub(element._restApiHelper, 'fetchJSON').returns(Promise.resolve({
+ ok: false}));
+
+ element.getPortedComments(change._number, 'current');
+
+ assert.isFalse(dispatchStub.called);
+ });
+
+ test('ported drafts are not requested user is not logged in', () => {
+ const change = createChange();
+ sinon.stub(element, 'getLoggedIn').returns(Promise.resolve(false));
+ const getChangeURLAndFetchStub = sinon.stub(element,
+ '_getChangeURLAndFetch');
+
+ element.getPortedDrafts(change._number, 'current');
+
+ assert.isFalse(getChangeURLAndFetchStub.called);
+ });
+
test('saveChangeStarred', async () => {
sinon.stub(element, 'getFromProjectLookup')
.returns(Promise.resolve('test'));
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
deleted file mode 100644
index d54d342..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js
+++ /dev/null
@@ -1,404 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 {getBaseUrl} from '../../../../utils/url-util.js';
-
-const JSON_PREFIX = ')]}\'';
-
-/**
- * Wrapper around Map for caching server responses. Site-based so that
- * changes to CANONICAL_PATH will result in a different cache going into
- * effect.
- */
-export class SiteBasedCache {
- constructor() {
- // Container of per-canonical-path caches.
- this._data = new Map();
- if (window.INITIAL_DATA != undefined) {
- // Put all data shipped with index.html into the cache. This makes it
- // so that we spare more round trips to the server when the app loads
- // initially.
- Object
- .entries(window.INITIAL_DATA)
- .forEach(e => this._cache().set(e[0], e[1]));
- }
- }
-
- // Returns the cache for the current canonical path.
- _cache() {
- if (!this._data.has(window.CANONICAL_PATH)) {
- this._data.set(window.CANONICAL_PATH, new Map());
- }
- return this._data.get(window.CANONICAL_PATH);
- }
-
- has(key) {
- return this._cache().has(key);
- }
-
- get(key) {
- return this._cache().get(key);
- }
-
- set(key, value) {
- this._cache().set(key, value);
- }
-
- delete(key) {
- this._cache().delete(key);
- }
-
- invalidatePrefix(prefix) {
- const newMap = new Map();
- for (const [key, value] of this._cache().entries()) {
- if (!key.startsWith(prefix)) {
- newMap.set(key, value);
- }
- }
- this._data.set(window.CANONICAL_PATH, newMap);
- }
-}
-
-export class FetchPromisesCache {
- constructor() {
- this._data = {};
- }
-
- has(key) {
- return !!this._data[key];
- }
-
- get(key) {
- return this._data[key];
- }
-
- set(key, value) {
- this._data[key] = value;
- }
-
- invalidatePrefix(prefix) {
- const newData = {};
- Object.entries(this._data).forEach(([key, value]) => {
- if (!key.startsWith(prefix)) {
- newData[key] = value;
- }
- });
- this._data = newData;
- }
-}
-
-export class GrRestApiHelper {
- /**
- * @param {SiteBasedCache} cache
- * @param {object} auth
- * @param {FetchPromisesCache} fetchPromisesCache
- * @param {object} restApiInterface
- */
- constructor(cache, auth, fetchPromisesCache,
- restApiInterface) {
- this._cache = cache;// TODO: make it public
- this._auth = auth;
- this._fetchPromisesCache = fetchPromisesCache;
- this._restApiInterface = restApiInterface;
- }
-
- /**
- * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
- * with timing and logging.
- *
- * @param {Gerrit.FetchRequest} req
- */
- fetch(req) {
- const start = Date.now();
- const xhr = this._auth.fetch(req.url, req.fetchOptions);
-
- // Log the call after it completes.
- xhr.then(res => this._logCall(req, start, res ? res.status : null));
-
- // Return the XHR directly (without the log).
- return xhr;
- }
-
- /**
- * Log information about a REST call. Because the elapsed time is determined
- * by this method, it should be called immediately after the request
- * finishes.
- *
- * @param {Gerrit.FetchRequest} req
- * @param {number} startTime the time that the request was started.
- * @param {number} status the HTTP status of the response. The status value
- * is used here rather than the response object so there is no way this
- * method can read the body stream.
- */
- _logCall(req, startTime, status) {
- const method = (req.fetchOptions && req.fetchOptions.method) ?
- req.fetchOptions.method : 'GET';
- const endTime = Date.now();
- const elapsed = (endTime - startTime);
- const startAt = new Date(startTime);
- const endAt = new Date(endTime);
- console.log([
- 'HTTP',
- status,
- method,
- elapsed + 'ms',
- req.anonymizedUrl || req.url,
- `(${startAt.toISOString()}, ${endAt.toISOString()})`,
- ].join(' '));
- if (req.anonymizedUrl) {
- this.dispatchEvent(new CustomEvent('rpc-log', {
- detail: {status, method, elapsed, anonymizedUrl: req.anonymizedUrl},
- composed: true, bubbles: true,
- }));
- }
- }
-
- /**
- * Fetch JSON from url provided.
- * Returns a Promise that resolves to a native Response.
- * Doesn't do error checking. Supports cancel condition. Performs auth.
- * Validates auth expiry errors.
- *
- * @param {Gerrit.FetchJSONRequest} req
- */
- fetchRawJSON(req) {
- const urlWithParams = this.urlWithParams(req.url, req.params);
- const fetchReq = {
- url: urlWithParams,
- fetchOptions: req.fetchOptions,
- anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
- };
- return this.fetch(fetchReq)
- .then(res => {
- if (req.cancelCondition && req.cancelCondition()) {
- res.body.cancel();
- return;
- }
- return res;
- })
- .catch(err => {
- if (req.errFn) {
- req.errFn.call(undefined, null, err);
- } else {
- this.dispatchEvent(new CustomEvent('network-error', {
- detail: {error: err},
- composed: true, bubbles: true,
- }));
- }
- throw err;
- });
- }
-
- /**
- * Fetch JSON from url provided.
- * Returns a Promise that resolves to a parsed response.
- * Same as {@link fetchRawJSON}, plus error handling.
- *
- * @param {Gerrit.FetchJSONRequest} req
- * @param {boolean} noAcceptHeader - don't add default accept json header
- */
- fetchJSON(req, noAcceptHeader) {
- if (!noAcceptHeader) {
- req = this.addAcceptJsonHeader(req);
- }
- return this.fetchRawJSON(req).then(response => {
- if (!response) {
- return;
- }
- if (!response.ok) {
- if (req.errFn) {
- req.errFn.call(null, response);
- return;
- }
- this.dispatchEvent(new CustomEvent('server-error', {
- detail: {request: req, response},
- composed: true, bubbles: true,
- }));
- return;
- }
- return response && this.getResponseObject(response);
- });
- }
-
- /**
- * @param {string} url
- * @param {?Object|string=} opt_params URL params, key-value hash.
- * @return {string}
- */
- urlWithParams(url, opt_params) {
- if (!opt_params) { return getBaseUrl() + url; }
-
- const params = [];
- for (const p in opt_params) {
- if (!opt_params.hasOwnProperty(p)) { continue; }
- if (opt_params[p] == null) {
- params.push(encodeURIComponent(p));
- continue;
- }
- for (const value of [].concat(opt_params[p])) {
- params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
- }
- }
- return getBaseUrl() + url + '?' + params.join('&');
- }
-
- /**
- * @param {!Object} response
- * @return {?}
- */
- getResponseObject(response) {
- return this.readResponsePayload(response)
- .then(payload => payload.parsed);
- }
-
- /**
- * @param {!Object} response
- * @return {!Object}
- */
- readResponsePayload(response) {
- return response.text().then(text => {
- let result;
- try {
- result = this.parsePrefixedJSON(text);
- } catch (_) {
- result = null;
- }
- return {parsed: result, raw: text};
- });
- }
-
- /**
- * @param {string} source
- * @return {?}
- */
- parsePrefixedJSON(source) {
- return JSON.parse(source.substring(JSON_PREFIX.length));
- }
-
- /**
- * @param {Gerrit.FetchJSONRequest} req
- * @return {Gerrit.FetchJSONRequest}
- */
- addAcceptJsonHeader(req) {
- if (!req.fetchOptions) req.fetchOptions = {};
- if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
- if (!req.fetchOptions.headers.has('Accept')) {
- req.fetchOptions.headers.append('Accept', 'application/json');
- }
- return req;
- }
-
- dispatchEvent(type, detail) {
- return this._restApiInterface.dispatchEvent(type, detail);
- }
-
- /**
- * @param {Gerrit.FetchJSONRequest} req
- */
- fetchCacheURL(req) {
- if (this._fetchPromisesCache.has(req.url)) {
- return this._fetchPromisesCache.get(req.url);
- }
- // TODO(andybons): Periodic cache invalidation.
- if (this._cache.has(req.url)) {
- return Promise.resolve(this._cache.get(req.url));
- }
- this._fetchPromisesCache.set(req.url,
- this.fetchJSON(req)
- .then(response => {
- if (response !== undefined) {
- this._cache.set(req.url, response);
- }
- this._fetchPromisesCache.set(req.url, undefined);
- return response;
- })
- .catch(err => {
- this._fetchPromisesCache.set(req.url, undefined);
- throw err;
- })
- );
- return this._fetchPromisesCache.get(req.url);
- }
-
- /**
- * Send an XHR.
- *
- * @param {Gerrit.SendRequest} req
- * @return {Promise}
- */
- send(req) {
- const options = {method: req.method};
- if (req.body) {
- options.headers = new Headers();
- options.headers.set(
- 'Content-Type', req.contentType || 'application/json');
- options.body = typeof req.body === 'string' ?
- req.body : JSON.stringify(req.body);
- }
- if (req.headers) {
- if (!options.headers) { options.headers = new Headers(); }
- for (const header in req.headers) {
- if (!req.headers.hasOwnProperty(header)) { continue; }
- options.headers.set(header, req.headers[header]);
- }
- }
- const url = req.url.startsWith('http') ?
- req.url : getBaseUrl() + req.url;
- const fetchReq = {
- url,
- fetchOptions: options,
- anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
- };
- const xhr = this.fetch(fetchReq)
- .then(response => {
- if (!response.ok) {
- if (req.errFn) {
- return req.errFn.call(undefined, response);
- }
- this.dispatchEvent(new CustomEvent('server-error', {
- detail: {request: fetchReq, response},
- composed: true, bubbles: true,
- }));
- }
- return response;
- })
- .catch(err => {
- this.dispatchEvent(new CustomEvent('network-error', {
- detail: {error: err},
- composed: true, bubbles: true,
- }));
- if (req.errFn) {
- return req.errFn.call(undefined, null, err);
- } else {
- throw err;
- }
- });
-
- if (req.parseResponse) {
- return xhr.then(res => this.getResponseObject(res));
- }
-
- return xhr;
- }
-
- /**
- * @param {string} prefix
- */
- invalidateFetchPromisesPrefix(prefix) {
- this._fetchPromisesCache.invalidatePrefix(prefix);
- this._cache.invalidatePrefix(prefix);
- }
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
new file mode 100644
index 0000000..6d93604
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.ts
@@ -0,0 +1,566 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {getBaseUrl} from '../../../../utils/url-util';
+import {
+ CancelConditionCallback,
+ ErrorCallback,
+ RestApiService,
+} from '../../../../services/services/gr-rest-api/gr-rest-api';
+import {
+ AuthRequestInit,
+ AuthService,
+} from '../../../../services/gr-auth/gr-auth';
+import {hasOwnProperty} from '../../../../utils/common-util';
+import {
+ AccountDetailInfo,
+ EmailInfo,
+ ParsedJSON,
+ RequestPayload,
+} from '../../../../types/common';
+import {HttpMethod} from '../../../../constants/constants';
+import {RpcLogEventDetail} from '../../../../types/events';
+
+const JSON_PREFIX = ")]}'";
+
+export interface ResponsePayload {
+ // TODO(TS): readResponsePayload can assign null to the parsed property if
+ // it can't parse input data. However polygerrit assumes in many places
+ // that the parsed property can't be null. We should update
+ // readResponsePayload method and reject a promise instead of assigning
+ // null to the parsed property
+ parsed: ParsedJSON; // Can be null!!! See comment above
+ raw: string;
+}
+
+/**
+ * Wrapper around Map for caching server responses. Site-based so that
+ * changes to CANONICAL_PATH will result in a different cache going into
+ * effect.
+ */
+export class SiteBasedCache {
+ // TODO(TS): Type looks unusual. Fix it.
+ // Container of per-canonical-path caches.
+ private readonly _data = new Map<
+ string | undefined,
+ unknown | Map<string, ParsedJSON | null>
+ >();
+
+ constructor() {
+ if (window.INITIAL_DATA) {
+ // Put all data shipped with index.html into the cache. This makes it
+ // so that we spare more round trips to the server when the app loads
+ // initially.
+ Object.entries(window.INITIAL_DATA).forEach(e =>
+ this._cache().set(e[0], (e[1] as unknown) as ParsedJSON)
+ );
+ }
+ }
+
+ // Returns the cache for the current canonical path.
+ _cache(): Map<string, unknown> {
+ if (!this._data.has(window.CANONICAL_PATH)) {
+ this._data.set(window.CANONICAL_PATH, new Map());
+ }
+ return this._data.get(window.CANONICAL_PATH) as Map<
+ string,
+ ParsedJSON | null
+ >;
+ }
+
+ has(key: string) {
+ return this._cache().has(key);
+ }
+
+ get(key: '/accounts/self/emails'): EmailInfo[] | null;
+
+ get(key: '/accounts/self/detail'): AccountDetailInfo[] | null;
+
+ get(key: string): ParsedJSON | null;
+
+ get(key: string): unknown {
+ return this._cache().get(key);
+ }
+
+ set(key: '/accounts/self/emails', value: EmailInfo[]): void;
+
+ set(key: '/accounts/self/detail', value: AccountDetailInfo[]): void;
+
+ set(key: string, value: ParsedJSON | null): void;
+
+ set(key: string, value: unknown) {
+ this._cache().set(key, value);
+ }
+
+ delete(key: string) {
+ this._cache().delete(key);
+ }
+
+ invalidatePrefix(prefix: string) {
+ const newMap = new Map();
+ for (const [key, value] of this._cache().entries()) {
+ if (!key.startsWith(prefix)) {
+ newMap.set(key, value);
+ }
+ }
+ this._data.set(window.CANONICAL_PATH, newMap);
+ }
+}
+
+type FetchPromisesCacheData = {
+ [url: string]: Promise<ParsedJSON | undefined> | undefined;
+};
+
+export class FetchPromisesCache {
+ private _data: FetchPromisesCacheData;
+
+ constructor() {
+ this._data = {};
+ }
+
+ public testOnlyGetData() {
+ return this._data;
+ }
+
+ /**
+ * @return true only if a value for a key sets and it is not undefined
+ */
+ has(key: string): boolean {
+ return !!this._data[key];
+ }
+
+ get(key: string) {
+ return this._data[key];
+ }
+
+ /**
+ * @param value a Promise to store in the cache. Pass undefined value to
+ * mark key as deleted.
+ */
+ set(key: string, value: Promise<ParsedJSON | undefined> | undefined) {
+ this._data[key] = value;
+ }
+
+ invalidatePrefix(prefix: string) {
+ const newData: FetchPromisesCacheData = {};
+ Object.entries(this._data).forEach(([key, value]) => {
+ if (!key.startsWith(prefix)) {
+ newData[key] = value;
+ }
+ });
+ this._data = newData;
+ }
+}
+export type FetchParams = {
+ [name: string]: string[] | string | number | boolean | undefined | null;
+};
+
+interface SendRequestBase {
+ method: HttpMethod | undefined;
+ body?: RequestPayload;
+ contentType?: string;
+ headers?: Record<string, string>;
+ url: string;
+ reportUrlAsIs?: boolean;
+ anonymizedUrl?: string;
+ errFn?: ErrorCallback;
+}
+
+export interface SendRawRequest extends SendRequestBase {
+ parseResponse?: false | null;
+}
+
+export interface SendJSONRequest extends SendRequestBase {
+ parseResponse: true;
+}
+
+export type SendRequest = SendRawRequest | SendJSONRequest;
+
+export interface FetchRequest {
+ url: string;
+ fetchOptions?: AuthRequestInit;
+ anonymizedUrl?: string;
+}
+
+export interface FetchJSONRequest extends FetchRequest {
+ reportUrlAsIs?: boolean;
+ params?: FetchParams;
+ cancelCondition?: CancelConditionCallback;
+ errFn?: ErrorCallback;
+}
+
+// export function isRequestWithCancel<T extends FetchJSONRequest>(
+// x: T
+// ): x is T & RequestWithCancel {
+// return !!(x as RequestWithCancel).cancelCondition;
+// }
+//
+// export function isRequestWithErrFn<T extends FetchJSONRequest>(
+// x: T
+// ): x is T & RequestWithErrFn {
+// return !!(x as RequestWithErrFn).errFn;
+// }
+
+export class GrRestApiHelper {
+ constructor(
+ private readonly _cache: SiteBasedCache,
+ private readonly _auth: AuthService,
+ private readonly _fetchPromisesCache: FetchPromisesCache,
+ private readonly _restApiInterface: RestApiService
+ ) {}
+
+ /**
+ * Wraps calls to the underlying authenticated fetch function (_auth.fetch)
+ * with timing and logging.
+s */
+ fetch(req: FetchRequest): Promise<Response> {
+ const start = Date.now();
+ const xhr = this._auth.fetch(req.url, req.fetchOptions);
+
+ // Log the call after it completes.
+ xhr.then(res => this._logCall(req, start, res ? res.status : null));
+
+ // Return the XHR directly (without the log).
+ return xhr;
+ }
+
+ /**
+ * Log information about a REST call. Because the elapsed time is determined
+ * by this method, it should be called immediately after the request
+ * finishes.
+ *
+ * @param startTime the time that the request was started.
+ * @param status the HTTP status of the response. The status value
+ * is used here rather than the response object so there is no way this
+ * method can read the body stream.
+ */
+ private _logCall(
+ req: FetchRequest,
+ startTime: number,
+ status: number | null
+ ) {
+ const method =
+ req.fetchOptions && req.fetchOptions.method
+ ? req.fetchOptions.method
+ : 'GET';
+ const endTime = Date.now();
+ const elapsed = endTime - startTime;
+ const startAt = new Date(startTime);
+ const endAt = new Date(endTime);
+ console.info(
+ [
+ 'HTTP',
+ status,
+ method,
+ `${elapsed}ms`,
+ req.anonymizedUrl || req.url,
+ `(${startAt.toISOString()}, ${endAt.toISOString()})`,
+ ].join(' ')
+ );
+ if (req.anonymizedUrl) {
+ const detail: RpcLogEventDetail = {
+ status,
+ method,
+ elapsed,
+ anonymizedUrl: req.anonymizedUrl,
+ };
+ this.dispatchEvent(
+ new CustomEvent('rpc-log', {
+ detail,
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ }
+
+ /**
+ * Fetch JSON from url provided.
+ * Returns a Promise that resolves to a native Response.
+ * Doesn't do error checking. Supports cancel condition. Performs auth.
+ * Validates auth expiry errors.
+ *
+ * @return Promise which resolves to undefined if cancelCondition returns true
+ * and resolves to Response otherwise
+ */
+ fetchRawJSON(req: FetchJSONRequest): Promise<Response | undefined> {
+ const urlWithParams = this.urlWithParams(req.url, req.params);
+ const fetchReq: FetchRequest = {
+ url: urlWithParams,
+ fetchOptions: req.fetchOptions,
+ anonymizedUrl: req.reportUrlAsIs ? urlWithParams : req.anonymizedUrl,
+ };
+ return this.fetch(fetchReq)
+ .then((res: Response) => {
+ if (req.cancelCondition && req.cancelCondition()) {
+ if (res.body) {
+ res.body.cancel();
+ }
+ return;
+ }
+ return res;
+ })
+ .catch(err => {
+ if (req.errFn) {
+ req.errFn.call(undefined, null, err);
+ } else {
+ this.dispatchEvent(
+ new CustomEvent('network-error', {
+ detail: {error: err},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ throw err;
+ });
+ }
+
+ /**
+ * Fetch JSON from url provided.
+ * Returns a Promise that resolves to a parsed response.
+ * Same as {@link fetchRawJSON}, plus error handling.
+ *
+ * @param noAcceptHeader - don't add default accept json header
+ */
+ fetchJSON(
+ req: FetchJSONRequest,
+ noAcceptHeader?: boolean
+ ): Promise<ParsedJSON | undefined> {
+ if (!noAcceptHeader) {
+ req = this.addAcceptJsonHeader(req);
+ }
+ return this.fetchRawJSON(req).then(response => {
+ if (!response) {
+ return;
+ }
+ if (!response.ok) {
+ if (req.errFn) {
+ req.errFn.call(null, response);
+ return;
+ }
+ this.dispatchEvent(
+ new CustomEvent('server-error', {
+ detail: {request: req, response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return;
+ }
+ return this.getResponseObject(response);
+ });
+ }
+
+ urlWithParams(url: string, fetchParams?: FetchParams): string {
+ if (!fetchParams) {
+ return getBaseUrl() + url;
+ }
+
+ const params: Array<string | number | boolean> = [];
+ for (const p in fetchParams) {
+ if (!hasOwnProperty(fetchParams, p)) {
+ continue;
+ }
+ const paramValue = fetchParams[p];
+ // TODO(TS): Replace == null with === and check for null and undefined
+ // eslint-disable-next-line eqeqeq
+ if (paramValue == null) {
+ params.push(this.encodeRFC5987(p));
+ continue;
+ }
+ // TODO(TS): Unclear, why do we need the following code.
+ // If paramValue can be array - we should either fix FetchParams type
+ // or convert the array to a string before calling urlWithParams method.
+ const paramValueAsArray = ([] as Array<string | number | boolean>).concat(
+ paramValue
+ );
+ for (const value of paramValueAsArray) {
+ params.push(`${this.encodeRFC5987(p)}=${this.encodeRFC5987(value)}`);
+ }
+ }
+ return getBaseUrl() + url + '?' + params.join('&');
+ }
+
+ // Backend encode url in RFC5987 and frontend needs to do same to match
+ // queries for preloading queries
+ encodeRFC5987(uri: string | number | boolean) {
+ return encodeURIComponent(uri).replace(
+ /['()*]/g,
+ c => '%' + c.charCodeAt(0).toString(16)
+ );
+ }
+
+ getResponseObject(response: Response): Promise<ParsedJSON> {
+ return this.readResponsePayload(response).then(payload => payload.parsed);
+ }
+
+ readResponsePayload(response: Response): Promise<ResponsePayload> {
+ return response.text().then(text => {
+ let result;
+ try {
+ result = this.parsePrefixedJSON(text);
+ } catch (_) {
+ result = null;
+ }
+ // TODO(TS): readResponsePayload can assign null to the parsed property if
+ // it can't parse input data. However polygerrit assumes in many places
+ // that the parsed property can't be null. We should update
+ // readResponsePayload method and reject a promise instead of assigning
+ // null to the parsed property
+ return {parsed: result!, raw: text};
+ });
+ }
+
+ parsePrefixedJSON(jsonWithPrefix: string): ParsedJSON {
+ return JSON.parse(
+ jsonWithPrefix.substring(JSON_PREFIX.length)
+ ) as ParsedJSON;
+ }
+
+ addAcceptJsonHeader(req: FetchJSONRequest) {
+ if (!req.fetchOptions) req.fetchOptions = {};
+ if (!req.fetchOptions.headers) req.fetchOptions.headers = new Headers();
+ if (!req.fetchOptions.headers.has('Accept')) {
+ req.fetchOptions.headers.append('Accept', 'application/json');
+ }
+ return req;
+ }
+
+ dispatchEvent(type: Event, detail?: unknown): boolean {
+ return this._restApiInterface.dispatchEvent(type, detail);
+ }
+
+ fetchCacheURL(req: FetchJSONRequest): Promise<ParsedJSON | undefined> {
+ if (this._fetchPromisesCache.has(req.url)) {
+ return this._fetchPromisesCache.get(req.url)!;
+ }
+ // TODO(andybons): Periodic cache invalidation.
+ if (this._cache.has(req.url)) {
+ return Promise.resolve(this._cache.get(req.url)!);
+ }
+ this._fetchPromisesCache.set(
+ req.url,
+ this.fetchJSON(req)
+ .then(response => {
+ if (response !== undefined) {
+ this._cache.set(req.url, response);
+ }
+ this._fetchPromisesCache.set(req.url, undefined);
+ return response;
+ })
+ .catch(err => {
+ this._fetchPromisesCache.set(req.url, undefined);
+ throw err;
+ })
+ );
+ return this._fetchPromisesCache.get(req.url)!;
+ }
+
+ // if errFn is not set, then only Response possible
+ send(req: SendRawRequest & {errFn?: undefined}): Promise<Response>;
+
+ send(req: SendRawRequest): Promise<Response | undefined>;
+
+ send(req: SendJSONRequest): Promise<ParsedJSON>;
+
+ send(req: SendRequest): Promise<Response | ParsedJSON | undefined>;
+
+ /**
+ * Send an XHR.
+ *
+ * @return Promise resolves to Response/ParsedJSON only if the request is successful
+ * (i.e. no exception and response.ok is trsue). If response fails then
+ * promise resolves either to void if errFn is set or rejects if errFn
+ * is not set */
+ send(req: SendRequest): Promise<Response | ParsedJSON | undefined> {
+ const options: AuthRequestInit = {method: req.method};
+ if (req.body) {
+ options.headers = new Headers();
+ options.headers.set(
+ 'Content-Type',
+ req.contentType || 'application/json'
+ );
+ options.body =
+ typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
+ }
+ if (req.headers) {
+ if (!options.headers) {
+ options.headers = new Headers();
+ }
+ for (const header in req.headers) {
+ if (!hasOwnProperty(req.headers, header)) {
+ continue;
+ }
+ options.headers.set(header, req.headers[header]);
+ }
+ }
+ const url = req.url.startsWith('http') ? req.url : getBaseUrl() + req.url;
+ const fetchReq: FetchRequest = {
+ url,
+ fetchOptions: options,
+ anonymizedUrl: req.reportUrlAsIs ? url : req.anonymizedUrl,
+ };
+ const xhr = this.fetch(fetchReq)
+ .then(response => {
+ if (!response.ok) {
+ if (req.errFn) {
+ req.errFn.call(undefined, response);
+ return;
+ }
+ this.dispatchEvent(
+ new CustomEvent('server-error', {
+ detail: {request: fetchReq, response},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ }
+ return response;
+ })
+ .catch(err => {
+ this.dispatchEvent(
+ new CustomEvent('network-error', {
+ detail: {error: err},
+ composed: true,
+ bubbles: true,
+ })
+ );
+ if (req.errFn) {
+ return req.errFn.call(undefined, null, err);
+ } else {
+ throw err;
+ }
+ });
+
+ if (req.parseResponse) {
+ // TODO(TS): remove as Response and fix error.
+ // Javascript code allows returning of a Response object from errFn.
+ // This can be a mistake and we should add check here or it can be used
+ // somewhere - in this case we should fix it carefully (define
+ // different type of callback if parseResponse is true, etc...).
+ return xhr.then(res => this.getResponseObject(res as Response));
+ }
+ // The actual xhr type is Promise<Response|undefined|void> because of the
+ // catch callback
+ return xhr as Promise<Response | undefined>;
+ }
+
+ invalidateFetchPromisesPrefix(prefix: string) {
+ this._fetchPromisesCache.invalidatePrefix(prefix);
+ this._cache.invalidatePrefix(prefix);
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
index a50a9e7..4eef8a2f 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
@@ -77,14 +77,13 @@
});
});
- test('JSON prefix is properly removed', done => {
- helper.fetchJSON({url: '/dummy/url'}).then(obj => {
- assert.deepEqual(obj, {hello: 'bonjour'});
- done();
- });
- });
+ test('JSON prefix is properly removed',
+ () => helper.fetchJSON({url: '/dummy/url'}).then(obj => {
+ assert.deepEqual(obj, {hello: 'bonjour'});
+ })
+ );
- test('cached results', done => {
+ test('cached results', () => {
let n = 0;
sinon.stub(helper, 'fetchJSON').callsFake(() => Promise.resolve(++n));
const promises = [];
@@ -92,21 +91,19 @@
promises.push(helper.fetchCacheURL('/foo'));
promises.push(helper.fetchCacheURL('/foo'));
- Promise.all(promises).then(results => {
+ return Promise.all(promises).then(results => {
assert.deepEqual(results, [1, 1, 1]);
- helper.fetchCacheURL('/foo').then(foo => {
+ return helper.fetchCacheURL('/foo').then(foo => {
assert.equal(foo, 1);
- done();
});
});
});
- test('cached promise', done => {
+ test('cached promise', () => {
const promise = Promise.reject(new Error('foo'));
cache.set('/foo', promise);
- helper.fetchCacheURL({url: '/foo'}).catch(p => {
+ return helper.fetchCacheURL({url: '/foo'}).catch(p => {
assert.equal(p.message, 'foo');
- done();
});
});
@@ -144,7 +141,7 @@
assert.equal(url, window.CANONICAL_PATH + '/path/?l=c&l=b&l=a');
});
- test('request callbacks can be canceled', done => {
+ test('request callbacks can be canceled', () => {
let cancelCalled = false;
window.fetch.returns(Promise.resolve({
body: {
@@ -152,12 +149,10 @@
},
}));
const cancelCondition = () => true;
- helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(
- obj => {
- assert.isUndefined(obj);
- assert.isTrue(cancelCalled);
- done();
- });
+ return helper.fetchJSON({url: '/dummy/url', cancelCondition}).then(obj => {
+ assert.isUndefined(obj);
+ assert.isTrue(cancelCalled);
+ });
});
});
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
deleted file mode 100644
index ed474d8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
+++ /dev/null
@@ -1,229 +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 {parseDate} from '../../../utils/date-util.js';
-import {MessageTag} from '../../../constants/constants.js';
-
-/** @constructor */
-export function GrReviewerUpdatesParser(change) {
- this.result = Object.assign({}, change);
- this._lastState = {};
-}
-
-GrReviewerUpdatesParser.parse = function(change) {
- if (!change ||
- !change.messages ||
- !change.reviewer_updates ||
- !change.reviewer_updates.length) {
- return change;
- }
- const parser = new GrReviewerUpdatesParser(change);
- parser._filterRemovedMessages();
- parser._groupUpdates();
- parser._formatUpdates();
- parser._advanceUpdates();
- return parser.result;
-};
-
-GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
-GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
-
-GrReviewerUpdatesParser.prototype.result = null;
-GrReviewerUpdatesParser.prototype._batch = null;
-GrReviewerUpdatesParser.prototype._updateItems = null;
-GrReviewerUpdatesParser.prototype._lastState = null;
-
-/**
- * Removes messages that describe removed reviewers, since reviewer_updates
- * are used.
- */
-GrReviewerUpdatesParser.prototype._filterRemovedMessages = function() {
- this.result.messages = this.result.messages
- .filter(message => message.tag !== MessageTag.TAG_DELETE_REVIEWER);
-};
-
-/**
- * Is a part of _groupUpdates(). Creates a new batch of updates.
- *
- * @param {Object} update instance of ReviewerUpdateInfo
- */
-GrReviewerUpdatesParser.prototype._startBatch = function(update) {
- this._updateItems = [];
- return {
- author: update.updated_by,
- date: update.updated,
- type: 'REVIEWER_UPDATE',
- tag: MessageTag.TAG_REVIEWER_UPDATE,
- };
-};
-
-/**
- * Is a part of _groupUpdates(). Validates current batch:
- * - filters out updates that don't change reviewer state.
- * - updates current reviewer state.
- *
- * @param {Object} update instance of ReviewerUpdateInfo
- */
-GrReviewerUpdatesParser.prototype._completeBatch = function(update) {
- const items = [];
- for (const accountId in this._updateItems) {
- if (!this._updateItems.hasOwnProperty(accountId)) continue;
- const updateItem = this._updateItems[accountId];
- if (this._lastState[accountId] !== updateItem.state) {
- this._lastState[accountId] = updateItem.state;
- items.push(updateItem);
- }
- }
- if (items.length) {
- this._batch.updates = items;
- }
-};
-
-/**
- * Groups reviewer updates. Sequential updates are grouped if:
- * - They were performed within short timeframe (6 seconds)
- * - Made by the same person
- * - Non-change updates are discarded within a group
- * - Groups with no-change updates are discarded (eg CC -> CC)
- */
-GrReviewerUpdatesParser.prototype._groupUpdates = function() {
- const updates = this.result.reviewer_updates;
- const newUpdates = updates.reduce((newUpdates, update) => {
- if (!this._batch) {
- this._batch = this._startBatch(update);
- }
- const updateDate = parseDate(update.updated).getTime();
- const batchUpdateDate = parseDate(this._batch.date).getTime();
- const reviewerId = update.reviewer._account_id.toString();
- if (updateDate - batchUpdateDate >
- GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS ||
- update.updated_by._account_id !== this._batch.author._account_id) {
- // Next sequential update should form new group.
- this._completeBatch();
- if (this._batch.updates && this._batch.updates.length) {
- newUpdates.push(this._batch);
- }
- this._batch = this._startBatch(update);
- }
- this._updateItems[reviewerId] = {
- reviewer: update.reviewer,
- state: update.state,
- };
- if (this._lastState[reviewerId]) {
- this._updateItems[reviewerId].prev_state = this._lastState[reviewerId];
- }
- return newUpdates;
- }, []);
- this._completeBatch();
- if (this._batch.updates && this._batch.updates.length) {
- newUpdates.push(this._batch);
- }
- this.result.reviewer_updates = newUpdates;
-};
-
-/**
- * Generates update message for reviewer state change.
- *
- * @param {string} prev previous reviewer state.
- * @param {string} state current reviewer state.
- * @return {string}
- */
-GrReviewerUpdatesParser.prototype._getUpdateMessage = function(prev, state) {
- if (prev === 'REMOVED' || !prev) {
- return 'Added to ' + state.toLowerCase() + ': ';
- } else if (state === 'REMOVED') {
- if (prev) {
- return 'Removed from ' + prev.toLowerCase() + ': ';
- } else {
- return 'Removed : ';
- }
- } else {
- return 'Moved from ' + prev.toLowerCase() + ' to ' + state.toLowerCase() +
- ': ';
- }
-};
-
-/**
- * Groups updates for same category (eg CC->CC) into a hash arrays of
- * reviewers.
- *
- * @param {!Array<!Object>} updates Array of ReviewerUpdateItemInfo.
- * @return {!Object} Hash of arrays of AccountInfo, message as key.
- */
-GrReviewerUpdatesParser.prototype._groupUpdatesByMessage = function(updates) {
- return updates.reduce((result, item) => {
- const message = this._getUpdateMessage(item.prev_state, item.state);
- if (!result[message]) {
- result[message] = [];
- }
- result[message].push(item.reviewer);
- return result;
- }, {});
-};
-
-/**
- * Generates text messages for grouped reviewer updates.
- * Formats reviewer updates to a (not yet implemented) EventInfo instance.
- *
- * @see https://gerrit-review.googlesource.com/c/94490/
- */
-GrReviewerUpdatesParser.prototype._formatUpdates = function() {
- for (const update of this.result.reviewer_updates) {
- const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
- const newUpdates = [];
- for (const message in grouppedReviewers) {
- if (grouppedReviewers.hasOwnProperty(message)) {
- newUpdates.push({
- message,
- reviewers: grouppedReviewers[message],
- });
- }
- }
- update.updates = newUpdates;
- }
-};
-
-/**
- * Moves reviewer updates that are within short time frame of change messages
- * back in time so they would come before change messages.
- * TODO(viktard): Remove when server-side serves reviewer updates like so.
- */
-GrReviewerUpdatesParser.prototype._advanceUpdates = function() {
- const updates = this.result.reviewer_updates;
- const messages = this.result.messages;
- messages.forEach((message, index) => {
- const messageDate = parseDate(message.date).getTime();
- const nextMessageDate = index === messages.length - 1 ? null :
- parseDate(messages[index + 1].date).getTime();
- for (const update of updates) {
- const date = parseDate(update.date).getTime();
- if (date >= messageDate &&
- (!nextMessageDate || date < nextMessageDate)) {
- const timestamp = parseDate(update.date).getTime() -
- GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
- update.date = new Date(timestamp)
- .toISOString()
- .replace('T', ' ')
- .replace('Z', '000000');
- }
- if (nextMessageDate && date > nextMessageDate) {
- break;
- }
- }
- });
-};
-
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
new file mode 100644
index 0000000..48a23c6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
@@ -0,0 +1,321 @@
+/**
+ * @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 {parseDate} from '../../../utils/date-util';
+import {MessageTag, ReviewerState} from '../../../constants/constants';
+import {
+ AccountInfo,
+ ChangeInfo,
+ ChangeMessageInfo,
+ ChangeViewChangeInfo,
+ CommitInfo,
+ PatchSetNum,
+ ReviewerUpdateInfo,
+ RevisionInfo,
+ Timestamp,
+} from '../../../types/common';
+import {hasOwnProperty} from '../../../utils/common-util';
+import {accountKey} from '../../../utils/account-util';
+
+const MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
+const REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
+
+interface ChangeInfoParserInput extends ChangeViewChangeInfo {
+ messages: ChangeMessageInfo[];
+ reviewer_updates: ReviewerUpdateInfo[]; // Always has at least 1 item
+}
+
+function isChangeInfoParserInput(
+ change: ChangeInfo
+): change is ChangeInfoParserInput {
+ return !!(
+ change.messages &&
+ change.reviewer_updates &&
+ change.reviewer_updates.length
+ );
+}
+
+interface ParserBatch {
+ author: AccountInfo;
+ date: Timestamp;
+ type: 'REVIEWER_UPDATE';
+ tag: MessageTag.TAG_REVIEWER_UPDATE;
+ updates?: UpdateItem[];
+}
+
+interface ParserBatchWithNonEmptyUpdates extends ParserBatch {
+ updates: UpdateItem[]; // Always has at least 1 items
+}
+
+export interface FormattedReviewerUpdateInfo {
+ author: AccountInfo;
+ date: Timestamp;
+ type: 'REVIEWER_UPDATE';
+ tag: MessageTag.TAG_REVIEWER_UPDATE;
+ updates: {message: string; reviewers: AccountInfo[]}[];
+}
+
+function isParserBatchWithNonEmptyUpdates(
+ x: ParserBatch
+): x is ParserBatchWithNonEmptyUpdates {
+ return !!(x.updates && x.updates.length);
+}
+
+interface UpdateItem {
+ reviewer: AccountInfo;
+ state: ReviewerState;
+ prev_state?: ReviewerState;
+}
+
+export interface EditRevisionInfo extends Partial<RevisionInfo> {
+ // EditRevisionInfo has less required properties then RevisionInfo
+ _number: PatchSetNum;
+ basePatchNum: PatchSetNum;
+ commit: CommitInfo;
+}
+
+export interface ParsedChangeInfo
+ extends Omit<ChangeViewChangeInfo, 'reviewer_updates' | 'revisions'> {
+ revisions: {[revisionId: string]: RevisionInfo | EditRevisionInfo};
+ reviewer_updates?: ReviewerUpdateInfo[] | FormattedReviewerUpdateInfo[];
+}
+
+type ReviewersGroupByMessage = {[message: string]: AccountInfo[]};
+
+export class GrReviewerUpdatesParser {
+ // TODO(TS): The parser several times reassigns different types to
+ // reviewer_updates. After parse complete, the result has ParsedChangeInfo
+ // type. This class should be refactored to avoid reassignment.
+ private readonly result: ChangeInfoParserInput;
+
+ private _batch: ParserBatch | null = null;
+
+ private _updateItems: {[accountId: string]: UpdateItem} | null = null;
+
+ private readonly _lastState: {[accountId: string]: ReviewerState} = {};
+
+ constructor(change: ChangeInfoParserInput) {
+ this.result = {...change};
+ }
+
+ /**
+ * Removes messages that describe removed reviewers, since reviewer_updates
+ * are used.
+ */
+ private _filterRemovedMessages() {
+ this.result.messages = this.result.messages.filter(
+ message => message.tag !== MessageTag.TAG_DELETE_REVIEWER
+ );
+ }
+
+ /**
+ * Is a part of _groupUpdates(). Creates a new batch of updates.
+ */
+ private _startBatch(update: ReviewerUpdateInfo): ParserBatch {
+ this._updateItems = {};
+ return {
+ author: update.updated_by,
+ date: update.updated,
+ type: 'REVIEWER_UPDATE',
+ tag: MessageTag.TAG_REVIEWER_UPDATE,
+ };
+ }
+
+ /**
+ * Is a part of _groupUpdates(). Validates current batch:
+ * - filters out updates that don't change reviewer state.
+ * - updates current reviewer state.
+ */
+ private _completeBatch(batch: ParserBatch) {
+ const items = [];
+ for (const accountId in this._updateItems) {
+ if (!hasOwnProperty(this._updateItems, accountId)) continue;
+ const updateItem = this._updateItems[accountId];
+ if (this._lastState[accountId] !== updateItem.state) {
+ this._lastState[accountId] = updateItem.state;
+ items.push(updateItem);
+ }
+ }
+ if (items.length) {
+ batch.updates = items;
+ }
+ }
+
+ /**
+ * Groups reviewer updates. Sequential updates are grouped if:
+ * - They were performed within short timeframe (6 seconds)
+ * - Made by the same person
+ * - Non-change updates are discarded within a group
+ * - Groups with no-change updates are discarded (eg CC -> CC)
+ */
+ _groupUpdates(): ParserBatchWithNonEmptyUpdates[] {
+ const updates = this.result.reviewer_updates;
+ const newUpdates = updates.reduce((newUpdates, update) => {
+ if (!this._batch) {
+ this._batch = this._startBatch(update);
+ }
+ const updateDate = parseDate(update.updated).getTime();
+ const batchUpdateDate = parseDate(this._batch.date).getTime();
+ const reviewerId = accountKey(update.reviewer);
+ if (
+ updateDate - batchUpdateDate > REVIEWER_UPDATE_THRESHOLD_MILLIS ||
+ update.updated_by._account_id !== this._batch.author._account_id
+ ) {
+ // Next sequential update should form new group.
+ this._completeBatch(this._batch);
+ if (isParserBatchWithNonEmptyUpdates(this._batch)) {
+ newUpdates.push(this._batch);
+ }
+ this._batch = this._startBatch(update);
+ }
+ // _startBatch assigns _updateItems. When _groupUpdates is calling,
+ // _batch and _updateItems are not set => _startBatch is called. The
+ // _startBatch method assigns _updateItems
+ const updateItems = this._updateItems!;
+ updateItems[reviewerId] = {
+ reviewer: update.reviewer,
+ state: update.state,
+ };
+ if (this._lastState[reviewerId]) {
+ updateItems[reviewerId].prev_state = this._lastState[reviewerId];
+ }
+ return newUpdates;
+ }, [] as ParserBatchWithNonEmptyUpdates[]);
+ // reviewer_updates always has at least 1 item
+ // (otherwise parse is not created) => updates.reduce calls callback
+ // at least once and callback assigns this._batch
+ const batch = this._batch!;
+ this._completeBatch(batch);
+ if (isParserBatchWithNonEmptyUpdates(batch)) {
+ newUpdates.push(batch);
+ }
+ ((this.result
+ .reviewer_updates as unknown) as ParserBatchWithNonEmptyUpdates[]) = newUpdates;
+ return newUpdates;
+ }
+
+ /**
+ * Generates update message for reviewer state change.
+ */
+ private _getUpdateMessage(
+ prevReviewerState: string | undefined,
+ currentReviewerState: string
+ ): string {
+ if (prevReviewerState === 'REMOVED' || !prevReviewerState) {
+ return `Added to ${currentReviewerState.toLowerCase()}: `;
+ } else if (currentReviewerState === 'REMOVED') {
+ if (prevReviewerState) {
+ return `Removed from ${prevReviewerState.toLowerCase()}: `;
+ } else {
+ return 'Removed : ';
+ }
+ } else {
+ return `Moved from ${prevReviewerState.toLowerCase()} to ${currentReviewerState.toLowerCase()}: `;
+ }
+ }
+
+ /**
+ * Groups updates for same category (eg CC->CC) into a hash arrays of
+ * reviewers.
+ */
+ _groupUpdatesByMessage(updates: UpdateItem[]): ReviewersGroupByMessage {
+ return updates.reduce((result, item) => {
+ const message = this._getUpdateMessage(item.prev_state, item.state);
+ if (!result[message]) {
+ result[message] = [];
+ }
+ result[message].push(item.reviewer);
+ return result;
+ }, {} as ReviewersGroupByMessage);
+ }
+
+ /**
+ * Generates text messages for grouped reviewer updates.
+ * Formats reviewer updates to a (not yet implemented) EventInfo instance.
+ *
+ * @see https://gerrit-review.googlesource.com/c/94490/
+ */
+ _formatUpdates() {
+ const reviewerUpdates = (this.result
+ .reviewer_updates as unknown) as ParserBatchWithNonEmptyUpdates[];
+ for (const update of reviewerUpdates) {
+ const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+ const newUpdates: {message: string; reviewers: AccountInfo[]}[] = [];
+ for (const message in grouppedReviewers) {
+ if (hasOwnProperty(grouppedReviewers, message)) {
+ newUpdates.push({
+ message,
+ reviewers: grouppedReviewers[message],
+ });
+ }
+ }
+ ((update as unknown) as FormattedReviewerUpdateInfo).updates = newUpdates;
+ }
+ }
+
+ /**
+ * Moves reviewer updates that are within short time frame of change messages
+ * back in time so they would come before change messages.
+ * TODO(viktard): Remove when server-side serves reviewer updates like so.
+ */
+ _advanceUpdates() {
+ const updates = (this.result
+ .reviewer_updates as unknown) as FormattedReviewerUpdateInfo[];
+ const messages = this.result.messages;
+ messages.forEach((message, index) => {
+ const messageDate = parseDate(message.date).getTime();
+ const nextMessageDate =
+ index === messages.length - 1
+ ? null
+ : parseDate(messages[index + 1].date).getTime();
+ for (const update of updates) {
+ const date = parseDate(update.date).getTime();
+ if (
+ date >= messageDate &&
+ (!nextMessageDate || date < nextMessageDate)
+ ) {
+ const timestamp =
+ parseDate(update.date).getTime() -
+ MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
+ update.date = new Date(timestamp)
+ .toISOString()
+ .replace('T', ' ')
+ .replace('Z', '000000') as Timestamp;
+ }
+ if (nextMessageDate && date > nextMessageDate) {
+ break;
+ }
+ }
+ });
+ }
+
+ static parse(
+ change: ChangeViewChangeInfo | undefined | null
+ ): ParsedChangeInfo | undefined | null {
+ // TODO(TS): The !change condition should be removed when all files are converted to TS
+ if (!change || !isChangeInfoParserInput(change)) {
+ return change;
+ }
+
+ const parser = new GrReviewerUpdatesParser(change);
+ parser._filterRemovedMessages();
+ parser._groupUpdates();
+ parser._formatUpdates();
+ parser._advanceUpdates();
+ return parser.result;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
index f408a97..34fb709 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.js
@@ -22,10 +22,6 @@
suite('gr-reviewer-updates-parser tests', () => {
let instance;
- setup(() => {
-
- });
-
test('ignores changes without messages', () => {
const change = {};
sinon.stub(
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
deleted file mode 100644
index 9b4bbaf..0000000
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ /dev/null
@@ -1,93 +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 {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 {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-/**
- * @extends PolymerElement
- */
-class GrSelect extends GestureEventListeners(
- LegacyElementMixin(PolymerElement)) {
- static get is() { return 'gr-select'; }
-
- static get template() {
- return html`
- <slot></slot>
- `;
- }
-
- static get properties() {
- return {
- bindValue: {
- type: String,
- notify: true,
- observer: '_updateValue',
- },
- };
- }
-
- get nativeSelect() {
- // gr-select is not a shadow component
- // TODO(taoalpha): maybe we should convert
- // it into a shadow dom component instead
- return this.querySelector('select');
- }
-
- _updateValue() {
- // It's possible to have a value of 0.
- if (this.bindValue !== undefined) {
- // Set for chrome/safari so it happens instantly
- this.nativeSelect.value = this.bindValue;
- // Async needed for firefox to populate value. It was trying to do it
- // before options from a dom-repeat were rendered previously.
- // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
- this.async(() => {
- this.nativeSelect.value = this.bindValue;
- }, 1);
- }
- }
-
- _valueChanged() {
- this.bindValue = this.nativeSelect.value;
- }
-
- focus() {
- this.nativeSelect.focus();
- }
-
- /** @override */
- created() {
- super.created();
- this.addEventListener('change',
- () => this._valueChanged());
- this.addEventListener('dom-change',
- () => this._updateValue());
- }
-
- /** @override */
- ready() {
- super.ready();
- // If not set via the property, set bind-value to the element value.
- if (this.bindValue == undefined && this.nativeSelect.options.length > 0) {
- this.bindValue = this.nativeSelect.value;
- }
- }
-}
-
-customElements.define(GrSelect.is, GrSelect);
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
new file mode 100644
index 0000000..a2c1253
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -0,0 +1,91 @@
+/**
+ * @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 {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 {html} from '@polymer/polymer/lib/utils/html-tag';
+import {customElement, property, observe} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-select': GrSelect;
+ }
+}
+
+/**
+ * GrSelect `gr-select` component.
+ */
+@customElement('gr-select')
+export class GrSelect extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return html` <slot></slot> `;
+ }
+
+ @property({type: String, notify: true})
+ bindValue?: string;
+
+ get nativeSelect() {
+ // gr-select is not a shadow component
+ // TODO(taoalpha): maybe we should convert
+ // it into a shadow dom component instead
+ // TODO(TS): should warn if no `select` detected.
+ return this.querySelector('select')!;
+ }
+
+ @observe('bindValue')
+ _updateValue() {
+ // It's possible to have a value of 0.
+ if (this.bindValue !== undefined) {
+ // Set for chrome/safari so it happens instantly
+ this.nativeSelect.value = this.bindValue;
+ // Async needed for firefox to populate value. It was trying to do it
+ // before options from a dom-repeat were rendered previously.
+ // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
+ this.async(() => {
+ // TODO(TS): maybe should check for undefined before assigning
+ // or fallback to ''
+ this.nativeSelect.value = this.bindValue!;
+ }, 1);
+ }
+ }
+
+ _valueChanged() {
+ this.bindValue = this.nativeSelect.value;
+ }
+
+ focus() {
+ this.nativeSelect.focus();
+ }
+
+ /** @override */
+ created() {
+ super.created();
+ this.addEventListener('change', () => this._valueChanged());
+ this.addEventListener('dom-change', () => this._updateValue());
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ // If not set via the property, set bind-value to the element value.
+ if (this.bindValue === undefined && this.nativeSelect.options.length > 0) {
+ this.bindValue = this.nativeSelect.value;
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
deleted file mode 100644
index a5212fc..0000000
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.js
+++ /dev/null
@@ -1,44 +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 '../../../styles/shared-styles.js';
-import '../gr-copy-clipboard/gr-copy-clipboard.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-shell-command_html.js';
-
-/** @extends PolymerElement */
-class GrShellCommand extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-shell-command'; }
-
- static get properties() {
- return {
- command: String,
- label: String,
- };
- }
-
- focusOnCopy() {
- this.shadowRoot.querySelector('gr-copy-clipboard').focusOnCopy();
- }
-}
-
-customElements.define(GrShellCommand.is, GrShellCommand);
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
new file mode 100644
index 0000000..27f4069
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -0,0 +1,53 @@
+/**
+ * @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 '../../../styles/shared-styles';
+import '../gr-copy-clipboard/gr-copy-clipboard';
+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-shell-command_html';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-shell-command': GrShellCommand;
+ }
+}
+
+@customElement('gr-shell-command')
+class GrShellCommand extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ command: string | undefined;
+
+ @property({type: String})
+ label: string | undefined;
+
+ focusOnCopy() {
+ if (this.shadowRoot !== null) {
+ const copyClipboard = this.shadowRoot.querySelector('gr-copy-clipboard');
+ if (copyClipboard !== null) {
+ copyClipboard.focusOnCopy();
+ }
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
index 5e20717..de9f243 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
@@ -27,7 +27,7 @@
element = basicFixture.instantiate();
element.text = `git fetch http://gerrit@localhost:8080/a/test-project
refs/changes/05/5/1 && git checkout FETCH_HEAD`;
- flushAsynchronousOperations();
+ flush();
});
test('focusOnCopy', () => {
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
deleted file mode 100644
index 90fdcd3..0000000
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ /dev/null
@@ -1,161 +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 {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';
-
-const DURATION_DAY = 24 * 60 * 60 * 1000;
-
-// Clean up old entries no more frequently than one day.
-const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
-
-const CLEANUP_PREFIXES_MAX_AGE_MAP = {
- // respectfultip has a 14-day expiration
- 'respectfultip:': 14 * DURATION_DAY,
- 'draft:': DURATION_DAY,
- 'editablecontent:': DURATION_DAY,
-};
-
-/** @extends PolymerElement */
-class GrStorage extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get is() { return 'gr-storage'; }
-
- static get properties() {
- return {
- _lastCleanup: Number,
- /** @type {?Storage} */
- _storage: {
- type: Object,
- value() {
- return window.localStorage;
- },
- },
- _exceededQuota: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- getDraftComment(location) {
- this._cleanupItems();
- return this._getObject(this._getDraftKey(location));
- }
-
- setDraftComment(location, message) {
- const key = this._getDraftKey(location);
- this._setObject(key, {message, updated: Date.now()});
- }
-
- eraseDraftComment(location) {
- const key = this._getDraftKey(location);
- this._storage.removeItem(key);
- }
-
- getEditableContentItem(key) {
- this._cleanupItems();
- return this._getObject(this._getEditableContentKey(key));
- }
-
- setEditableContentItem(key, message) {
- this._setObject(this._getEditableContentKey(key),
- {message, updated: Date.now()});
- }
-
- getRespectfulTipVisibility() {
- this._cleanupItems();
- return this._getObject('respectfultip:visibility');
- }
-
- setRespectfulTipVisibility(delayDays = 0) {
- this._cleanupItems();
- this._setObject(
- 'respectfultip:visibility',
- {updated: Date.now() + delayDays * DURATION_DAY}
- );
- }
-
- eraseEditableContentItem(key) {
- this._storage.removeItem(this._getEditableContentKey(key));
- }
-
- _getDraftKey(location) {
- const range = location.range ?
- `${location.range.start_line}-${location.range.start_character}` +
- `-${location.range.end_character}-${location.range.end_line}` :
- null;
- let key = ['draft', location.changeNum, location.patchNum, location.path,
- location.line || ''].join(':');
- if (range) {
- key = key + ':' + range;
- }
- return key;
- }
-
- _getEditableContentKey(key) {
- return `editablecontent:${key}`;
- }
-
- _cleanupItems() {
- // Throttle cleanup to the throttle interval.
- if (this._lastCleanup &&
- Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
- return;
- }
- this._lastCleanup = Date.now();
-
- let item;
- Object.keys(this._storage).forEach(key => {
- Object.keys(CLEANUP_PREFIXES_MAX_AGE_MAP).forEach(prefix => {
- if (key.startsWith(prefix)) {
- item = this._getObject(key);
- const expiration = CLEANUP_PREFIXES_MAX_AGE_MAP[prefix];
- if (Date.now() - item.updated > expiration) {
- this._storage.removeItem(key);
- }
- }
- });
- });
- }
-
- _getObject(key) {
- const serial = this._storage.getItem(key);
- if (!serial) { return null; }
- return JSON.parse(serial);
- }
-
- _setObject(key, obj) {
- if (this._exceededQuota) { return; }
- try {
- this._storage.setItem(key, JSON.stringify(obj));
- } catch (exc) {
- // Catch for QuotaExceededError and disable writes on local storage the
- // first time that it occurs.
- if (exc.code === 22) {
- this._exceededQuota = true;
- console.warn('Local storage quota exceeded: disabling');
- return;
- } else {
- throw exc;
- }
- }
- }
-}
-
-customElements.define(GrStorage.is, GrStorage);
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
new file mode 100644
index 0000000..1369a17
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.ts
@@ -0,0 +1,183 @@
+/**
+ * @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 {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 {customElement, property} from '@polymer/decorators';
+import {CommentRange, PatchSetNum} from '../../../types/common';
+
+export interface StorageLocation {
+ changeNum: number;
+ patchNum: PatchSetNum | '@change';
+ path?: string;
+ line?: number;
+ range?: CommentRange;
+}
+
+export interface StorageObject {
+ message?: string;
+ updated: number;
+}
+
+const DURATION_DAY = 24 * 60 * 60 * 1000;
+
+// Clean up old entries no more frequently than one day.
+const CLEANUP_THROTTLE_INTERVAL = DURATION_DAY;
+
+const CLEANUP_PREFIXES_MAX_AGE_MAP = new Map<string, number>();
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('respectfultip', 14 * DURATION_DAY);
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('draft', DURATION_DAY);
+CLEANUP_PREFIXES_MAX_AGE_MAP.set('editablecontent', DURATION_DAY);
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-storage': GrStorage;
+ }
+}
+
+export interface GrStorage {
+ $: {};
+}
+
+@customElement('gr-storage')
+export class GrStorage extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ @property({type: Number})
+ _lastCleanup = 0;
+
+ @property({type: Object})
+ _storage = window.localStorage;
+
+ @property({type: Boolean})
+ _exceededQuota = false;
+
+ getDraftComment(location: StorageLocation): StorageObject | null {
+ this._cleanupItems();
+ return this._getObject(this._getDraftKey(location));
+ }
+
+ setDraftComment(location: StorageLocation, message: string) {
+ const key = this._getDraftKey(location);
+ this._setObject(key, {message, updated: Date.now()});
+ }
+
+ eraseDraftComment(location: StorageLocation) {
+ const key = this._getDraftKey(location);
+ this._storage.removeItem(key);
+ }
+
+ getEditableContentItem(key: string): StorageObject | null {
+ this._cleanupItems();
+ return this._getObject(this._getEditableContentKey(key));
+ }
+
+ setEditableContentItem(key: string, message: string) {
+ this._setObject(this._getEditableContentKey(key), {
+ message,
+ updated: Date.now(),
+ });
+ }
+
+ getRespectfulTipVisibility(): StorageObject | null {
+ this._cleanupItems();
+ return this._getObject('respectfultip:visibility');
+ }
+
+ setRespectfulTipVisibility(delayDays = 0) {
+ this._cleanupItems();
+ this._setObject('respectfultip:visibility', {
+ updated: Date.now() + delayDays * DURATION_DAY,
+ });
+ }
+
+ eraseEditableContentItem(key: string) {
+ this._storage.removeItem(this._getEditableContentKey(key));
+ }
+
+ _getDraftKey(location: StorageLocation): string {
+ const range = location.range
+ ? `${location.range.start_line}-${location.range.start_character}` +
+ `-${location.range.end_character}-${location.range.end_line}`
+ : null;
+ let key = [
+ 'draft',
+ location.changeNum,
+ location.patchNum,
+ location.path,
+ location.line || '',
+ ].join(':');
+ if (range) {
+ key = key + ':' + range;
+ }
+ return key;
+ }
+
+ _getEditableContentKey(key: string): string {
+ return `editablecontent:${key}`;
+ }
+
+ _cleanupItems() {
+ // Throttle cleanup to the throttle interval.
+ if (
+ this._lastCleanup &&
+ Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL
+ ) {
+ return;
+ }
+ this._lastCleanup = Date.now();
+
+ Object.keys(this._storage).forEach(key => {
+ const entries = CLEANUP_PREFIXES_MAX_AGE_MAP.entries();
+ for (const [prefix, expiration] of entries) {
+ if (key.startsWith(prefix)) {
+ const item = this._getObject(key);
+ if (!item || Date.now() - item.updated > expiration) {
+ this._storage.removeItem(key);
+ }
+ }
+ }
+ });
+ }
+
+ _getObject(key: string): StorageObject | null {
+ const serial = this._storage.getItem(key);
+ if (!serial) {
+ return null;
+ }
+ return JSON.parse(serial) as StorageObject;
+ }
+
+ _setObject(key: string, obj: StorageObject) {
+ if (this._exceededQuota) {
+ return;
+ }
+ try {
+ this._storage.setItem(key, JSON.stringify(obj));
+ } catch (exc) {
+ // Catch for QuotaExceededError and disable writes on local storage the
+ // first time that it occurs.
+ if (exc.code === 22) {
+ this._exceededQuota = true;
+ console.warn('Local storage quota exceeded: disabling');
+ return;
+ } else {
+ throw exc;
+ }
+ }
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
deleted file mode 100644
index 8f763f1..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
+++ /dev/null
@@ -1,346 +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 '../gr-autocomplete-dropdown/gr-autocomplete-dropdown.js';
-import '../gr-cursor-manager/gr-cursor-manager.js';
-import '../gr-overlay/gr-overlay.js';
-import '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js';
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea.js';
-import '../../../styles/shared-styles.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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-textarea_html.js';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {appContext} from '../../../services/app-context.js';
-
-const MAX_ITEMS_DROPDOWN = 10;
-
-const ALL_SUGGESTIONS = [
- {value: '😊', match: 'smile :)'},
- {value: '👍', match: 'thumbs up'},
- {value: '😄', match: 'laugh :D'},
- {value: '🎉', match: 'party'},
- {value: '😞', match: 'sad :('},
- {value: '😂', match: 'tears :\')'},
- {value: '🙏', match: 'pray'},
- {value: '😐', match: 'neutral :|'},
- {value: '😮', match: 'shock :O'},
- {value: '👎', match: 'thumbs down'},
- {value: '😎', match: 'cool |;)'},
- {value: '😕', match: 'confused'},
- {value: '👌', match: 'ok'},
- {value: '🔥', match: 'fire'},
- {value: '👊', match: 'fistbump'},
- {value: '💯', match: '100'},
- {value: '💔', match: 'broken heart'},
- {value: '🍺', match: 'beer'},
- {value: '✔', match: 'check'},
- {value: '😋', match: 'tongue'},
- {value: '😭', match: 'crying :\'('},
- {value: '🐨', match: 'koala'},
- {value: '🤓', match: 'glasses'},
- {value: '😆', match: 'grin'},
- {value: '💩', match: 'poop'},
- {value: '😢', match: 'tear'},
- {value: '😒', match: 'unamused'},
- {value: '😉', match: 'wink ;)'},
- {value: '🍷', match: 'wine'},
- {value: '😜', match: 'winking tongue ;)'},
-];
-
-/**
- * @extends PolymerElement
- */
-class GrTextarea extends KeyboardShortcutMixin(GestureEventListeners(
- LegacyElementMixin(PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-textarea'; }
- /**
- * @event bind-value-changed
- */
-
- static get properties() {
- return {
- autocomplete: Boolean,
- disabled: Boolean,
- rows: Number,
- maxRows: Number,
- placeholder: String,
- text: {
- type: String,
- notify: true,
- observer: '_handleTextChanged',
- },
- hideBorder: {
- type: Boolean,
- value: false,
- },
- /** Text input should be rendered in monspace font. */
- monospace: {
- type: Boolean,
- value: false,
- },
- /** Text input should be rendered in code font, which is smaller than the
- standard monospace font. */
- code: {
- type: Boolean,
- value: false,
- },
- /** @type {?number} */
- _colonIndex: Number,
- _currentSearchString: {
- type: String,
- observer: '_determineSuggestions',
- },
- _hideAutocomplete: {
- type: Boolean,
- value: true,
- },
- _index: Number,
- _suggestions: Array,
- // Offset makes dropdown appear below text.
- _verticalOffset: {
- type: Number,
- value: 20,
- readOnly: true,
- },
- };
- }
-
- get keyBindings() {
- return {
- esc: '_handleEscKey',
- tab: '_handleEnterByKey',
- enter: '_handleEnterByKey',
- up: '_handleUpKey',
- down: '_handleDownKey',
- };
- }
-
- constructor() {
- super();
- this.reporting = appContext.reportingService;
- }
-
- /** @override */
- ready() {
- super.ready();
- if (this.monospace) {
- this.classList.add('monospace');
- }
- if (this.code) {
- this.classList.add('code');
- }
- if (this.hideBorder) {
- this.$.textarea.classList.add('noBorder');
- }
- }
-
- closeDropdown() {
- return this.$.emojiSuggestions.close();
- }
-
- getNativeTextarea() {
- return this.$.textarea.textarea;
- }
-
- putCursorAtEnd() {
- const textarea = this.getNativeTextarea();
- // Put the cursor at the end always.
- textarea.selectionStart = textarea.value.length;
- textarea.selectionEnd = textarea.selectionStart;
- this.async(() => {
- textarea.focus();
- });
- }
-
- _handleEscKey(e) {
- if (this._hideAutocomplete) { return; }
- e.preventDefault();
- e.stopPropagation();
- this._resetEmojiDropdown();
- }
-
- _handleUpKey(e) {
- if (this._hideAutocomplete) { return; }
- e.preventDefault();
- e.stopPropagation();
- this.$.emojiSuggestions.cursorUp();
- this.$.textarea.textarea.focus();
- this.disableEnterKeyForSelectingEmoji = false;
- }
-
- _handleDownKey(e) {
- if (this._hideAutocomplete) { return; }
- e.preventDefault();
- e.stopPropagation();
- this.$.emojiSuggestions.cursorDown();
- this.$.textarea.textarea.focus();
- this.disableEnterKeyForSelectingEmoji = false;
- }
-
- _handleEnterByKey(e) {
- if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
- return;
- }
- e.preventDefault();
- e.stopPropagation();
- this._setEmoji(this.$.emojiSuggestions.getCurrentText());
- }
-
- _handleEmojiSelect(e) {
- this._setEmoji(e.detail.selected.dataset.value);
- }
-
- _setEmoji(text) {
- const colonIndex = this._colonIndex;
- this.text = this._getText(text);
- this.$.textarea.selectionStart = colonIndex + 1;
- this.$.textarea.selectionEnd = colonIndex + 1;
- this.reporting.reportInteraction('select-emoji', {type: text});
- this._resetEmojiDropdown();
- }
-
- _getText(value) {
- return this.text.substr(0, this._colonIndex || 0) +
- value + this.text.substr(this.$.textarea.selectionStart);
- }
-
- /**
- * Uses a hidden element with the same width and styling of the textarea and
- * the text up until the point of interest. Then caratSpan element is added
- * to the end and is set to be the positionTarget for the dropdown. Together
- * this allows the dropdown to appear near where the user is typing.
- */
- _updateCaratPosition() {
- this._hideAutocomplete = false;
- this.$.hiddenText.textContent = this.$.textarea.value.substr(0,
- this.$.textarea.selectionStart);
-
- const caratSpan = this.$.caratSpan;
- this.$.hiddenText.appendChild(caratSpan);
- this.$.emojiSuggestions.positionTarget = caratSpan;
- this._openEmojiDropdown();
- }
-
- _getFontSize() {
- const fontSizePx = getComputedStyle(this).fontSize || '12px';
- return parseInt(fontSizePx.substr(0, fontSizePx.length - 2),
- 10);
- }
-
- _getScrollTop() {
- return document.body.scrollTop;
- }
-
- /**
- * _handleKeydown used for key handling in the this.$.textarea AND all child
- * autocomplete options.
- */
- _onValueChanged(e) {
- // Relay the event.
- this.dispatchEvent(new CustomEvent('bind-value-changed', {
- detail: e,
- composed: true, bubbles: true,
- }));
-
- // If cursor is not in textarea (just opened with colon as last char),
- // Don't do anything.
- if (!e.currentTarget.focused) { return; }
-
- const charAtCursor = e.detail && e.detail.value ?
- e.detail.value[this.$.textarea.selectionStart - 1] : '';
- if (charAtCursor !== ':' && this._colonIndex == null) { return; }
-
- // When a colon is detected, set a colon index. We are interested only on
- // colons after space or in beginning of textarea
- if (charAtCursor === ':') {
- if (this.$.textarea.selectionStart < 2 ||
- e.detail.value[this.$.textarea.selectionStart - 2] === ' ') {
- this._colonIndex = this.$.textarea.selectionStart - 1;
- }
- }
-
- this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
- this.$.textarea.selectionStart - this._colonIndex - 1);
- // Under the following conditions, close and reset the dropdown:
- // - The cursor is no longer at the end of the current search string
- // - The search string is an space or new line
- // - The colon has been removed
- // - There are no suggestions that match the search string
- if (this.$.textarea.selectionStart !==
- this._currentSearchString.length + this._colonIndex + 1 ||
- this._currentSearchString === ' ' ||
- this._currentSearchString === '\n' ||
- !(e.detail.value[this._colonIndex] === ':') ||
- !this._suggestions.length) {
- this._resetEmojiDropdown();
- // Otherwise open the dropdown and set the position to be just below the
- // cursor.
- } else if (this.$.emojiSuggestions.isHidden) {
- this._updateCaratPosition();
- }
- this.$.textarea.textarea.focus();
- }
-
- _openEmojiDropdown() {
- this.$.emojiSuggestions.open();
- this.reporting.reportInteraction('open-emoji-dropdown');
- }
-
- _formatSuggestions(matchedSuggestions) {
- const suggestions = [];
- for (const suggestion of matchedSuggestions) {
- suggestion.dataValue = suggestion.value;
- suggestion.text = suggestion.value + ' ' + suggestion.match;
- suggestions.push(suggestion);
- }
- this.set('_suggestions', suggestions);
- }
-
- _determineSuggestions(emojiText) {
- if (!emojiText.length) {
- this._formatSuggestions(ALL_SUGGESTIONS);
- this.disableEnterKeyForSelectingEmoji = true;
- } else {
- const matches = ALL_SUGGESTIONS
- .filter(suggestion => suggestion.match.includes(emojiText))
- .slice(0, MAX_ITEMS_DROPDOWN);
- this._formatSuggestions(matches);
- this.disableEnterKeyForSelectingEmoji = false;
- }
- }
-
- _resetEmojiDropdown() {
- // hide and reset the autocomplete dropdown.
- flush();
- this._currentSearchString = '';
- this._hideAutocomplete = true;
- this.closeDropdown();
- this._colonIndex = null;
- this.$.textarea.textarea.focus();
- }
-
- _handleTextChanged(text) {
- this.dispatchEvent(
- new CustomEvent('value-changed', {detail: {value: text}}));
- }
-}
-
-customElements.define(GrTextarea.is, GrTextarea);
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
new file mode 100644
index 0000000..b3c0a85
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -0,0 +1,419 @@
+/**
+ * @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 '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import '../gr-cursor-manager/gr-cursor-manager';
+import '../gr-overlay/gr-overlay';
+import '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
+import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/shared-styles';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+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-textarea_html';
+import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {appContext} from '../../../services/app-context';
+import {customElement, property} from '@polymer/decorators';
+import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+
+const MAX_ITEMS_DROPDOWN = 10;
+
+const ALL_SUGGESTIONS: EmojiSuggestion[] = [
+ {value: '😊', match: 'smile :)'},
+ {value: '👍', match: 'thumbs up'},
+ {value: '😄', match: 'laugh :D'},
+ {value: '🎉', match: 'party'},
+ {value: '😞', match: 'sad :('},
+ {value: '😂', match: "tears :')"},
+ {value: '🙏', match: 'pray'},
+ {value: '😐', match: 'neutral :|'},
+ {value: '😮', match: 'shock :O'},
+ {value: '👎', match: 'thumbs down'},
+ {value: '😎', match: 'cool |;)'},
+ {value: '😕', match: 'confused'},
+ {value: '👌', match: 'ok'},
+ {value: '🔥', match: 'fire'},
+ {value: '👊', match: 'fistbump'},
+ {value: '💯', match: '100'},
+ {value: '💔', match: 'broken heart'},
+ {value: '🍺', match: 'beer'},
+ {value: '✔', match: 'check'},
+ {value: '😋', match: 'tongue'},
+ {value: '😭', match: "crying :'("},
+ {value: '🐨', match: 'koala'},
+ {value: '🤓', match: 'glasses'},
+ {value: '😆', match: 'grin'},
+ {value: '💩', match: 'poop'},
+ {value: '😢', match: 'tear'},
+ {value: '😒', match: 'unamused'},
+ {value: '😉', match: 'wink ;)'},
+ {value: '🍷', match: 'wine'},
+ {value: '😜', match: 'winking tongue ;)'},
+];
+
+interface EmojiSuggestion {
+ value: string;
+ match: string;
+ dataValue?: string;
+ text?: string;
+}
+
+interface ValueChangeEvent {
+ value: string;
+}
+
+export interface GrTextarea {
+ $: {
+ textarea: IronAutogrowTextareaElement;
+ emojiSuggestions: GrAutocompleteDropdown;
+ caratSpan: HTMLSpanElement;
+ hiddenText: HTMLDivElement;
+ };
+}
+/**
+ * @extends PolymerElement
+ */
+@customElement('gr-textarea')
+export class GrTextarea extends KeyboardShortcutMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ /**
+ * @event bind-value-changed
+ */
+ @property({type: Boolean})
+ autocomplete?: boolean;
+
+ @property({type: Boolean})
+ disabled?: boolean;
+
+ @property({type: Number})
+ rows?: number;
+
+ @property({type: Number})
+ maxRows?: number;
+
+ @property({type: String})
+ placeholder?: string;
+
+ @property({type: String, notify: true, observer: '_handleTextChanged'})
+ text?: string;
+
+ @property({type: Boolean})
+ hideBorder = false;
+
+ /** Text input should be rendered in monspace font. */
+ @property({type: Boolean})
+ monospace = false;
+
+ /** Text input should be rendered in code font, which is smaller than the
+ standard monospace font. */
+ @property({type: Boolean})
+ code = false;
+
+ @property({type: Number})
+ _colonIndex: number | null = null;
+
+ @property({type: String, observer: '_determineSuggestions'})
+ _currentSearchString?: string;
+
+ @property({type: Boolean})
+ _hideAutocomplete = true;
+
+ @property({type: Number})
+ _index?: number;
+
+ @property({type: Array})
+ _suggestions?: EmojiSuggestion[];
+
+ @property({type: Number})
+ readonly _verticalOffset = 20;
+ // Offset makes dropdown appear below text.
+
+ reporting: ReportingService;
+
+ disableEnterKeyForSelectingEmoji = false;
+
+ get keyBindings() {
+ return {
+ esc: '_handleEscKey',
+ tab: '_handleEnterByKey',
+ enter: '_handleEnterByKey',
+ up: '_handleUpKey',
+ down: '_handleDownKey',
+ };
+ }
+
+ constructor() {
+ super();
+ this.reporting = appContext.reportingService;
+ }
+
+ /** @override */
+ ready() {
+ super.ready();
+ if (this.monospace) {
+ this.classList.add('monospace');
+ }
+ if (this.code) {
+ this.classList.add('code');
+ }
+ if (this.hideBorder) {
+ this.$.textarea.classList.add('noBorder');
+ }
+ }
+
+ closeDropdown() {
+ return this.$.emojiSuggestions.close();
+ }
+
+ getNativeTextarea() {
+ return this.$.textarea.textarea;
+ }
+
+ putCursorAtEnd() {
+ const textarea = this.getNativeTextarea();
+ // Put the cursor at the end always.
+ textarea.selectionStart = textarea.value.length;
+ textarea.selectionEnd = textarea.selectionStart;
+ this.async(() => {
+ textarea.focus();
+ });
+ }
+
+ _handleEscKey(e: KeyboardEvent) {
+ if (this._hideAutocomplete) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ this._resetEmojiDropdown();
+ }
+
+ _handleUpKey(e: KeyboardEvent) {
+ if (this._hideAutocomplete) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ this.$.emojiSuggestions.cursorUp();
+ this.$.textarea.textarea.focus();
+ this.disableEnterKeyForSelectingEmoji = false;
+ }
+
+ _handleDownKey(e: KeyboardEvent) {
+ if (this._hideAutocomplete) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ this.$.emojiSuggestions.cursorDown();
+ this.$.textarea.textarea.focus();
+ this.disableEnterKeyForSelectingEmoji = false;
+ }
+
+ _handleEnterByKey(e: KeyboardEvent) {
+ if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+ }
+
+ _handleEmojiSelect(e: CustomEvent) {
+ this._setEmoji(e.detail.selected.dataset['value']);
+ }
+
+ _setEmoji(text: string) {
+ if (this._colonIndex === null) {
+ return;
+ }
+ const colonIndex = this._colonIndex;
+ this.text = this._getText(text);
+ this.$.textarea.selectionStart = colonIndex + 1;
+ this.$.textarea.selectionEnd = colonIndex + 1;
+ this.reporting.reportInteraction('select-emoji', {type: text});
+ this._resetEmojiDropdown();
+ }
+
+ _getText(value: string) {
+ if (!this.text) return '';
+ return (
+ this.text.substr(0, this._colonIndex || 0) +
+ value +
+ this.text.substr(this.$.textarea.selectionStart)
+ );
+ }
+
+ /**
+ * Uses a hidden element with the same width and styling of the textarea and
+ * the text up until the point of interest. Then caratSpan element is added
+ * to the end and is set to be the positionTarget for the dropdown. Together
+ * this allows the dropdown to appear near where the user is typing.
+ */
+ _updateCaratPosition() {
+ this._hideAutocomplete = false;
+ if (typeof this.$.textarea.value === 'string') {
+ this.$.hiddenText.textContent = this.$.textarea.value.substr(
+ 0,
+ this.$.textarea.selectionStart
+ );
+ }
+
+ const caratSpan = this.$.caratSpan;
+ this.$.hiddenText.appendChild(caratSpan);
+ this.$.emojiSuggestions.positionTarget = caratSpan;
+ this._openEmojiDropdown();
+ }
+
+ _getFontSize() {
+ const fontSizePx = getComputedStyle(this).fontSize || '12px';
+ return Number(fontSizePx.substr(0, fontSizePx.length - 2));
+ }
+
+ _getScrollTop() {
+ return document.body.scrollTop;
+ }
+
+ /**
+ * _handleKeydown used for key handling in the this.$.textarea AND all child
+ * autocomplete options.
+ */
+ _onValueChanged(e: CustomEvent<ValueChangeEvent>) {
+ // Relay the event.
+ this.dispatchEvent(
+ new CustomEvent('bind-value-changed', {
+ detail: e,
+ composed: true,
+ bubbles: true,
+ })
+ );
+
+ // If cursor is not in textarea (just opened with colon as last char),
+ // Don't do anything.
+ if (
+ e.currentTarget === null ||
+ !(e.currentTarget as IronAutogrowTextareaElement).focused
+ ) {
+ return;
+ }
+
+ const charAtCursor =
+ e.detail && e.detail.value
+ ? e.detail.value[this.$.textarea.selectionStart - 1]
+ : '';
+ if (charAtCursor !== ':' && this._colonIndex === null) {
+ return;
+ }
+
+ // When a colon is detected, set a colon index. We are interested only on
+ // colons after space or in beginning of textarea
+ if (charAtCursor === ':') {
+ if (
+ this.$.textarea.selectionStart < 2 ||
+ e.detail.value[this.$.textarea.selectionStart - 2] === ' '
+ ) {
+ this._colonIndex = this.$.textarea.selectionStart - 1;
+ }
+ }
+ if (this._colonIndex === null) {
+ return;
+ }
+
+ this._currentSearchString = e.detail.value.substr(
+ this._colonIndex + 1,
+ this.$.textarea.selectionStart - this._colonIndex - 1
+ );
+ // Under the following conditions, close and reset the dropdown:
+ // - The cursor is no longer at the end of the current search string
+ // - The search string is an space or new line
+ // - The colon has been removed
+ // - There are no suggestions that match the search string
+ if (
+ this.$.textarea.selectionStart !==
+ this._currentSearchString.length + this._colonIndex + 1 ||
+ this._currentSearchString === ' ' ||
+ this._currentSearchString === '\n' ||
+ !(e.detail.value[this._colonIndex] === ':') ||
+ !this._suggestions ||
+ !this._suggestions.length
+ ) {
+ this._resetEmojiDropdown();
+ // Otherwise open the dropdown and set the position to be just below the
+ // cursor.
+ } else if (this.$.emojiSuggestions.isHidden) {
+ this._updateCaratPosition();
+ }
+ this.$.textarea.textarea.focus();
+ }
+
+ _openEmojiDropdown() {
+ this.$.emojiSuggestions.open();
+ this.reporting.reportInteraction('open-emoji-dropdown');
+ }
+
+ _formatSuggestions(matchedSuggestions: EmojiSuggestion[]) {
+ const suggestions = [];
+ for (const suggestion of matchedSuggestions) {
+ suggestion.dataValue = suggestion.value;
+ suggestion.text = suggestion.value + ' ' + suggestion.match;
+ suggestions.push(suggestion);
+ }
+ this.set('_suggestions', suggestions);
+ }
+
+ _determineSuggestions(emojiText: string) {
+ if (!emojiText.length) {
+ this._formatSuggestions(ALL_SUGGESTIONS);
+ this.disableEnterKeyForSelectingEmoji = true;
+ } else {
+ const matches = ALL_SUGGESTIONS.filter(suggestion =>
+ suggestion.match.includes(emojiText)
+ ).slice(0, MAX_ITEMS_DROPDOWN);
+ this._formatSuggestions(matches);
+ this.disableEnterKeyForSelectingEmoji = false;
+ }
+ }
+
+ _resetEmojiDropdown() {
+ // hide and reset the autocomplete dropdown.
+ flush();
+ this._currentSearchString = '';
+ this._hideAutocomplete = true;
+ this.closeDropdown();
+ this._colonIndex = null;
+ this.$.textarea.textarea.focus();
+ }
+
+ _handleTextChanged(text: string) {
+ this.dispatchEvent(
+ new CustomEvent('value-changed', {detail: {value: text}})
+ );
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-textarea': GrTextarea;
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
index 2aa7697..5b3a2b2 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
@@ -68,7 +68,7 @@
element.$.textarea.selectionStart = 1;
element.$.textarea.selectionEnd = 1;
element.text = ':';
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.emojiSuggestions.isHidden);
assert.equal(element._colonIndex, 0);
assert.isFalse(element._hideAutocomplete);
@@ -83,7 +83,7 @@
element.$.textarea.selectionStart = 2;
element.$.textarea.selectionEnd = 2;
element.text = ' :';
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.emojiSuggestions.isHidden);
assert.equal(element._colonIndex, 1);
assert.isFalse(element._hideAutocomplete);
@@ -98,7 +98,7 @@
element.$.textarea.selectionStart = 5;
element.$.textarea.selectionEnd = 5;
element.text = 'test:';
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element.$.emojiSuggestions.isHidden);
assert.isTrue(element._hideAutocomplete);
});
@@ -114,7 +114,7 @@
element.$.textarea.selectionStart = 2;
element.$.textarea.selectionEnd = 2;
element.text = ':t';
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.emojiSuggestions.isHidden);
assert.equal(element._colonIndex, 0);
assert.isFalse(element._hideAutocomplete);
@@ -139,7 +139,7 @@
},
});
element.text = text;
- flushAsynchronousOperations();
+ flush();
assert.isFalse(element.$.emojiSuggestions.isHidden);
assert.equal(element._colonIndex, 0);
assert.isFalse(element._hideAutocomplete);
@@ -148,7 +148,7 @@
test('emoji selector closes when text changes before the colon', () => {
const resetStub = sinon.stub(element, '_resetEmojiDropdown');
MockInteractions.focus(element.$.textarea);
- flushAsynchronousOperations();
+ flush();
element.$.textarea.selectionStart = 10;
element.$.textarea.selectionEnd = 10;
element.text = 'test test ';
@@ -173,7 +173,7 @@
assert.equal(element._colonIndex, null);
element.$.emojiSuggestions.open();
- flushAsynchronousOperations();
+ flush();
element._resetEmojiDropdown();
assert.isTrue(closeSpy.called);
});
@@ -246,7 +246,7 @@
element.$.textarea.selectionStart = 1;
element.$.textarea.selectionEnd = 2;
element.text = ':1';
- flushAsynchronousOperations();
+ flush();
}
test('escape key', () => {
@@ -285,7 +285,7 @@
setupDropdown();
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
assert.isTrue(enterSpy.called);
- flushAsynchronousOperations();
+ flush();
assert.equal(element.text, '💯');
});
@@ -298,7 +298,7 @@
element.$.textarea.selectionStart = 1;
element.$.textarea.selectionEnd = 1;
element.text = ':';
- flushAsynchronousOperations();
+ flush();
MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
assert.isFalse(enterSpy.called);
});
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
deleted file mode 100644
index d2182e4..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.js
+++ /dev/null
@@ -1,49 +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 '../gr-icons/gr-icons.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-tooltip-content_html.js';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin.js';
-
-/**
- * @extends PolymerElement
- */
-class GrTooltipContent extends TooltipMixin(
- GestureEventListeners(
- LegacyElementMixin(
- PolymerElement))) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-tooltip-content'; }
-
- static get properties() {
- return {
- maxWidth: {
- type: String,
- reflectToAttribute: true,
- },
- showIcon: {
- type: Boolean,
- value: false,
- },
- };
- }
-}
-
-customElements.define(GrTooltipContent.is, GrTooltipContent);
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
new file mode 100644
index 0000000..cfd9e81
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
@@ -0,0 +1,47 @@
+/**
+ * @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 '../gr-icons/gr-icons';
+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-tooltip-content_html';
+import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {customElement, property} from '@polymer/decorators';
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-tooltip-content': GrTooltipContent;
+ }
+}
+
+/**
+ * Transclude anything inside and wrap them to support tooltip functionality.
+ */
+@customElement('gr-tooltip-content')
+export class GrTooltipContent extends TooltipMixin(
+ GestureEventListeners(LegacyElementMixin(PolymerElement))
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String, reflectToAttribute: true})
+ maxWidth?: string;
+
+ @property({type: Boolean})
+ showIcon = false;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
deleted file mode 100644
index 2244cca..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ /dev/null
@@ -1,50 +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 '../../../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-tooltip_html.js';
-
-/** @extends PolymerElement */
-class GrTooltip extends GestureEventListeners(
- LegacyElementMixin(
- PolymerElement)) {
- static get template() { return htmlTemplate; }
-
- static get is() { return 'gr-tooltip'; }
-
- static get properties() {
- return {
- text: String,
- maxWidth: {
- type: String,
- observer: '_updateWidth',
- },
- positionBelow: {
- type: Boolean,
- reflectToAttribute: true,
- },
- };
- }
-
- _updateWidth(maxWidth) {
- this.updateStyles({'--tooltip-max-width': maxWidth});
- }
-}
-
-customElements.define(GrTooltip.is, GrTooltip);
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
new file mode 100644
index 0000000..c1a8eb2
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.ts
@@ -0,0 +1,55 @@
+/**
+ * @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 '../../../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-tooltip_html';
+import {customElement, property, observe} from '@polymer/decorators';
+
+export interface GrTooltip {
+ $: {};
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'gr-tooltip': GrTooltip;
+ }
+}
+
+@customElement('gr-tooltip')
+export class GrTooltip extends GestureEventListeners(
+ LegacyElementMixin(PolymerElement)
+) {
+ static get template() {
+ return htmlTemplate;
+ }
+
+ @property({type: String})
+ text = '';
+
+ @property({type: String})
+ maxWidth = '';
+
+ @property({type: Boolean, reflectToAttribute: true})
+ positionBelow = false;
+
+ @observe('maxWidth')
+ _updateWidth(maxWidth: string) {
+ this.updateStyles({'--tooltip-max-width': maxWidth});
+ }
+}
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.js b/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
deleted file mode 100644
index 2f6bfea..0000000
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.js
+++ /dev/null
@@ -1,81 +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 {patchNumEquals} from '../../../utils/patch-set-util.js';
-
-/**
- * @constructor
- * @param {Object} change A change object resulting from a change detail
- * call that includes revision information.
- */
-export function RevisionInfo(change) {
- this._change = change;
-}
-
-/**
- * Get the largest number of parents of the commit in any revision. For
- * example, with normal changes this will always return 1. For merge changes
- * wherein the revisions are merge commits this will return 2 or potentially
- * more.
- *
- * @return {number}
- */
-RevisionInfo.prototype.getMaxParents = function() {
- if (!this._change || !this._change.revisions) {
- return 0;
- }
- return Object.values(this._change.revisions)
- .reduce((acc, rev) => Math.max(rev.commit.parents.length, acc), 0);
-};
-
-/**
- * Get an object that maps revision numbers to the number of parents of the
- * commit of that revision.
- *
- * @return {!Object}
- */
-RevisionInfo.prototype.getParentCountMap = function() {
- const result = {};
- if (!this._change || !this._change.revisions) {
- return {};
- }
- Object.values(this._change.revisions)
- .forEach(rev => { result[rev._number] = rev.commit.parents.length; });
- return result;
-};
-
-/**
- * @param {number|string} patchNum
- * @return {number}
- */
-RevisionInfo.prototype.getParentCount = function(patchNum) {
- return this.getParentCountMap()[patchNum];
-};
-
-/**
- * Get the commit ID of the (0-offset) indexed parent in the given revision
- * number.
- *
- * @param {number|string} patchNum
- * @param {number} parentIndex (0-offset)
- * @return {string}
- */
-RevisionInfo.prototype.getParentId = function(patchNum, parentIndex) {
- const rev = Object.values(this._change.revisions).find(rev =>
- patchNumEquals(rev._number, patchNum));
- return rev.commit.parents[parentIndex].commit;
-};
diff --git a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
new file mode 100644
index 0000000..fadbfa7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -0,0 +1,80 @@
+/**
+ * @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 {patchNumEquals} from '../../../utils/patch-set-util';
+import {ChangeInfo, PatchSetNum} from '../../../types/common';
+import {ParsedChangeInfo} from '../gr-rest-api-interface/gr-reviewer-updates-parser';
+
+type RevNumberToParentCountMap = {[revNumber: number]: number};
+
+export class RevisionInfo {
+ /**
+ * @constructor
+ * @param change A change object resulting from a change detail
+ * call that includes revision information.
+ */
+ constructor(private change: ChangeInfo | ParsedChangeInfo) {}
+
+ /**
+ * Get the largest number of parents of the commit in any revision. For
+ * example, with normal changes this will always return 1. For merge changes
+ * wherein the revisions are merge commits this will return 2 or potentially
+ * more.
+ */
+ getMaxParents() {
+ if (!this.change || !this.change.revisions) {
+ return 0;
+ }
+ return Object.values(this.change.revisions).reduce(
+ (acc, rev) => Math.max(!rev.commit ? 0 : rev.commit.parents.length, acc),
+ 0
+ );
+ }
+
+ /**
+ * Get an object that maps revision numbers to the number of parents of the
+ * commit of that revision.
+ */
+ getParentCountMap() {
+ const result: RevNumberToParentCountMap = {};
+ if (!this.change || !this.change.revisions) {
+ return {};
+ }
+ Object.values(this.change.revisions).forEach(rev => {
+ if (rev.commit) result[rev._number as number] = rev.commit.parents.length;
+ });
+ return result;
+ }
+
+ getParentCount(patchNum: PatchSetNum) {
+ return this.getParentCountMap()[patchNum as number];
+ }
+
+ /**
+ * Get the commit ID of the (0-offset) indexed parent in the given revision
+ * number.
+ */
+
+ getParentId(patchNum: PatchSetNum, parentIndex: number) {
+ if (!this.change.revisions) return;
+ const rev = Object.values(this.change.revisions).find(rev =>
+ patchNumEquals(rev._number, patchNum)
+ );
+ if (!rev || !rev.commit) return;
+ return rev.commit.parents[parentIndex].commit;
+ }
+}
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.js b/polygerrit-ui/app/embed/gr-diff-app-context-init.js
deleted file mode 100644
index 933edba..0000000
--- a/polygerrit-ui/app/embed/gr-diff-app-context-init.js
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import {appContext} from '../services/app-context.js';
-import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock.js';
-
-class MockFlagsService {
- isEnabled(experimentId) {
- return false;
- }
-
- /**
- * @returns {!Array<string>} array of all enabled experiments.
- */
- get enabledExperiments() {
- return [];
- }
-}
-
-class MockAuthService {
- clearCache() {
-
- }
-
- get isAuthed() {
- return false;
- }
-
- authCheck() {
- return Promise.resolve(false);
- }
-}
-
-// Setup mocks for appContext.
-// This is a temporary solution
-// TODO(dmfilippov): find a better solution for gr-diff
-export function initDiffAppContext() {
- function setMock(serviceName, setupMock) {
- Object.defineProperty(appContext, serviceName, {
- get() {
- return setupMock;
- },
- });
- }
- setMock('flagsService', new MockFlagsService);
- setMock('reportingService', grReportingMock);
- setMock('authService', new MockAuthService);
-}
diff --git a/polygerrit-ui/app/embed/gr-diff-app-context-init.ts b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
new file mode 100644
index 0000000..6a30477
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff-app-context-init.ts
@@ -0,0 +1,73 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {appContext} from '../services/app-context';
+import {FlagsService} from '../services/flags/flags';
+import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
+import {AuthService} from '../services/gr-auth/gr-auth';
+
+class MockFlagsService implements FlagsService {
+ isEnabled() {
+ return false;
+ }
+
+ /**
+ * @returns array of all enabled experiments.
+ */
+ get enabledExperiments() {
+ return [];
+ }
+}
+
+class MockAuthService implements AuthService {
+ clearCache() {}
+
+ get isAuthed() {
+ return false;
+ }
+
+ authCheck() {
+ return Promise.resolve(false);
+ }
+
+ baseUrl = '';
+
+ setup() {}
+
+ fetch() {
+ const blob = new Blob();
+ const init = {status: 200, statusText: 'Ack'};
+ const response = new Response(blob, init);
+ return Promise.resolve(response);
+ }
+}
+
+// Setup mocks for appContext.
+// This is a temporary solution
+// TODO(dmfilippov): find a better solution for gr-diff
+export function initDiffAppContext() {
+ function setMock(serviceName: string, setupMock: unknown) {
+ Object.defineProperty(appContext, serviceName, {
+ get() {
+ return setupMock;
+ },
+ });
+ }
+ setMock('flagsService', new MockFlagsService());
+ setMock('reportingService', grReportingMock);
+ setMock('authService', new MockAuthService());
+}
diff --git a/polygerrit-ui/app/embed/gr-diff.js b/polygerrit-ui/app/embed/gr-diff.js
deleted file mode 100644
index 6050d69..0000000
--- a/polygerrit-ui/app/embed/gr-diff.js
+++ /dev/null
@@ -1,41 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-window.Gerrit = window.Gerrit || {};
-// We need to use goog.declareModuleId internally in google for TS-imports-JS
-// case. To avoid errors when goog is not available, the empty implementation is
-// added.
-window.goog = window.goog || {declareModuleId(name) {}};
-// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
-// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
-// Because gr-diff.js is a shared component, it shouldn' pollute global
-// variables. If an application wants to use Polymer global variable -
-// the app must assign/import it and do not rely on the Polymer variable
-// exposed by shared gr-diff component.
-import '../scripts/bundled-polymer.js';
-import '../elements/diff/gr-diff/gr-diff.js';
-import '../elements/diff/gr-diff-cursor/gr-diff-cursor.js';
-import {initDiffAppContext} from './gr-diff-app-context-init.js';
-import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line.js';
-import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation.js';
-
-// Setup appContext for diff.
-// TODO (dmfilippov): find a better solution
-initDiffAppContext();
-// Setup global variables for existing usages of this component
-window.GrDiffLine = GrDiffLine;
-window.GrAnnotation = GrAnnotation;
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
new file mode 100644
index 0000000..405d22e
--- /dev/null
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -0,0 +1,40 @@
+/**
+ * @license
+ * 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.
+ */
+
+// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
+// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
+// Because gr-diff.js is a shared component, it shouldn' pollute global
+// variables. If an application wants to use Polymer global variable -
+// the app must assign/import it and do not rely on the Polymer variable
+// exposed by shared gr-diff component.
+import '../scripts/bundled-polymer';
+import '../elements/diff/gr-diff/gr-diff';
+import '../elements/diff/gr-diff-cursor/gr-diff-cursor';
+import {initDiffAppContext} from './gr-diff-app-context-init';
+import {
+ GrDiffLine,
+ GrDiffLineType,
+} from '../elements/diff/gr-diff/gr-diff-line';
+import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
+
+// Setup appContext for diff.
+// TODO (dmfilippov): find a better solution
+initDiffAppContext();
+// Setup global variables for existing usages of this component
+window.GrDiffLine = GrDiffLine;
+window.GrDiffLineType = GrDiffLineType;
+window.GrAnnotation = GrAnnotation;
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.js b/polygerrit-ui/app/gr-diff/gr-diff-root.js
deleted file mode 100644
index bb5d602..0000000
--- a/polygerrit-ui/app/gr-diff/gr-diff-root.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-window.Gerrit = window.Gerrit || {};
-import '../elements/diff/gr-diff/gr-diff.js';
diff --git a/polygerrit-ui/app/gr-diff/gr-diff-root.ts b/polygerrit-ui/app/gr-diff/gr-diff-root.ts
new file mode 100644
index 0000000..fbe81fb
--- /dev/null
+++ b/polygerrit-ui/app/gr-diff/gr-diff-root.ts
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../elements/diff/gr-diff/gr-diff';
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.js b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.js
deleted file mode 100644
index e3b9f20..0000000
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.js
+++ /dev/null
@@ -1,122 +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 {dedupingMixin} from '@polymer/polymer/lib/utils/mixin.js';
-
-/**
- * @polymer
- * @mixinFunction
- */
-export const ChangeTableMixin = dedupingMixin(superClass => {
- /**
- * @polymer
- * @mixinClass
- */
- class Mixin extends superClass {
- static get properties() {
- return {
- columnNames: {
- type: Array,
- value: [
- 'Subject',
- 'Status',
- 'Owner',
- 'Assignee',
- 'Reviewers',
- 'Comments',
- 'Repo',
- 'Branch',
- 'Updated',
- 'Size',
- ],
- readOnly: true,
- },
- };
- }
-
- /**
- * Returns the complement to the given column array
- *
- * @param {Array} columns
- * @return {!Array}
- */
- getComplementColumns(columns) {
- return this.columnNames.filter(column => !columns.includes(column));
- }
-
- /**
- * @param {string} columnToCheck
- * @param {!Array} columnsToDisplay
- * @return {boolean}
- */
- isColumnHidden(columnToCheck, columnsToDisplay) {
- if ([columnsToDisplay, columnToCheck].includes(undefined)) {
- return false;
- }
- return !columnsToDisplay.includes(columnToCheck);
- }
-
- /**
- * Is the column disabled by a server config or experiment? For example the
- * assignee feature might be disabled and thus the corresponding column is
- * also disabled.
- *
- * @param {string} column
- * @param {Object} config
- * @param {!Array<string>} experiments
- * @return {boolean}
- */
- isColumnEnabled(column, config, experiments) {
- if (!config || !config.change) return true;
- if (column === 'Assignee') return !!config.change.enable_assignee;
- if (column === 'Comments') return experiments.includes('comments-column');
- if (column === 'Reviewers') return !!config.change.enable_attention_set;
- return true;
- }
-
- /**
- * @param {!Array<string>} columns
- * @param {Object} config
- * @param {!Array<string>} experiments
- * @return {!Array<string>} enabled columns, see isColumnEnabled().
- */
- getEnabledColumns(columns, config, experiments) {
- return columns.filter(
- col => this.isColumnEnabled(col, config, experiments));
- }
-
- /**
- * The Project column was renamed to Repo, but some users may have
- * preferences that use its old name. If that column is found, rename it
- * before use.
- *
- * @param {!Array<string>} columns
- * @return {!Array<string>} If the column was renamed, returns a new array
- * with the corrected name. Otherwise, it returns the original param.
- */
- getVisibleColumns(columns) {
- const projectIndex = columns.indexOf('Project');
- if (projectIndex === -1) { return columns; }
- const newColumns = columns.slice(0);
- newColumns[projectIndex] = 'Repo';
- return newColumns;
- }
- }
-
- return Mixin;
-});
-
diff --git a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
new file mode 100644
index 0000000..abbcbc8
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
@@ -0,0 +1,136 @@
+/**
+ * @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 {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+import {property} from '@polymer/decorators';
+import {ServerInfo} from '../../types/common';
+
+/**
+ * @polymer
+ * @mixinFunction
+ */
+export const ChangeTableMixin = dedupingMixin(
+ <T extends Constructor<PolymerElement>>(
+ superClass: T
+ ): T & Constructor<ChangeTableMixinInterface> => {
+ /**
+ * @polymer
+ * @mixinClass
+ */
+ class Mixin extends superClass {
+ @property({type: Array})
+ readonly columnNames: string[] = [
+ 'Subject',
+ 'Status',
+ 'Owner',
+ 'Assignee',
+ 'Reviewers',
+ 'Comments',
+ 'Repo',
+ 'Branch',
+ 'Updated',
+ 'Size',
+ ];
+
+ /**
+ * Returns the complement to the given column array
+ *
+ */
+ getComplementColumns(columns: string[]) {
+ return this.columnNames.filter(column => !columns.includes(column));
+ }
+
+ isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
+ if (!columnsToDisplay || !columnToCheck) {
+ return false;
+ }
+ return !columnsToDisplay.includes(columnToCheck);
+ }
+
+ /**
+ * Is the column disabled by a server config or experiment? For example the
+ * assignee feature might be disabled and thus the corresponding column is
+ * also disabled.
+ *
+ */
+ isColumnEnabled(
+ column: string,
+ config: ServerInfo,
+ experiments: string[]
+ ) {
+ if (!config || !config.change) return true;
+ if (column === 'Assignee') return !!config.change.enable_assignee;
+ if (column === 'Comments')
+ return experiments.includes('comments-column');
+ if (column === 'Reviewers') return !!config.change.enable_attention_set;
+ return true;
+ }
+
+ /**
+ * @return enabled columns, see isColumnEnabled().
+ */
+ getEnabledColumns(
+ columns: string[],
+ config: ServerInfo,
+ experiments: string[]
+ ) {
+ return columns.filter(col =>
+ this.isColumnEnabled(col, config, experiments)
+ );
+ }
+
+ /**
+ * The Project column was renamed to Repo, but some users may have
+ * preferences that use its old name. If that column is found, rename it
+ * before use.
+ *
+ * @return If the column was renamed, returns a new array
+ * with the corrected name. Otherwise, it returns the original param.
+ */
+ getVisibleColumns(columns: string[]) {
+ const projectIndex = columns.indexOf('Project');
+ if (projectIndex === -1) {
+ return columns;
+ }
+ const newColumns = [...columns];
+ newColumns[projectIndex] = 'Repo';
+ return newColumns;
+ }
+ }
+
+ return Mixin;
+ }
+);
+
+export interface ChangeTableMixinInterface {
+ readonly columnNames: string[];
+ getComplementColumns(columns: string[]): string[];
+ isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]): boolean;
+ isColumnEnabled(
+ column: string,
+ config: ServerInfo,
+ experiments: string[]
+ ): boolean;
+ getEnabledColumns(
+ columns: string[],
+ config: ServerInfo,
+ experiments: string[]
+ ): string[];
+ getVisibleColumns(columns: string[]): string[];
+}
diff --git a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.js b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.js
deleted file mode 100644
index 0b2c306..0000000
--- a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.js
+++ /dev/null
@@ -1,65 +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 {encodeURL, getBaseUrl} from '../../utils/url-util.js';
-import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin.js';
-
-/**
- * @polymer
- * @mixinFunction
- */
-export const ListViewMixin = dedupingMixin(superClass => {
- /**
- * @polymer
- * @mixinClass
- */
- class Mixin extends superClass {
- computeLoadingClass(loading) {
- return loading ? 'loading' : '';
- }
-
- computeShownItems(items) {
- return items.slice(0, 25);
- }
-
- getUrl(path, item) {
- return getBaseUrl() + path + encodeURL(item, true);
- }
-
- /**
- * @param {Object} params
- * @return {string}
- */
- getFilterValue(params) {
- if (!params) { return ''; }
- return params.filter || '';
- }
-
- /**
- * @param {Object} params
- * @return {number}
- */
- getOffsetValue(params) {
- if (params && params.offset) {
- return params.offset;
- }
- return 0;
- }
- }
-
- return Mixin;
-});
diff --git a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts
new file mode 100644
index 0000000..af89194
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts
@@ -0,0 +1,78 @@
+/**
+ * @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 {encodeURL, getBaseUrl} from '../../utils/url-util';
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+
+/**
+ * @polymer
+ * @mixinFunction
+ */
+export const ListViewMixin = dedupingMixin(
+ <T extends Constructor<PolymerElement>>(
+ superClass: T
+ ): T & Constructor<ListViewMixinInterface> => {
+ /**
+ * @polymer
+ * @mixinClass
+ */
+ class Mixin extends superClass {
+ computeLoadingClass(loading: boolean): string {
+ return loading ? 'loading' : '';
+ }
+
+ computeShownItems<T>(items: T[]): T[] {
+ return items.slice(0, 25);
+ }
+
+ getUrl(path: string, item: string) {
+ return getBaseUrl() + path + encodeURL(item, true);
+ }
+
+ getFilterValue<T extends ListViewParams>(params: T): string {
+ if (!params) {
+ return '';
+ }
+ return params.filter || '';
+ }
+
+ getOffsetValue<T extends ListViewParams>(params: T): number {
+ if (params?.offset) {
+ return Number(params.offset);
+ }
+ return 0;
+ }
+ }
+
+ return Mixin;
+ }
+);
+
+export interface ListViewMixinInterface {
+ computeLoadingClass(loading: boolean): string;
+ computeShownItems<T>(items: T[]): T[];
+ getUrl(path: string, item: string): string;
+ getFilterValue<T extends ListViewParams>(params: T): string;
+ getOffsetValue<T extends ListViewParams>(params: T): number;
+}
+
+export interface ListViewParams {
+ filter?: string | null;
+ offset?: number | string;
+}
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.js b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.js
deleted file mode 100644
index 68d594d..0000000
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.js
+++ /dev/null
@@ -1,179 +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 '../../elements/shared/gr-tooltip/gr-tooltip.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {getRootElement} from '../../scripts/rootElement.js';
-import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin.js';
-
-const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
-
-/**
- * @polymer
- * @mixinFunction
- */
-export const TooltipMixin = dedupingMixin(superClass => {
- /**
- * @polymer
- * @mixinClass
- */
- class Mixin extends superClass {
- static get properties() {
- return {
- hasTooltip: {
- type: Boolean,
- observer: '_setupTooltipListeners',
- },
- positionBelow: {
- type: Boolean,
- value: false,
- reflectToAttribute: true,
- },
-
- _isTouchDevice: {
- type: Boolean,
- value() {
- return 'ontouchstart' in document.documentElement;
- },
- },
- _tooltip: Object,
- _titleText: String,
- _hasSetupTooltipListeners: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- /** @override */
- disconnectedCallback() {
- super.disconnectedCallback();
- // NOTE: if you define your own `detached` in your component
- // then this won't take affect (as its not a class yet)
- this._handleHideTooltip();
- this.removeEventListener('mouseenter', this._mouseenterHandler);
- }
-
- _setupTooltipListeners() {
- if (!this._mouseenterHandler) {
- this._mouseenterHandler = this._handleShowTooltip.bind(this);
- }
-
- if (!this.hasTooltip) {
- // if attribute set to false, remove the listener
- this.removeEventListener('mouseenter', this._mouseenterHandler);
- this._hasSetupTooltipListeners = false;
- return;
- }
-
- if (this._hasSetupTooltipListeners) {
- return;
- }
- this._hasSetupTooltipListeners = true;
-
- this.addEventListener('mouseenter', this._mouseenterHandler);
- }
-
- _handleShowTooltip(e) {
- if (this._isTouchDevice) { return; }
-
- if (!this.hasAttribute('title') ||
- this.getAttribute('title') === '' ||
- this._tooltip) {
- return;
- }
-
- // Store the title attribute text then set it to an empty string to
- // prevent it from showing natively.
- this._titleText = this.getAttribute('title');
- this.setAttribute('title', '');
-
- const tooltip = document.createElement('gr-tooltip');
- tooltip.text = this._titleText;
- tooltip.maxWidth = this.getAttribute('max-width');
- tooltip.positionBelow = this.getAttribute('position-below');
-
- // Set visibility to hidden before appending to the DOM so that
- // calculations can be made based on the element’s size.
- tooltip.style.visibility = 'hidden';
- getRootElement().appendChild(tooltip);
- this._positionTooltip(tooltip);
- tooltip.style.visibility = null;
-
- this._tooltip = tooltip;
- this.listen(window, 'scroll', '_handleWindowScroll');
- this.listen(this, 'mouseleave', '_handleHideTooltip');
- this.listen(this, 'click', '_handleHideTooltip');
- }
-
- _handleHideTooltip(e) {
- if (this._isTouchDevice) { return; }
- if (!this.hasAttribute('title') ||
- this._titleText == null) {
- return;
- }
-
- this.unlisten(window, 'scroll', '_handleWindowScroll');
- this.unlisten(this, 'mouseleave', '_handleHideTooltip');
- this.unlisten(this, 'click', '_handleHideTooltip');
- this.setAttribute('title', this._titleText);
- if (this._tooltip && this._tooltip.parentNode) {
- this._tooltip.parentNode.removeChild(this._tooltip);
- }
- this._tooltip = null;
- }
-
- _handleWindowScroll(e) {
- if (!this._tooltip) { return; }
-
- this._positionTooltip(this._tooltip);
- }
-
- _positionTooltip(tooltip) {
- // This flush is needed for tooltips to be positioned correctly in Firefox
- // and Safari.
- flush();
- const rect = this.getBoundingClientRect();
- const boxRect = tooltip.getBoundingClientRect();
- const parentRect = tooltip.parentElement.getBoundingClientRect();
- const top = rect.top - parentRect.top;
- const left =
- rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
- const right = parentRect.width - left - boxRect.width;
- if (left < 0) {
- tooltip.updateStyles({
- '--gr-tooltip-arrow-center-offset': left + 'px',
- });
- } else if (right < 0) {
- tooltip.updateStyles({
- '--gr-tooltip-arrow-center-offset': (-0.5 * right) + 'px',
- });
- }
- tooltip.style.left = Math.max(0, left) + 'px';
-
- if (!this.positionBelow) {
- tooltip.style.top = Math.max(0, top) + 'px';
- tooltip.style.transform = 'translateY(calc(-100% - ' + BOTTOM_OFFSET +
- 'px))';
- } else {
- tooltip.style.top = top + rect.height + BOTTOM_OFFSET + 'px';
- }
- }
- }
-
- return Mixin;
-});
-
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
new file mode 100644
index 0000000..08b18a3
--- /dev/null
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
@@ -0,0 +1,221 @@
+/**
+ * @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 '../../elements/shared/gr-tooltip/gr-tooltip';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {getRootElement} from '../../scripts/rootElement';
+import {property, observe} from '@polymer/decorators';
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {GrTooltip} from '../../elements/shared/gr-tooltip/gr-tooltip';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+
+const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
+
+/** The interface corresponding to TooltipMixin */
+export interface TooltipMixinInterface {
+ hasTooltip: boolean;
+ positionBelow: boolean;
+ _isTouchDevice: boolean;
+ _tooltip: GrTooltip | null;
+ _titleText: string;
+ _hasSetupTooltipListeners: boolean;
+}
+
+/**
+ * @polymer
+ * @mixinFunction
+ */
+export const TooltipMixin = dedupingMixin(
+ <T extends Constructor<PolymerElement>>(
+ superClass: T
+ ): T & Constructor<TooltipMixinInterface> => {
+ /**
+ * @polymer
+ * @mixinClass
+ */
+ class Mixin extends superClass {
+ @property({type: Boolean})
+ hasTooltip = false;
+
+ @property({type: Boolean, reflectToAttribute: true})
+ positionBelow = false;
+
+ @property({type: Boolean})
+ _isTouchDevice = 'ontouchstart' in document.documentElement;
+
+ @property({type: Object})
+ _tooltip: GrTooltip | null = null;
+
+ @property({type: String})
+ _titleText = '';
+
+ @property({type: Boolean})
+ _hasSetupTooltipListeners = false;
+
+ // Handler for mouseenter event
+ private mouseenterHandler?: (e: MouseEvent) => void;
+
+ // Hanlder for scrolling on window
+ private readonly windowScrollHandler: () => void;
+
+ // Hanlder for showing the tooltip, will be attached to certain events
+ private readonly showHandler: () => void;
+
+ // Hanlder for hiding the tooltip, will be attached to certain events
+ private readonly hideHandler: () => void;
+
+ // tslint:disable-next-line:no-any Required for constructor signature.
+ constructor(..._: any[]) {
+ super();
+ this.windowScrollHandler = () => this._handleWindowScroll();
+ this.showHandler = () => this._handleShowTooltip();
+ this.hideHandler = () => this._handleHideTooltip();
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ // NOTE: if you define your own `detached` in your component
+ // then this won't take affect (as its not a class yet)
+ this._handleHideTooltip();
+ if (this.mouseenterHandler) {
+ this.removeEventListener('mouseenter', this.mouseenterHandler);
+ }
+ window.removeEventListener('scroll', this.windowScrollHandler);
+ }
+
+ @observe('hasTooltip')
+ _setupTooltipListeners() {
+ if (!this.mouseenterHandler) {
+ this.mouseenterHandler = this.showHandler;
+ }
+
+ if (!this.hasTooltip) {
+ // if attribute set to false, remove the listener
+ this.removeEventListener('mouseenter', this.mouseenterHandler);
+ this._hasSetupTooltipListeners = false;
+ return;
+ }
+
+ if (this._hasSetupTooltipListeners) {
+ return;
+ }
+ this._hasSetupTooltipListeners = true;
+
+ this.addEventListener('mouseenter', this.mouseenterHandler);
+ }
+
+ _handleShowTooltip() {
+ if (this._isTouchDevice) {
+ return;
+ }
+
+ if (
+ !this.hasAttribute('title') ||
+ this.getAttribute('title') === '' ||
+ this._tooltip
+ ) {
+ return;
+ }
+
+ // Store the title attribute text then set it to an empty string to
+ // prevent it from showing natively.
+ this._titleText = this.getAttribute('title') || '';
+ this.setAttribute('title', '');
+
+ const tooltip = document.createElement('gr-tooltip');
+ tooltip.text = this._titleText;
+ tooltip.maxWidth = this.getAttribute('max-width') || '';
+ tooltip.positionBelow = this.hasAttribute('position-below');
+
+ // Set visibility to hidden before appending to the DOM so that
+ // calculations can be made based on the element’s size.
+ tooltip.style.visibility = 'hidden';
+ getRootElement().appendChild(tooltip);
+ this._positionTooltip(tooltip);
+ tooltip.style.visibility = 'initial';
+
+ this._tooltip = tooltip;
+ window.addEventListener('scroll', this.windowScrollHandler);
+ this.addEventListener('mouseleave', this.hideHandler);
+ this.addEventListener('click', this.hideHandler);
+ }
+
+ _handleHideTooltip() {
+ if (this._isTouchDevice) {
+ return;
+ }
+ if (!this.hasAttribute('title') || !this._titleText) {
+ return;
+ }
+
+ window.removeEventListener('scroll', this.windowScrollHandler);
+ this.removeEventListener('mouseleave', this.hideHandler);
+ this.removeEventListener('click', this.hideHandler);
+ this.setAttribute('title', this._titleText);
+
+ if (this._tooltip?.parentNode) {
+ this._tooltip.parentNode.removeChild(this._tooltip);
+ }
+ this._tooltip = null;
+ }
+
+ _handleWindowScroll() {
+ if (!this._tooltip) {
+ return;
+ }
+
+ this._positionTooltip(this._tooltip);
+ }
+
+ _positionTooltip(tooltip: GrTooltip) {
+ // This flush is needed for tooltips to be positioned correctly in Firefox
+ // and Safari.
+ flush();
+ const rect = this.getBoundingClientRect();
+ const boxRect = tooltip.getBoundingClientRect();
+ if (!tooltip.parentElement) {
+ return;
+ }
+ const parentRect = tooltip.parentElement.getBoundingClientRect();
+ const top = rect.top - parentRect.top;
+ const left =
+ rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
+ const right = parentRect.width - left - boxRect.width;
+ if (left < 0) {
+ tooltip.updateStyles({
+ '--gr-tooltip-arrow-center-offset': `${left}px`,
+ });
+ } else if (right < 0) {
+ tooltip.updateStyles({
+ '--gr-tooltip-arrow-center-offset': `${-0.5 * right}px`,
+ });
+ }
+ tooltip.style.left = `${Math.max(0, left)}px`;
+
+ if (!this.positionBelow) {
+ tooltip.style.top = `${Math.max(0, top)}px`;
+ tooltip.style.transform = `translateY(calc(-100% - ${BOTTOM_OFFSET}px))`;
+ } else {
+ tooltip.style.top = `${top + rect.height + BOTTOM_OFFSET}px`;
+ }
+ }
+ }
+
+ return Mixin;
+ }
+);
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
index 589307d..209c83af 100644
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
+++ b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
@@ -117,7 +117,7 @@
test('hides tooltip when detached', () => {
sinon.stub(element, '_handleHideTooltip');
element.remove();
- flushAsynchronousOperations();
+ flush();
assert.isTrue(element._handleHideTooltip.called);
});
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.js b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.js
deleted file mode 100644
index db482cf..0000000
--- a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
-
-// In .d.ts, the mixinBehaviors clears all type information about superClass.
-// As a workaround, we define IronFitMixin manually here and after conversion
-// to typescript we can define correct typing here as well.
-export const IronFitMixin = superClass => mixinBehaviors(
- [IronFitBehavior], superClass);
diff --git a/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
new file mode 100644
index 0000000..48a4848
--- /dev/null
+++ b/polygerrit-ui/app/mixins/iron-fit-mixin/iron-fit-mixin.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {Constructor} from '../../utils/common-util';
+
+// The mixinBehaviors clears all type information about superClass.
+// As a workaround, we define IronFitMixin with correct type.
+// Due to the following issues:
+// https://github.com/microsoft/TypeScript/issues/15870
+// https://github.com/microsoft/TypeScript/issues/9944
+// we have to import IronFitBehavior in the same file where IronFitMixin
+// is used. To ensure that this import can't be avoided, the second parameter
+// is added. Usage example:
+// class Element extends IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior)
+// The code 'IronFitBehavior as IronFitBehavior' required, becuase IronFitBehavior
+// defined as an object, not as IronFitBehavior instance.
+
+export const IronFitMixin = <T extends Constructor<PolymerElement>>(
+ superClass: T,
+ _: IronFitBehavior
+): T & Constructor<IronFitBehavior> =>
+ // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
+ // which will fail the type check due to missing IronFitBehavior interface
+ mixinBehaviors([IronFitBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.js b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.js
deleted file mode 100644
index bdd5a4e..0000000
--- a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
-
-// In .d.ts, the mixinBehaviors clears all type information about superClass.
-// As a workaround, we define IronOverlayMixin manually here and after
-// conversion to typescript we can define correct typing here as well.
-export const IronOverlayMixin = superClass => mixinBehaviors(
- [IronOverlayBehavior], superClass);
diff --git a/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
new file mode 100644
index 0000000..4884ec2
--- /dev/null
+++ b/polygerrit-ui/app/mixins/iron-overlay-mixin/iron-overlay-mixin.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {Constructor} from '../../utils/common-util';
+
+// The mixinBehaviors clears all type information about superClass.
+// As a workaround, we define IronOverlayMixin with correct type.
+// Due to the following issues:
+// https://github.com/microsoft/TypeScript/issues/15870
+// https://github.com/microsoft/TypeScript/issues/9944
+// we have to import IronOverlayBehavior in the same file where IronOverlayMixin
+// is used. To ensure that this import can't be avoided, the second parameter
+// is added. Usage example:
+// class Element extends IronOverlayMixin(PolymerElement, IronOverlayBehavior as IronOverlayBehavior)
+// The code 'IronOverlayBehavior as IronOverlayBehavior' required, because
+// IronOverlayBehavior defined as an object, not as IronOverlayBehavior instance.
+export const IronOverlayMixin = <T extends Constructor<PolymerElement>>(
+ superClass: T,
+ _: IronOverlayBehavior
+): T & Constructor<IronOverlayBehavior> =>
+ // TODO(TS): mixinBehaviors in some lib is returning: `new () => T`
+ // instead which will fail the type check due to missing
+ // IronOverlayBehavior interface
+ mixinBehaviors([IronOverlayBehavior], superClass) as any;
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js
deleted file mode 100644
index 75b0b00..0000000
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js
+++ /dev/null
@@ -1,772 +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.
- */
-/*
-
-How to Add a Keyboard Shortcut
-==============================
-
-A keyboard shortcut is composed of the following parts:
-
- 1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
- 2. Documentation for the keyboard shortcut help dialog
- 3. A binding between key combos and the semantic identifier
- 4. A binding between the semantic identifier and a listener
-
-Parts (1) and (2) for all shortcuts are defined in this file. The semantic
-identifier is declared in the Shortcut enum near the head of this script:
-
- const Shortcut = {
- // ...
- TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
- // ...
- };
-
-Immediately following the Shortcut enum definition, there is a _describe
-function defined which is then invoked many times to populate the help dialog.
-Add a new invocation here to document the shortcut:
-
- _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
- 'Hide/show left diff');
-
-When an attached view binds one or more key combos to this shortcut, the help
-dialog will display this text in the given section (in this case, "Diffs"). See
-the ShortcutSection enum immediately below for the list of supported sections.
-
-Part (3), the actual key bindings, are declared by gr-app. In the future, this
-system may be expanded to allow key binding customizations by plugins or user
-preferences. Key bindings are defined in the following forms:
-
- // Ordinary shortcut with a single binding.
- this.bindShortcut(
- Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-
- // Ordinary shortcut with multiple bindings.
- this.bindShortcut(
- Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-
- // A "go-key" keyboard shortcut, which is combined with a previously and
- // continuously pressed "go" key (the go-key is hard-coded as 'g').
- this.bindShortcut(
- Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'o');
-
- // A "doc-only" keyboard shortcut. This declares the key-binding for help
- // dialog purposes, but doesn't actually implement the binding. It is up
- // to some element to implement this binding using iron-a11y-keys-behavior's
- // keyBindings property.
- this.bindShortcut(
- Shortcut.EXPAND_ALL_COMMENT_THREADS, SPECIAL_SHORTCUT.DOC_ONLY, 'e');
-
-Part (4), the listener definitions, are declared by the view or element that
-implements the shortcut behavior. This is done by implementing a method named
-keyboardShortcuts() in an element that mixes in this behavior, returning an
-object that maps semantic identifiers (as property names) to listener method
-names, like this:
-
- keyboardShortcuts() {
- return {
- [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
- };
- },
-
-You can implement key bindings in an element that is hosted by a view IF that
-element is always attached exactly once under that view (e.g. the search bar in
-gr-app). When that is not the case, you will have to define a doc-only binding
-in gr-app, declare the shortcut in the view that hosts the element, and use
-iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
-element. An example of this is in comment threads. A diff view supports actions
-on comment threads, but there may be zero or many comment threads attached at
-any given point. So the shortcut is declared as doc-only by the diff view and
-by gr-app, and actually implemented by gr-comment-thread.
-
-NOTE: doc-only shortcuts will not be customizable in the same way that other
-shortcuts are.
-*/
-
-import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
-import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin.js';
-
-export const SPECIAL_SHORTCUT = {
- DOC_ONLY: 'DOC_ONLY',
- GO_KEY: 'GO_KEY',
- V_KEY: 'V_KEY',
-};
-
-// The maximum age of a keydown event to be used in a jump navigation. This
-// is only for cases when the keyup event is lost.
-const GO_KEY_TIMEOUT_MS = 1000;
-
-const V_KEY_TIMEOUT_MS = 1000;
-
-export const ShortcutSection = {
- ACTIONS: 'Actions',
- DIFFS: 'Diffs',
- EVERYWHERE: 'Everywhere',
- FILE_LIST: 'File list',
- NAVIGATION: 'Navigation',
- REPLY_DIALOG: 'Reply dialog',
-};
-
-export const Shortcut = {
- OPEN_SHORTCUT_HELP_DIALOG: 'OPEN_SHORTCUT_HELP_DIALOG',
- GO_TO_USER_DASHBOARD: 'GO_TO_USER_DASHBOARD',
- GO_TO_OPENED_CHANGES: 'GO_TO_OPENED_CHANGES',
- GO_TO_MERGED_CHANGES: 'GO_TO_MERGED_CHANGES',
- GO_TO_ABANDONED_CHANGES: 'GO_TO_ABANDONED_CHANGES',
- GO_TO_WATCHED_CHANGES: 'GO_TO_WATCHED_CHANGES',
-
- CURSOR_NEXT_CHANGE: 'CURSOR_NEXT_CHANGE',
- CURSOR_PREV_CHANGE: 'CURSOR_PREV_CHANGE',
- OPEN_CHANGE: 'OPEN_CHANGE',
- NEXT_PAGE: 'NEXT_PAGE',
- PREV_PAGE: 'PREV_PAGE',
- TOGGLE_CHANGE_REVIEWED: 'TOGGLE_CHANGE_REVIEWED',
- TOGGLE_CHANGE_STAR: 'TOGGLE_CHANGE_STAR',
- REFRESH_CHANGE_LIST: 'REFRESH_CHANGE_LIST',
-
- OPEN_REPLY_DIALOG: 'OPEN_REPLY_DIALOG',
- OPEN_DOWNLOAD_DIALOG: 'OPEN_DOWNLOAD_DIALOG',
- EXPAND_ALL_MESSAGES: 'EXPAND_ALL_MESSAGES',
- COLLAPSE_ALL_MESSAGES: 'COLLAPSE_ALL_MESSAGES',
- UP_TO_DASHBOARD: 'UP_TO_DASHBOARD',
- UP_TO_CHANGE: 'UP_TO_CHANGE',
- TOGGLE_DIFF_MODE: 'TOGGLE_DIFF_MODE',
- REFRESH_CHANGE: 'REFRESH_CHANGE',
- EDIT_TOPIC: 'EDIT_TOPIC',
- DIFF_AGAINST_BASE: 'DIFF_AGAINST_BASE',
- DIFF_AGAINST_LATEST: 'DIFF_AGAINST_LATEST',
- DIFF_BASE_AGAINST_LEFT: 'DIFF_BASE_AGAINST_LEFT',
- DIFF_RIGHT_AGAINST_LATEST: 'DIFF_RIGHT_AGAINST_LATEST',
- DIFF_BASE_AGAINST_LATEST: 'DIFF_BASE_AGAINST_LATEST',
-
- NEXT_LINE: 'NEXT_LINE',
- PREV_LINE: 'PREV_LINE',
- VISIBLE_LINE: 'VISIBLE_LINE',
- NEXT_CHUNK: 'NEXT_CHUNK',
- PREV_CHUNK: 'PREV_CHUNK',
- EXPAND_ALL_DIFF_CONTEXT: 'EXPAND_ALL_DIFF_CONTEXT',
- NEXT_COMMENT_THREAD: 'NEXT_COMMENT_THREAD',
- PREV_COMMENT_THREAD: 'PREV_COMMENT_THREAD',
- EXPAND_ALL_COMMENT_THREADS: 'EXPAND_ALL_COMMENT_THREADS',
- COLLAPSE_ALL_COMMENT_THREADS: 'COLLAPSE_ALL_COMMENT_THREADS',
- LEFT_PANE: 'LEFT_PANE',
- RIGHT_PANE: 'RIGHT_PANE',
- TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
- NEW_COMMENT: 'NEW_COMMENT',
- SAVE_COMMENT: 'SAVE_COMMENT',
- OPEN_DIFF_PREFS: 'OPEN_DIFF_PREFS',
- TOGGLE_DIFF_REVIEWED: 'TOGGLE_DIFF_REVIEWED',
-
- NEXT_FILE: 'NEXT_FILE',
- PREV_FILE: 'PREV_FILE',
- NEXT_FILE_WITH_COMMENTS: 'NEXT_FILE_WITH_COMMENTS',
- PREV_FILE_WITH_COMMENTS: 'PREV_FILE_WITH_COMMENTS',
- NEXT_UNREVIEWED_FILE: 'NEXT_UNREVIEWED_FILE',
- CURSOR_NEXT_FILE: 'CURSOR_NEXT_FILE',
- CURSOR_PREV_FILE: 'CURSOR_PREV_FILE',
- OPEN_FILE: 'OPEN_FILE',
- TOGGLE_FILE_REVIEWED: 'TOGGLE_FILE_REVIEWED',
- TOGGLE_ALL_INLINE_DIFFS: 'TOGGLE_ALL_INLINE_DIFFS',
- TOGGLE_INLINE_DIFF: 'TOGGLE_INLINE_DIFF',
- TOGGLE_HIDE_ALL_COMMENT_THREADS: 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
-
- OPEN_FIRST_FILE: 'OPEN_FIRST_FILE',
- OPEN_LAST_FILE: 'OPEN_LAST_FILE',
-
- SEARCH: 'SEARCH',
- SEND_REPLY: 'SEND_REPLY',
- EMOJI_DROPDOWN: 'EMOJI_DROPDOWN',
- TOGGLE_BLAME: 'TOGGLE_BLAME',
-};
-
-const _help = new Map();
-
-function _describe(shortcut, section, text) {
- if (!_help.has(section)) {
- _help.set(section, []);
- }
- _help.get(section).push({shortcut, text});
-}
-
-_describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
-_describe(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, ShortcutSection.EVERYWHERE,
- 'Show this dialog');
-_describe(Shortcut.GO_TO_USER_DASHBOARD, ShortcutSection.EVERYWHERE,
- 'Go to User Dashboard');
-_describe(Shortcut.GO_TO_OPENED_CHANGES, ShortcutSection.EVERYWHERE,
- 'Go to Opened Changes');
-_describe(Shortcut.GO_TO_MERGED_CHANGES, ShortcutSection.EVERYWHERE,
- 'Go to Merged Changes');
-_describe(Shortcut.GO_TO_ABANDONED_CHANGES, ShortcutSection.EVERYWHERE,
- 'Go to Abandoned Changes');
-_describe(Shortcut.GO_TO_WATCHED_CHANGES, ShortcutSection.EVERYWHERE,
- 'Go to Watched Changes');
-
-_describe(Shortcut.CURSOR_NEXT_CHANGE, ShortcutSection.ACTIONS,
- 'Select next change');
-_describe(Shortcut.CURSOR_PREV_CHANGE, ShortcutSection.ACTIONS,
- 'Select previous change');
-_describe(Shortcut.OPEN_CHANGE, ShortcutSection.ACTIONS,
- 'Show selected change');
-_describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
-_describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
-_describe(Shortcut.OPEN_REPLY_DIALOG, ShortcutSection.ACTIONS,
- 'Open reply dialog to publish comments and add reviewers');
-_describe(Shortcut.OPEN_DOWNLOAD_DIALOG, ShortcutSection.ACTIONS,
- 'Open download overlay');
-_describe(Shortcut.EXPAND_ALL_MESSAGES, ShortcutSection.ACTIONS,
- 'Expand all messages');
-_describe(Shortcut.COLLAPSE_ALL_MESSAGES, ShortcutSection.ACTIONS,
- 'Collapse all messages');
-_describe(Shortcut.REFRESH_CHANGE, ShortcutSection.ACTIONS,
- 'Reload the change at the latest patch');
-_describe(Shortcut.TOGGLE_CHANGE_REVIEWED, ShortcutSection.ACTIONS,
- 'Mark/unmark change as reviewed');
-_describe(Shortcut.TOGGLE_FILE_REVIEWED, ShortcutSection.ACTIONS,
- 'Toggle review flag on selected file');
-_describe(Shortcut.REFRESH_CHANGE_LIST, ShortcutSection.ACTIONS,
- 'Refresh list of changes');
-_describe(Shortcut.TOGGLE_CHANGE_STAR, ShortcutSection.ACTIONS,
- 'Star/unstar change');
-_describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS,
- 'Add a change topic');
-_describe(Shortcut.DIFF_AGAINST_BASE, ShortcutSection.ACTIONS,
- 'Diff against base');
-_describe(Shortcut.DIFF_AGAINST_LATEST, ShortcutSection.ACTIONS,
- 'Diff against latest patchset');
-_describe(Shortcut.DIFF_BASE_AGAINST_LEFT, ShortcutSection.ACTIONS,
- 'Diff base against left');
-_describe(Shortcut.DIFF_RIGHT_AGAINST_LATEST, ShortcutSection.ACTIONS,
- 'Diff right against latest');
-_describe(Shortcut.DIFF_BASE_AGAINST_LATEST, ShortcutSection.ACTIONS,
- 'Diff base against latest');
-
-_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
-_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
-_describe(Shortcut.DIFF_AGAINST_BASE, ShortcutSection.DIFFS,
- 'Diff against base');
-_describe(Shortcut.DIFF_AGAINST_LATEST, ShortcutSection.DIFFS,
- 'Diff against latest patchset');
-_describe(Shortcut.DIFF_BASE_AGAINST_LEFT, ShortcutSection.DIFFS,
- 'Diff base against left');
-_describe(Shortcut.DIFF_RIGHT_AGAINST_LATEST, ShortcutSection.DIFFS,
- 'Diff right against latest');
-_describe(Shortcut.DIFF_BASE_AGAINST_LATEST, ShortcutSection.DIFFS,
- 'Diff base against latest');
-_describe(Shortcut.VISIBLE_LINE, ShortcutSection.DIFFS,
- 'Move cursor to currently visible code');
-_describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS,
- 'Go to next diff chunk');
-_describe(Shortcut.PREV_CHUNK, ShortcutSection.DIFFS,
- 'Go to previous diff chunk');
-_describe(Shortcut.EXPAND_ALL_DIFF_CONTEXT, ShortcutSection.DIFFS,
- 'Expand all diff context');
-_describe(Shortcut.NEXT_COMMENT_THREAD, ShortcutSection.DIFFS,
- 'Go to next comment thread');
-_describe(Shortcut.PREV_COMMENT_THREAD, ShortcutSection.DIFFS,
- 'Go to previous comment thread');
-_describe(Shortcut.EXPAND_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
- 'Expand all comment threads');
-_describe(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
- 'Collapse all comment threads');
-_describe(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, ShortcutSection.DIFFS,
- 'Hide/Display all comment threads');
-_describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
-_describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
-_describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
- 'Hide/show left diff');
-_describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
-_describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
-_describe(Shortcut.OPEN_DIFF_PREFS, ShortcutSection.DIFFS,
- 'Show diff preferences');
-_describe(Shortcut.TOGGLE_DIFF_REVIEWED, ShortcutSection.DIFFS,
- 'Mark/unmark file as reviewed');
-_describe(Shortcut.TOGGLE_DIFF_MODE, ShortcutSection.DIFFS,
- 'Toggle unified/side-by-side diff');
-_describe(Shortcut.NEXT_UNREVIEWED_FILE, ShortcutSection.DIFFS,
- 'Mark file as reviewed and go to next unreviewed file');
-_describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame');
-
-_describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
-_describe(Shortcut.PREV_FILE, ShortcutSection.NAVIGATION,
- 'Go to previous file');
-_describe(Shortcut.NEXT_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
- 'Go to next file that has comments');
-_describe(Shortcut.PREV_FILE_WITH_COMMENTS, ShortcutSection.NAVIGATION,
- 'Go to previous file that has comments');
-_describe(Shortcut.OPEN_FIRST_FILE, ShortcutSection.NAVIGATION,
- 'Go to first file');
-_describe(Shortcut.OPEN_LAST_FILE, ShortcutSection.NAVIGATION,
- 'Go to last file');
-_describe(Shortcut.UP_TO_DASHBOARD, ShortcutSection.NAVIGATION,
- 'Up to dashboard');
-_describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
-
-_describe(Shortcut.CURSOR_NEXT_FILE, ShortcutSection.FILE_LIST,
- 'Select next file');
-_describe(Shortcut.CURSOR_PREV_FILE, ShortcutSection.FILE_LIST,
- 'Select previous file');
-_describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST,
- 'Go to selected file');
-_describe(Shortcut.TOGGLE_ALL_INLINE_DIFFS, ShortcutSection.FILE_LIST,
- 'Show/hide all inline diffs');
-_describe(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, ShortcutSection.FILE_LIST,
- 'Hide/Display all comment threads');
-_describe(Shortcut.TOGGLE_INLINE_DIFF, ShortcutSection.FILE_LIST,
- 'Show/hide selected inline diff');
-
-_describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
-_describe(Shortcut.EMOJI_DROPDOWN, ShortcutSection.REPLY_DIALOG,
- 'Emoji dropdown');
-
-// Must be declared outside behavior implementation to be accessed inside
-// behavior functions.
-
-/** @return {!(Event|PolymerDomApi|PolymerEventApi)} */
-const getKeyboardEvent = function(e) {
- e = dom(e.detail ? e.detail.keyboardEvent : e);
- // When e is a keyboardEvent, e.event is not null.
- if (e.event) { e = e.event; }
- return e;
-};
-
-export class ShortcutManager {
- constructor() {
- this.activeHosts = new Map();
- this.bindings = new Map();
- this.listeners = new Set();
- }
-
- bindShortcut(shortcut, ...bindings) {
- this.bindings.set(shortcut, bindings);
- }
-
- getBindingsForShortcut(shortcut) {
- return this.bindings.get(shortcut);
- }
-
- attachHost(host) {
- if (!host.keyboardShortcuts) { return; }
- const shortcuts = host.keyboardShortcuts();
- this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
- this.notifyListeners();
- return shortcuts;
- }
-
- detachHost(host) {
- if (this.activeHosts.delete(host)) {
- this.notifyListeners();
- return true;
- }
- return false;
- }
-
- addListener(listener) {
- this.listeners.add(listener);
- listener(this.directoryView());
- }
-
- removeListener(listener) {
- return this.listeners.delete(listener);
- }
-
- getDescription(section, shortcutName) {
- const binding =
- _help.get(section).find(binding => binding.shortcut == shortcutName);
- return binding ? binding.text : '';
- }
-
- getShortcut(shortcutName) {
- const binding = this.bindings.get(shortcutName);
- return binding ? this.describeBinding(binding) : '';
- }
-
- activeShortcutsBySection() {
- const activeShortcuts = new Set();
- this.activeHosts.forEach(shortcuts => {
- shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
- });
-
- const activeShortcutsBySection = new Map();
- _help.forEach((shortcutList, section) => {
- shortcutList.forEach(shortcutHelp => {
- if (activeShortcuts.has(shortcutHelp.shortcut)) {
- if (!activeShortcutsBySection.has(section)) {
- activeShortcutsBySection.set(section, []);
- }
- activeShortcutsBySection.get(section).push(shortcutHelp);
- }
- });
- });
- return activeShortcutsBySection;
- }
-
- directoryView() {
- const view = new Map();
- this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
- const sectionView = [];
- shortcutHelps.forEach(shortcutHelp => {
- const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
- if (!bindingDesc) { return; }
- this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
- sectionView.push({
- binding: bindingDesc,
- text: shortcutHelp.text,
- });
- });
- });
- view.set(section, sectionView);
- });
- return view;
- }
-
- distributeBindingDesc(bindingDesc) {
- if (bindingDesc.length === 1 ||
- this.comboSetDisplayWidth(bindingDesc) < 21) {
- return [bindingDesc];
- }
- // Find the largest prefix of bindings that is under the
- // size threshold.
- const head = [bindingDesc[0]];
- for (let i = 1; i < bindingDesc.length; i++) {
- head.push(bindingDesc[i]);
- if (this.comboSetDisplayWidth(head) >= 21) {
- head.pop();
- return [head].concat(
- this.distributeBindingDesc(bindingDesc.slice(i)));
- }
- }
- }
-
- comboSetDisplayWidth(bindingDesc) {
- const bindingSizer = binding => binding.reduce(
- (acc, key) => acc + key.length, 0);
- // Width is the sum of strings + (n-1) * 2 to account for the word
- // "or" joining them.
- return bindingDesc.reduce(
- (acc, binding) => acc + bindingSizer(binding), 0) +
- 2 * (bindingDesc.length - 1);
- }
-
- describeBindings(shortcut) {
- const bindings = this.bindings.get(shortcut);
- if (!bindings) { return null; }
- if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
- return bindings.slice(1).map(
- binding => this._describeKey(binding)
- )
- .map(binding => ['g'].concat(binding));
- }
- if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
- return bindings.slice(1).map(
- binding => this._describeKey(binding)
- )
- .map(binding => ['v'].concat(binding));
- }
- return bindings
- .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
- .map(binding => this.describeBinding(binding));
- }
-
- _describeKey(key) {
- switch (key) {
- case 'shift':
- return 'Shift';
- case 'meta':
- return 'Meta';
- case 'ctrl':
- return 'Ctrl';
- case 'enter':
- return 'Enter';
- case 'up':
- return '↑';
- case 'down':
- return '↓';
- case 'left':
- return '←';
- case 'right':
- return '→';
- default:
- return key;
- }
- }
-
- describeBinding(binding) {
- if (binding.length === 1) {
- return [binding];
- }
- return binding.split(':')[0].split('+').map(part =>
- this._describeKey(part)
- );
- }
-
- notifyListeners() {
- const view = this.directoryView();
- this.listeners.forEach(listener => listener(view));
- }
-}
-
-const shortcutManager = new ShortcutManager();
-
-/**
- * @polymer
- * @mixinFunction
- */
-const InternalKeyboardShortcutMixin = dedupingMixin(superClass => {
- /**
- * @polymer
- * @mixinClass
- */
- class Mixin extends superClass {
- static get properties() {
- return {
- _shortcut_go_key_last_pressed: {
- type: Number,
- value: null,
- },
- _shortcut_v_key_last_pressed: {
- type: Number,
- value: null,
- },
- _shortcut_go_table: {
- type: Array,
- value() {
- return new Map();
- },
- },
- _shortcut_v_table: {
- type: Array,
- value() {
- return new Map();
- },
- },
- };
- }
-
- modifierPressed(e) {
- /* We are checking for g/v as modifiers pressed. There are cases such as
- * pressing v and then /, where we want the handler for / to be triggered.
- * TODO(dhruvsri): find a way to support that keyboard combination
- */
- e = getKeyboardEvent(e);
- return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey ||
- !!this._inGoKeyMode() || !!this._inVKeyMode();
- }
-
- isModifierPressed(e, modifier) {
- return getKeyboardEvent(e)[modifier];
- }
-
- shouldSuppressKeyboardShortcut(e) {
- e = getKeyboardEvent(e);
- const tagName = dom(e).rootTarget.tagName;
- if (tagName === 'INPUT' || tagName === 'TEXTAREA' ||
- (e.keyCode === 13 && tagName === 'A')) {
- // Suppress shortcuts if the key is 'enter' and target is an anchor.
- return true;
- }
- for (let i = 0; e.path && i < e.path.length; i++) {
- if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
- }
-
- this.dispatchEvent(new CustomEvent('shortcut-triggered', {
- detail: {
- event: e,
- goKey: this._inGoKeyMode(),
- vKey: this._inVKeyMode(),
- },
- composed: true, bubbles: true,
- }));
- return false;
- }
-
- // Alias for getKeyboardEvent.
- /** @return {!Event} */
- getKeyboardEvent(e) {
- return getKeyboardEvent(e);
- }
-
- getRootTarget(e) {
- return dom(getKeyboardEvent(e)).rootTarget;
- }
-
- bindShortcut(shortcut, ...bindings) {
- shortcutManager.bindShortcut(shortcut, ...bindings);
- }
-
- createTitle(shortcutName, section) {
- const desc = shortcutManager.getDescription(section, shortcutName);
- const shortcut = shortcutManager.getShortcut(shortcutName);
- return (desc && shortcut) ? `${desc} (shortcut: ${shortcut})` : '';
- }
-
- _addOwnKeyBindings(shortcut, handler) {
- const bindings = shortcutManager.getBindingsForShortcut(shortcut);
- if (!bindings) {
- return;
- }
- if (bindings[0] === SPECIAL_SHORTCUT.DOC_ONLY) {
- return;
- }
- if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
- bindings.slice(1).forEach(binding =>
- this._shortcut_go_table.set(binding, handler));
- } else if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
- // for each binding added with the go/v key, we set the handler to be
- // handleVKeyAction. handleVKeyAction then looks up in th
- // shortcut_table to see what the relevant handler should be
- bindings.slice(1).forEach(binding =>
- this._shortcut_v_table.set(binding, handler));
- } else {
- this.addOwnKeyBinding(bindings.join(' '), handler);
- }
- }
-
- ready() {
- super.ready();
- }
-
- /** @override */
- connectedCallback() {
- super.connectedCallback();
- const shortcuts = shortcutManager.attachHost(this);
- if (!shortcuts) { return; }
-
- for (const key of Object.keys(shortcuts)) {
- this._addOwnKeyBindings(key, shortcuts[key]);
- }
-
- // each component that uses this behaviour must be aware if go key is
- // pressed or not, since it needs to check it as a modifier
- this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
- this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
-
- // If any of the shortcuts utilized GO_KEY, then they are handled
- // directly by this behavior.
- if (this._shortcut_go_table.size > 0) {
- this._shortcut_go_table.forEach((handler, key) => {
- this.addOwnKeyBinding(key, '_handleGoAction');
- });
- }
-
- this.addOwnKeyBinding('v:keydown', '_handleVKeyDown');
- this.addOwnKeyBinding('v:keyup', '_handleVKeyUp');
- if (this._shortcut_v_table.size > 0) {
- this._shortcut_v_table.forEach((handler, key) => {
- this.addOwnKeyBinding(key, '_handleVAction');
- });
- }
- }
-
- /** @override */
- disconnectedCallback() {
- super.disconnectedCallback();
- if (shortcutManager.detachHost(this)) {
- this.removeOwnKeyBindings();
- }
- }
-
- keyboardShortcuts() {
- return {};
- }
-
- addKeyboardShortcutDirectoryListener(listener) {
- shortcutManager.addListener(listener);
- }
-
- removeKeyboardShortcutDirectoryListener(listener) {
- shortcutManager.removeListener(listener);
- }
-
- _handleVKeyDown(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) return;
- this._shortcut_v_key_last_pressed = Date.now();
- }
-
- _handleVKeyUp(e) {
- setTimeout(() => {
- this._shortcut_v_key_last_pressed = null;
- }, V_KEY_TIMEOUT_MS);
- }
-
- _inVKeyMode() {
- return this._shortcut_v_key_last_pressed &&
- (Date.now() - this._shortcut_v_key_last_pressed <=
- V_KEY_TIMEOUT_MS);
- }
-
- _handleVAction(e) {
- if (!this._inVKeyMode() ||
- !this._shortcut_v_table.has(e.detail.key) ||
- this.shouldSuppressKeyboardShortcut(e)) {
- return;
- }
- e.preventDefault();
- const handler = this._shortcut_v_table.get(e.detail.key);
- this[handler](e);
- }
-
- _handleGoKeyDown(e) {
- if (this.shouldSuppressKeyboardShortcut(e)) return;
- this._shortcut_go_key_last_pressed = Date.now();
- }
-
- _handleGoKeyUp(e) {
- // Set go_key_last_pressed to null `GO_KEY_TIMEOUT_MS` after keyup event
- // so that users can trigger `g + i` by pressing g and i quickly.
- setTimeout(() => {
- this._shortcut_go_key_last_pressed = null;
- }, GO_KEY_TIMEOUT_MS);
- }
-
- _inGoKeyMode() {
- return this._shortcut_go_key_last_pressed &&
- (Date.now() - this._shortcut_go_key_last_pressed <=
- GO_KEY_TIMEOUT_MS);
- }
-
- _handleGoAction(e) {
- if (!this._inGoKeyMode() ||
- !this._shortcut_go_table.has(e.detail.key) ||
- this.shouldSuppressKeyboardShortcut(e)) {
- return;
- }
- e.preventDefault();
- const handler = this._shortcut_go_table.get(e.detail.key);
- this[handler](e);
- }
- }
-
- return Mixin;
-});
-
-// The following doesn't work (IronA11yKeysBehavior crashes):
-// const KeyboardShortcutMixin = dedupingMixin(superClass => {
-// class Mixin extends mixinBehaviors([IronA11yKeysBehavior], superClass) {
-// ...
-// }
-// return Mixin;
-// }
-// This is a workaround
-export const KeyboardShortcutMixin = superClass =>
- InternalKeyboardShortcutMixin(
- mixinBehaviors([IronA11yKeysBehavior], superClass));
-
-export function _testOnly_getShortcutManagerInstance() {
- return shortcutManager;
-}
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
new file mode 100644
index 0000000..7aade93
--- /dev/null
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.ts
@@ -0,0 +1,1096 @@
+/**
+ * @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.
+ */
+/*
+
+How to Add a Keyboard Shortcut
+==============================
+
+A keyboard shortcut is composed of the following parts:
+
+ 1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
+ 2. Documentation for the keyboard shortcut help dialog
+ 3. A binding between key combos and the semantic identifier
+ 4. A binding between the semantic identifier and a listener
+
+Parts (1) and (2) for all shortcuts are defined in this file. The semantic
+identifier is declared in the Shortcut enum near the head of this script:
+
+ const Shortcut = {
+ // ...
+ TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
+ // ...
+ };
+
+Immediately following the Shortcut enum definition, there is a _describe
+function defined which is then invoked many times to populate the help dialog.
+Add a new invocation here to document the shortcut:
+
+ _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
+ 'Hide/show left diff');
+
+When an attached view binds one or more key combos to this shortcut, the help
+dialog will display this text in the given section (in this case, "Diffs"). See
+the ShortcutSection enum immediately below for the list of supported sections.
+
+Part (3), the actual key bindings, are declared by gr-app. In the future, this
+system may be expanded to allow key binding customizations by plugins or user
+preferences. Key bindings are defined in the following forms:
+
+ // Ordinary shortcut with a single binding.
+ this.bindShortcut(
+ Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
+
+ // Ordinary shortcut with multiple bindings.
+ this.bindShortcut(
+ Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
+
+ // A "go-key" keyboard shortcut, which is combined with a previously and
+ // continuously pressed "go" key (the go-key is hard-coded as 'g').
+ this.bindShortcut(
+ Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'o');
+
+ // A "doc-only" keyboard shortcut. This declares the key-binding for help
+ // dialog purposes, but doesn't actually implement the binding. It is up
+ // to some element to implement this binding using iron-a11y-keys-behavior's
+ // keyBindings property.
+ this.bindShortcut(
+ Shortcut.EXPAND_ALL_COMMENT_THREADS, SPECIAL_SHORTCUT.DOC_ONLY, 'e');
+
+Part (4), the listener definitions, are declared by the view or element that
+implements the shortcut behavior. This is done by implementing a method named
+keyboardShortcuts() in an element that mixes in this behavior, returning an
+object that maps semantic identifiers (as property names) to listener method
+names, like this:
+
+ keyboardShortcuts() {
+ return {
+ [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+ };
+ },
+
+You can implement key bindings in an element that is hosted by a view IF that
+element is always attached exactly once under that view (e.g. the search bar in
+gr-app). When that is not the case, you will have to define a doc-only binding
+in gr-app, declare the shortcut in the view that hosts the element, and use
+iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
+element. An example of this is in comment threads. A diff view supports actions
+on comment threads, but there may be zero or many comment threads attached at
+any given point. So the shortcut is declared as doc-only by the diff view and
+by gr-app, and actually implemented by gr-comment-thread.
+
+NOTE: doc-only shortcuts will not be customizable in the same way that other
+shortcuts are.
+*/
+
+import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
+import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
+import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
+import {property} from '@polymer/decorators';
+import {PolymerElement} from '@polymer/polymer';
+import {Constructor} from '../../utils/common-util';
+import {
+ CustomKeyboardEvent,
+ ShortcutTriggeredEventDetail,
+} from '../../types/events';
+
+/** Enum for all special shortcuts */
+export enum SPECIAL_SHORTCUT {
+ DOC_ONLY = 'DOC_ONLY',
+ GO_KEY = 'GO_KEY',
+ V_KEY = 'V_KEY',
+}
+
+// The maximum age of a keydown event to be used in a jump navigation. This
+// is only for cases when the keyup event is lost.
+const GO_KEY_TIMEOUT_MS = 1000;
+
+const V_KEY_TIMEOUT_MS = 1000;
+
+const THROTTLE_INTERVAL_MS = 500;
+
+/**
+ * Enum for all shortcut sections, where that shortcut should be applied to.
+ */
+export enum ShortcutSection {
+ ACTIONS = 'Actions',
+ DIFFS = 'Diffs',
+ EVERYWHERE = 'Everywhere',
+ FILE_LIST = 'File list',
+ NAVIGATION = 'Navigation',
+ REPLY_DIALOG = 'Reply dialog',
+}
+
+/**
+ * Enum for all possible shortcut names.
+ */
+export enum Shortcut {
+ OPEN_SHORTCUT_HELP_DIALOG = 'OPEN_SHORTCUT_HELP_DIALOG',
+ GO_TO_USER_DASHBOARD = 'GO_TO_USER_DASHBOARD',
+ GO_TO_OPENED_CHANGES = 'GO_TO_OPENED_CHANGES',
+ GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
+ GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
+ GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
+
+ CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
+ CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
+ OPEN_CHANGE = 'OPEN_CHANGE',
+ NEXT_PAGE = 'NEXT_PAGE',
+ PREV_PAGE = 'PREV_PAGE',
+ TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
+ TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
+ REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
+
+ OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
+ OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
+ EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
+ COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
+ UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
+ UP_TO_CHANGE = 'UP_TO_CHANGE',
+ TOGGLE_DIFF_MODE = 'TOGGLE_DIFF_MODE',
+ REFRESH_CHANGE = 'REFRESH_CHANGE',
+ EDIT_TOPIC = 'EDIT_TOPIC',
+ DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
+ DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
+ DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
+ DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
+ DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
+
+ NEXT_LINE = 'NEXT_LINE',
+ PREV_LINE = 'PREV_LINE',
+ VISIBLE_LINE = 'VISIBLE_LINE',
+ NEXT_CHUNK = 'NEXT_CHUNK',
+ PREV_CHUNK = 'PREV_CHUNK',
+ EXPAND_ALL_DIFF_CONTEXT = 'EXPAND_ALL_DIFF_CONTEXT',
+ NEXT_COMMENT_THREAD = 'NEXT_COMMENT_THREAD',
+ PREV_COMMENT_THREAD = 'PREV_COMMENT_THREAD',
+ EXPAND_ALL_COMMENT_THREADS = 'EXPAND_ALL_COMMENT_THREADS',
+ COLLAPSE_ALL_COMMENT_THREADS = 'COLLAPSE_ALL_COMMENT_THREADS',
+ LEFT_PANE = 'LEFT_PANE',
+ RIGHT_PANE = 'RIGHT_PANE',
+ TOGGLE_LEFT_PANE = 'TOGGLE_LEFT_PANE',
+ NEW_COMMENT = 'NEW_COMMENT',
+ SAVE_COMMENT = 'SAVE_COMMENT',
+ OPEN_DIFF_PREFS = 'OPEN_DIFF_PREFS',
+ TOGGLE_DIFF_REVIEWED = 'TOGGLE_DIFF_REVIEWED',
+
+ NEXT_FILE = 'NEXT_FILE',
+ PREV_FILE = 'PREV_FILE',
+ NEXT_FILE_WITH_COMMENTS = 'NEXT_FILE_WITH_COMMENTS',
+ PREV_FILE_WITH_COMMENTS = 'PREV_FILE_WITH_COMMENTS',
+ NEXT_UNREVIEWED_FILE = 'NEXT_UNREVIEWED_FILE',
+ CURSOR_NEXT_FILE = 'CURSOR_NEXT_FILE',
+ CURSOR_PREV_FILE = 'CURSOR_PREV_FILE',
+ OPEN_FILE = 'OPEN_FILE',
+ TOGGLE_FILE_REVIEWED = 'TOGGLE_FILE_REVIEWED',
+ TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
+ TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
+ TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
+ OPEN_FILE_LIST = 'OPEN_FILE_LIST',
+
+ OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
+ OPEN_LAST_FILE = 'OPEN_LAST_FILE',
+
+ SEARCH = 'SEARCH',
+ SEND_REPLY = 'SEND_REPLY',
+ EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
+ TOGGLE_BLAME = 'TOGGLE_BLAME',
+}
+
+export type SectionView = Array<{binding: string[][]; text: string}>;
+
+/**
+ * The interface for listener for shortcut events.
+ */
+export type ShortcutListener = (
+ viewMap?: Map<ShortcutSection, SectionView>
+) => void;
+
+interface ShortcutEnabledElement extends PolymerElement {
+ // TODO: should replace with Map so we can have proper type here
+ keyboardShortcuts(): {[shortcut: string]: string};
+}
+
+interface ShortcutHelpItem {
+ shortcut: Shortcut;
+ text: string;
+}
+
+// TODO(TS): rename to something more meaningful
+const _help = new Map<ShortcutSection, ShortcutHelpItem[]>();
+
+function _describe(shortcut: Shortcut, section: ShortcutSection, text: string) {
+ if (!_help.has(section)) {
+ _help.set(section, []);
+ }
+ const shortcuts = _help.get(section);
+ if (shortcuts) {
+ shortcuts.push({shortcut, text});
+ }
+}
+
+_describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
+_describe(
+ Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
+ ShortcutSection.EVERYWHERE,
+ 'Show this dialog'
+);
+_describe(
+ Shortcut.GO_TO_USER_DASHBOARD,
+ ShortcutSection.EVERYWHERE,
+ 'Go to User Dashboard'
+);
+_describe(
+ Shortcut.GO_TO_OPENED_CHANGES,
+ ShortcutSection.EVERYWHERE,
+ 'Go to Opened Changes'
+);
+_describe(
+ Shortcut.GO_TO_MERGED_CHANGES,
+ ShortcutSection.EVERYWHERE,
+ 'Go to Merged Changes'
+);
+_describe(
+ Shortcut.GO_TO_ABANDONED_CHANGES,
+ ShortcutSection.EVERYWHERE,
+ 'Go to Abandoned Changes'
+);
+_describe(
+ Shortcut.GO_TO_WATCHED_CHANGES,
+ ShortcutSection.EVERYWHERE,
+ 'Go to Watched Changes'
+);
+
+_describe(
+ Shortcut.CURSOR_NEXT_CHANGE,
+ ShortcutSection.ACTIONS,
+ 'Select next change'
+);
+_describe(
+ Shortcut.CURSOR_PREV_CHANGE,
+ ShortcutSection.ACTIONS,
+ 'Select previous change'
+);
+_describe(
+ Shortcut.OPEN_CHANGE,
+ ShortcutSection.ACTIONS,
+ 'Show selected change'
+);
+_describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
+_describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
+_describe(
+ Shortcut.OPEN_REPLY_DIALOG,
+ ShortcutSection.ACTIONS,
+ 'Open reply dialog to publish comments and add reviewers'
+);
+_describe(
+ Shortcut.OPEN_DOWNLOAD_DIALOG,
+ ShortcutSection.ACTIONS,
+ 'Open download overlay'
+);
+_describe(
+ Shortcut.EXPAND_ALL_MESSAGES,
+ ShortcutSection.ACTIONS,
+ 'Expand all messages'
+);
+_describe(
+ Shortcut.COLLAPSE_ALL_MESSAGES,
+ ShortcutSection.ACTIONS,
+ 'Collapse all messages'
+);
+_describe(
+ Shortcut.REFRESH_CHANGE,
+ ShortcutSection.ACTIONS,
+ 'Reload the change at the latest patch'
+);
+_describe(
+ Shortcut.TOGGLE_CHANGE_REVIEWED,
+ ShortcutSection.ACTIONS,
+ 'Mark/unmark change as reviewed'
+);
+_describe(
+ Shortcut.TOGGLE_FILE_REVIEWED,
+ ShortcutSection.ACTIONS,
+ 'Toggle review flag on selected file'
+);
+_describe(
+ Shortcut.REFRESH_CHANGE_LIST,
+ ShortcutSection.ACTIONS,
+ 'Refresh list of changes'
+);
+_describe(
+ Shortcut.TOGGLE_CHANGE_STAR,
+ ShortcutSection.ACTIONS,
+ 'Star/unstar change'
+);
+_describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic');
+_describe(
+ Shortcut.DIFF_AGAINST_BASE,
+ ShortcutSection.ACTIONS,
+ 'Diff against base'
+);
+_describe(
+ Shortcut.DIFF_AGAINST_LATEST,
+ ShortcutSection.ACTIONS,
+ 'Diff against latest patchset'
+);
+_describe(
+ Shortcut.DIFF_BASE_AGAINST_LEFT,
+ ShortcutSection.ACTIONS,
+ 'Diff base against left'
+);
+_describe(
+ Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+ ShortcutSection.ACTIONS,
+ 'Diff right against latest'
+);
+_describe(
+ Shortcut.DIFF_BASE_AGAINST_LATEST,
+ ShortcutSection.ACTIONS,
+ 'Diff base against latest'
+);
+
+_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
+_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
+_describe(
+ Shortcut.DIFF_AGAINST_BASE,
+ ShortcutSection.DIFFS,
+ 'Diff against base'
+);
+_describe(
+ Shortcut.DIFF_AGAINST_LATEST,
+ ShortcutSection.DIFFS,
+ 'Diff against latest patchset'
+);
+_describe(
+ Shortcut.DIFF_BASE_AGAINST_LEFT,
+ ShortcutSection.DIFFS,
+ 'Diff base against left'
+);
+_describe(
+ Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+ ShortcutSection.DIFFS,
+ 'Diff right against latest'
+);
+_describe(
+ Shortcut.DIFF_BASE_AGAINST_LATEST,
+ ShortcutSection.DIFFS,
+ 'Diff base against latest'
+);
+_describe(
+ Shortcut.VISIBLE_LINE,
+ ShortcutSection.DIFFS,
+ 'Move cursor to currently visible code'
+);
+_describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk');
+_describe(
+ Shortcut.PREV_CHUNK,
+ ShortcutSection.DIFFS,
+ 'Go to previous diff chunk'
+);
+_describe(
+ Shortcut.EXPAND_ALL_DIFF_CONTEXT,
+ ShortcutSection.DIFFS,
+ 'Expand all diff context'
+);
+_describe(
+ Shortcut.NEXT_COMMENT_THREAD,
+ ShortcutSection.DIFFS,
+ 'Go to next comment thread'
+);
+_describe(
+ Shortcut.PREV_COMMENT_THREAD,
+ ShortcutSection.DIFFS,
+ 'Go to previous comment thread'
+);
+_describe(
+ Shortcut.EXPAND_ALL_COMMENT_THREADS,
+ ShortcutSection.DIFFS,
+ 'Expand all comment threads'
+);
+_describe(
+ Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+ ShortcutSection.DIFFS,
+ 'Collapse all comment threads'
+);
+_describe(
+ Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+ ShortcutSection.DIFFS,
+ 'Hide/Display all comment threads'
+);
+_describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
+_describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
+_describe(
+ Shortcut.TOGGLE_LEFT_PANE,
+ ShortcutSection.DIFFS,
+ 'Hide/show left diff'
+);
+_describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
+_describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
+_describe(
+ Shortcut.OPEN_DIFF_PREFS,
+ ShortcutSection.DIFFS,
+ 'Show diff preferences'
+);
+_describe(
+ Shortcut.TOGGLE_DIFF_REVIEWED,
+ ShortcutSection.DIFFS,
+ 'Mark/unmark file as reviewed'
+);
+_describe(
+ Shortcut.TOGGLE_DIFF_MODE,
+ ShortcutSection.DIFFS,
+ 'Toggle unified/side-by-side diff'
+);
+_describe(
+ Shortcut.NEXT_UNREVIEWED_FILE,
+ ShortcutSection.DIFFS,
+ 'Mark file as reviewed and go to next unreviewed file'
+);
+_describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame');
+
+_describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
+_describe(
+ Shortcut.PREV_FILE,
+ ShortcutSection.NAVIGATION,
+ 'Go to previous file'
+);
+_describe(
+ Shortcut.NEXT_FILE_WITH_COMMENTS,
+ ShortcutSection.NAVIGATION,
+ 'Go to next file that has comments'
+);
+_describe(
+ Shortcut.PREV_FILE_WITH_COMMENTS,
+ ShortcutSection.NAVIGATION,
+ 'Go to previous file that has comments'
+);
+_describe(
+ Shortcut.OPEN_FIRST_FILE,
+ ShortcutSection.NAVIGATION,
+ 'Go to first file'
+);
+_describe(
+ Shortcut.OPEN_LAST_FILE,
+ ShortcutSection.NAVIGATION,
+ 'Go to last file'
+);
+_describe(
+ Shortcut.UP_TO_DASHBOARD,
+ ShortcutSection.NAVIGATION,
+ 'Up to dashboard'
+);
+_describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
+
+_describe(
+ Shortcut.CURSOR_NEXT_FILE,
+ ShortcutSection.FILE_LIST,
+ 'Select next file'
+);
+_describe(
+ Shortcut.CURSOR_PREV_FILE,
+ ShortcutSection.FILE_LIST,
+ 'Select previous file'
+);
+_describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST, 'Go to selected file');
+_describe(
+ Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+ ShortcutSection.FILE_LIST,
+ 'Show/hide all inline diffs'
+);
+_describe(
+ Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+ ShortcutSection.FILE_LIST,
+ 'Hide/Display all comment threads'
+);
+_describe(
+ Shortcut.TOGGLE_INLINE_DIFF,
+ ShortcutSection.FILE_LIST,
+ 'Show/hide selected inline diff'
+);
+
+_describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
+_describe(
+ Shortcut.EMOJI_DROPDOWN,
+ ShortcutSection.REPLY_DIALOG,
+ 'Emoji dropdown'
+);
+
+// Must be declared outside behavior implementation to be accessed inside
+// behavior functions.
+
+function getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent {
+ const event = dom(e.detail ? e.detail.keyboardEvent : e);
+ // TODO(TS): worth checking if this still holds or not, if no, remove this.
+ // When e is a keyboardEvent, e.event is not null.
+ if ('event' in event && (event as CustomKeyboardEvent).event) {
+ return (event as CustomKeyboardEvent).event;
+ }
+ return event as CustomKeyboardEvent;
+}
+
+/**
+ * Shortcut manager, holds all hosts, bindings and listners.
+ */
+export class ShortcutManager {
+ private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
+
+ private readonly bindings = new Map<Shortcut, string[]>();
+
+ public _testOnly_getBindings() {
+ return this.bindings;
+ }
+
+ public _testOnly_isEmpty() {
+ return this.activeHosts.size === 0 && this.listeners.size === 0;
+ }
+
+ private readonly listeners = new Set<ShortcutListener>();
+
+ bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
+ this.bindings.set(shortcut, bindings);
+ }
+
+ getBindingsForShortcut(shortcut: Shortcut) {
+ return this.bindings.get(shortcut);
+ }
+
+ attachHost(host: PolymerElement | ShortcutEnabledElement) {
+ if (!('keyboardShortcuts' in host)) {
+ return;
+ }
+ const shortcuts = host.keyboardShortcuts();
+ this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
+ this.notifyListeners();
+ return shortcuts;
+ }
+
+ detachHost(host: PolymerElement) {
+ if (this.activeHosts.delete(host)) {
+ this.notifyListeners();
+ return true;
+ }
+ return false;
+ }
+
+ addListener(listener: ShortcutListener) {
+ this.listeners.add(listener);
+ listener(this.directoryView());
+ }
+
+ removeListener(listener: ShortcutListener) {
+ return this.listeners.delete(listener);
+ }
+
+ getDescription(section: ShortcutSection, shortcutName: Shortcut) {
+ const bindings = _help.get(section);
+ let desc = '';
+ if (bindings) {
+ const binding = bindings.find(
+ binding => binding.shortcut === shortcutName
+ );
+ desc = binding ? binding.text : '';
+ }
+ return desc;
+ }
+
+ getShortcut(shortcutName: Shortcut) {
+ const bindings = this.bindings.get(shortcutName);
+ return bindings
+ ? bindings
+ .map(binding => this.describeBinding(binding).join('+'))
+ .join(',')
+ : '';
+ }
+
+ activeShortcutsBySection() {
+ const activeShortcuts = new Set<string>();
+ this.activeHosts.forEach(shortcuts => {
+ shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
+ });
+
+ const activeShortcutsBySection = new Map<
+ ShortcutSection,
+ ShortcutHelpItem[]
+ >();
+ _help.forEach((shortcutList, section) => {
+ shortcutList.forEach(shortcutHelp => {
+ if (activeShortcuts.has(shortcutHelp.shortcut)) {
+ if (!activeShortcutsBySection.has(section)) {
+ activeShortcutsBySection.set(section, []);
+ }
+ // From previous condition, the `get(section)`
+ // should always return a valid result
+ activeShortcutsBySection.get(section)!.push(shortcutHelp);
+ }
+ });
+ });
+ return activeShortcutsBySection;
+ }
+
+ directoryView() {
+ const view = new Map<ShortcutSection, SectionView>();
+ this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
+ const sectionView: Array<{binding: string[][]; text: string}> = [];
+ shortcutHelps.forEach(shortcutHelp => {
+ const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
+ if (!bindingDesc) {
+ return;
+ }
+ this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
+ sectionView.push({
+ binding: bindingDesc,
+ text: shortcutHelp.text,
+ });
+ });
+ });
+ view.set(section, sectionView);
+ });
+ return view;
+ }
+
+ distributeBindingDesc(bindingDesc: string[][]): string[][][] {
+ if (
+ bindingDesc.length === 1 ||
+ this.comboSetDisplayWidth(bindingDesc) < 21
+ ) {
+ return [bindingDesc];
+ }
+ // Find the largest prefix of bindings that is under the
+ // size threshold.
+ const head = [bindingDesc[0]];
+ for (let i = 1; i < bindingDesc.length; i++) {
+ head.push(bindingDesc[i]);
+ if (this.comboSetDisplayWidth(head) >= 21) {
+ head.pop();
+ return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
+ }
+ }
+ return [];
+ }
+
+ comboSetDisplayWidth(bindingDesc: string[][]) {
+ const bindingSizer = (binding: string[]) =>
+ binding.reduce((acc, key) => acc + key.length, 0);
+ // Width is the sum of strings + (n-1) * 2 to account for the word
+ // "or" joining them.
+ return (
+ bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
+ 2 * (bindingDesc.length - 1)
+ );
+ }
+
+ describeBindings(shortcut: Shortcut): string[][] | null {
+ const bindings = this.bindings.get(shortcut);
+ if (!bindings) {
+ return null;
+ }
+ // TODO(TS): should check base on length to differentiate two
+ // cases
+ if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
+ return bindings
+ .slice(1)
+ .map(binding => this._describeKey(binding))
+ .map(binding => ['g'].concat(binding));
+ }
+ if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
+ return bindings
+ .slice(1)
+ .map(binding => this._describeKey(binding))
+ .map(binding => ['v'].concat(binding));
+ }
+
+ return bindings
+ .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
+ .map(binding => this.describeBinding(binding));
+ }
+
+ _describeKey(key: string) {
+ switch (key) {
+ case 'shift':
+ return 'Shift';
+ case 'meta':
+ return 'Meta';
+ case 'ctrl':
+ return 'Ctrl';
+ case 'enter':
+ return 'Enter';
+ case 'up':
+ return '\u2191'; // ↑
+ case 'down':
+ return '\u2193'; // ↓
+ case 'left':
+ return '\u2190'; // ←
+ case 'right':
+ return '\u2192'; // →
+ default:
+ return key;
+ }
+ }
+
+ describeBinding(binding: string) {
+ // single key bindings
+ if (binding.length === 1) {
+ return [binding];
+ }
+ return binding
+ .split(':')[0]
+ .split('+')
+ .map(part => this._describeKey(part));
+ }
+
+ notifyListeners() {
+ const view = this.directoryView();
+ this.listeners.forEach(listener => listener(view));
+ }
+}
+
+const shortcutManager = new ShortcutManager();
+
+/**
+ * Enum for supported modifiers.
+ */
+export enum Modifier {
+ SHIFT_KEY = 'shiftKey',
+ CTRL_KEY = 'ctrlKey',
+ META_KEY = 'metaKey',
+ // Add when you need it
+}
+
+interface IronA11yKeysMixinConstructor {
+ // Note: this is needed to have same interface as other mixins
+ new (...args: any[]): IronA11yKeysBehavior;
+}
+/**
+ * @polymer
+ * @mixinFunction
+ */
+const InternalKeyboardShortcutMixin = dedupingMixin(
+ <T extends Constructor<PolymerElement> & IronA11yKeysMixinConstructor>(
+ superClass: T
+ ): T & Constructor<KeyboardShortcutMixinInterface> => {
+ /**
+ * @polymer
+ * @mixinClass
+ */
+ class Mixin extends superClass {
+ @property({type: Number})
+ _shortcut_go_key_last_pressed: number | null = null;
+
+ @property({type: Number})
+ _shortcut_v_key_last_pressed: number | null = null;
+
+ @property({type: Object})
+ _shortcut_go_table: Map<string, string> = new Map();
+
+ @property({type: Object})
+ _shortcut_v_table: Map<string, string> = new Map();
+
+ Shortcut = Shortcut;
+
+ ShortcutSection = ShortcutSection;
+
+ modifierPressed(event: CustomKeyboardEvent) {
+ /* We are checking for g/v as modifiers pressed. There are cases such as
+ * pressing v and then /, where we want the handler for / to be triggered.
+ * TODO(dhruvsri): find a way to support that keyboard combination
+ */
+ const e = getKeyboardEvent(event);
+ return (
+ e.altKey ||
+ e.ctrlKey ||
+ e.metaKey ||
+ e.shiftKey ||
+ !!this._inGoKeyMode() ||
+ !!this.inVKeyMode()
+ );
+ }
+
+ isModifierPressed(e: CustomKeyboardEvent, modifier: Modifier) {
+ return getKeyboardEvent(e)[modifier];
+ }
+
+ shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent) {
+ const e = getKeyboardEvent(event);
+ // TODO(TS): maybe override the EventApi, narrow it down to Element always
+ const target = (dom(e) as EventApi).rootTarget as Element;
+ const tagName = target.tagName;
+ const type = target.getAttribute('type');
+ if (
+ // Suppress shortcuts on <input> and <textarea>, but not on
+ // checkboxes, because we want to enable workflows like 'click
+ // mark-reviewed and then press ] to go to the next file'.
+ (tagName === 'INPUT' && type !== 'checkbox') ||
+ tagName === 'TEXTAREA' ||
+ // Suppress shortcuts if the key is 'enter' and target is an anchor.
+ (e.keyCode === 13 && tagName === 'A')
+ ) {
+ return true;
+ }
+ for (let i = 0; e.path && i < e.path.length; i++) {
+ // TODO(TS): narrow this down to Element from EventTarget first
+ if ((e.path[i] as Element).tagName === 'GR-OVERLAY') {
+ return true;
+ }
+ }
+ const detail: ShortcutTriggeredEventDetail = {
+ event: e,
+ goKey: this._inGoKeyMode(),
+ vKey: this.inVKeyMode(),
+ };
+ this.dispatchEvent(
+ new CustomEvent('shortcut-triggered', {
+ detail,
+ composed: true,
+ bubbles: true,
+ })
+ );
+ return false;
+ }
+
+ // Alias for getKeyboardEvent.
+ getKeyboardEvent(e: CustomKeyboardEvent) {
+ return getKeyboardEvent(e);
+ }
+
+ // TODO(TS): maybe remove, no reference in the code base
+ getRootTarget(e: CustomKeyboardEvent) {
+ // TODO(TS): worth checking if we can limit this to EventApi only
+ // dom currently returns DomNativeApi|EventApi
+ return (dom(getKeyboardEvent(e)) as EventApi).rootTarget;
+ }
+
+ bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
+ shortcutManager.bindShortcut(shortcut, ...bindings);
+ }
+
+ createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+ const desc = shortcutManager.getDescription(section, shortcutName);
+ const shortcut = shortcutManager.getShortcut(shortcutName);
+ return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
+ }
+
+ _throttleWrap(fn: (e: Event) => void) {
+ let lastCall: number | undefined;
+ return (e: Event) => {
+ if (
+ lastCall !== undefined &&
+ Date.now() - lastCall < THROTTLE_INTERVAL_MS
+ ) {
+ return;
+ }
+ lastCall = Date.now();
+ fn(e);
+ };
+ }
+
+ _addOwnKeyBindings(shortcut: Shortcut, handler: string) {
+ const bindings = shortcutManager.getBindingsForShortcut(shortcut);
+ if (!bindings) {
+ return;
+ }
+ if (bindings[0] === SPECIAL_SHORTCUT.DOC_ONLY) {
+ return;
+ }
+ if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
+ bindings
+ .slice(1)
+ .forEach(binding => this._shortcut_go_table.set(binding, handler));
+ } else if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
+ // for each binding added with the go/v key, we set the handler to be
+ // handleVKeyAction. handleVKeyAction then looks up in th
+ // shortcut_table to see what the relevant handler should be
+ bindings
+ .slice(1)
+ .forEach(binding => this._shortcut_v_table.set(binding, handler));
+ } else {
+ this.addOwnKeyBinding(bindings.join(' '), handler);
+ }
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+ const shortcuts = shortcutManager.attachHost(this);
+ if (!shortcuts) {
+ return;
+ }
+
+ for (const key of Object.keys(shortcuts)) {
+ // TODO(TS): not needed if convert shortcuts to Map
+ this._addOwnKeyBindings(key as Shortcut, shortcuts[key]);
+ }
+
+ // each component that uses this behaviour must be aware if go key is
+ // pressed or not, since it needs to check it as a modifier
+ this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
+ this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
+
+ // If any of the shortcuts utilized GO_KEY, then they are handled
+ // directly by this behavior.
+ if (this._shortcut_go_table.size > 0) {
+ this._shortcut_go_table.forEach((_, key) => {
+ this.addOwnKeyBinding(key, '_handleGoAction');
+ });
+ }
+
+ this.addOwnKeyBinding('v:keydown', '_handleVKeyDown');
+ this.addOwnKeyBinding('v:keyup', '_handleVKeyUp');
+ if (this._shortcut_v_table.size > 0) {
+ this._shortcut_v_table.forEach((_, key) => {
+ this.addOwnKeyBinding(key, '_handleVAction');
+ });
+ }
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ if (shortcutManager.detachHost(this)) {
+ this.removeOwnKeyBindings();
+ }
+ }
+
+ keyboardShortcuts() {
+ return {};
+ }
+
+ addKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
+ shortcutManager.addListener(listener);
+ }
+
+ removeKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
+ shortcutManager.removeListener(listener);
+ }
+
+ _handleVKeyDown(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ this._shortcut_v_key_last_pressed = Date.now();
+ }
+
+ _handleVKeyUp() {
+ setTimeout(() => {
+ this._shortcut_v_key_last_pressed = null;
+ }, V_KEY_TIMEOUT_MS);
+ }
+
+ private inVKeyMode() {
+ return !!(
+ this._shortcut_v_key_last_pressed &&
+ Date.now() - this._shortcut_v_key_last_pressed <= V_KEY_TIMEOUT_MS
+ );
+ }
+
+ _handleVAction(e: CustomKeyboardEvent) {
+ if (
+ !this.inVKeyMode() ||
+ !this._shortcut_v_table.has(e.detail.key) ||
+ this.shouldSuppressKeyboardShortcut(e)
+ ) {
+ return;
+ }
+ e.preventDefault();
+ const handler = this._shortcut_v_table.get(e.detail.key);
+ if (handler) {
+ // TODO(TS): should fix this
+ (this as any)[handler](e);
+ }
+ }
+
+ _handleGoKeyDown(e: CustomKeyboardEvent) {
+ if (this.shouldSuppressKeyboardShortcut(e)) return;
+ this._shortcut_go_key_last_pressed = Date.now();
+ }
+
+ _handleGoKeyUp() {
+ // Set go_key_last_pressed to null `GO_KEY_TIMEOUT_MS` after keyup event
+ // so that users can trigger `g + i` by pressing g and i quickly.
+ setTimeout(() => {
+ this._shortcut_go_key_last_pressed = null;
+ }, GO_KEY_TIMEOUT_MS);
+ }
+
+ _inGoKeyMode() {
+ return !!(
+ this._shortcut_go_key_last_pressed &&
+ Date.now() - this._shortcut_go_key_last_pressed <= GO_KEY_TIMEOUT_MS
+ );
+ }
+
+ _handleGoAction(e: CustomKeyboardEvent) {
+ if (
+ !this._inGoKeyMode() ||
+ !this._shortcut_go_table.has(e.detail.key) ||
+ this.shouldSuppressKeyboardShortcut(e)
+ ) {
+ return;
+ }
+ e.preventDefault();
+ const handler = this._shortcut_go_table.get(e.detail.key);
+ if (handler) {
+ // TODO(TS): should fix this
+ (this as any)[handler](e);
+ }
+ }
+ }
+
+ return Mixin;
+ }
+);
+
+// The following doesn't work (IronA11yKeysBehavior crashes):
+// const KeyboardShortcutMixin = dedupingMixin(superClass => {
+// class Mixin extends mixinBehaviors([IronA11yKeysBehavior], superClass) {
+// ...
+// }
+// return Mixin;
+// }
+// This is a workaround
+export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
+ superClass: T
+): T & Constructor<KeyboardShortcutMixinInterface> => {
+ return InternalKeyboardShortcutMixin(
+ // TODO(TS): mixinBehaviors in some lib is returning: `new () => T` instead
+ // which will fail the type check due to missing IronA11yKeysBehavior interface
+ mixinBehaviors([IronA11yKeysBehavior], superClass) as any
+ );
+};
+
+/** The interface corresponding to KeyboardShortcutMixin */
+export interface KeyboardShortcutMixinInterface {
+ Shortcut: typeof Shortcut;
+ ShortcutSection: typeof ShortcutSection;
+ _shortcut_go_key_last_pressed: number | null;
+ _shortcut_v_key_last_pressed: number | null;
+ _shortcut_go_table: Map<string, string>;
+ _shortcut_v_table: Map<string, string>;
+ keyboardShortcuts(): {[key: string]: string | null};
+ createTitle(name: Shortcut, section: ShortcutSection): string;
+ bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
+ shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
+ modifierPressed(event: CustomKeyboardEvent): boolean;
+ isModifierPressed(event: CustomKeyboardEvent, modifier: Modifier): boolean;
+ getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent;
+ addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
+ removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
+ // TODO(TS): Remove underscore. Apparently not a private method.
+ _throttleWrap(eventListener: EventListener): EventListener;
+}
+
+export function _testOnly_getShortcutManagerInstance() {
+ return shortcutManager;
+}
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
index 3ab7b6d..180dbe7 100644
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
+++ b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
@@ -28,7 +28,7 @@
const withinOverlayFixture = fixtureFromTemplate(html`
<gr-overlay>
- <keyboard-shortcut-mixin-test-element>
+ <keyboard-shortcut-mixin-test-element>
</keyboard-shortcut-mixin-test-element>
</gr-overlay>
`);
@@ -73,6 +73,24 @@
[']', '}', 'right']);
});
+ test('getShortcut', () => {
+ const mgr = new ShortcutManager();
+ const NEXT_FILE = Shortcut.NEXT_FILE;
+
+ assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+ mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
+ assert.equal(mgr.getShortcut(NEXT_FILE), '],},→');
+ });
+
+ test('getShortcut with modifiers', () => {
+ const mgr = new ShortcutManager();
+ const NEXT_FILE = Shortcut.NEXT_FILE;
+
+ assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
+ mgr.bindShortcut(NEXT_FILE, 'Shift+a:key');
+ assert.equal(mgr.getShortcut(NEXT_FILE), 'Shift+a');
+ });
+
suite('binding descriptions', () => {
function mapToObject(m) {
const o = {};
@@ -293,6 +311,17 @@
MockInteractions.keyDownOn(inputEl, 75, null, 'k');
});
+ test('doesn’t block kb shortcuts for checkboxes', done => {
+ const inputEl = document.createElement('input');
+ inputEl.setAttribute('type', 'checkbox');
+ element.appendChild(inputEl);
+ element._handleKey = e => {
+ assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
+ done();
+ };
+ MockInteractions.keyDownOn(inputEl, 75, null, 'k');
+ });
+
test('blocks kb shortcuts for textarea els', done => {
const textareaEl = document.createElement('textarea');
element.appendChild(textareaEl);
diff --git a/polygerrit-ui/app/node_modules_licenses/BUILD b/polygerrit-ui/app/node_modules_licenses/BUILD
index 92a3db8..7652ddc 100644
--- a/polygerrit-ui/app/node_modules_licenses/BUILD
+++ b/polygerrit-ui/app/node_modules_licenses/BUILD
@@ -1,4 +1,4 @@
-load("@npm_bazel_typescript//:index.bzl", "ts_library")
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
load("//tools/node_tools/node_modules_licenses:node_modules_licenses.bzl", "node_modules_licenses")
filegroup(
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 1141d6b..0e2307b 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -35,6 +35,11 @@
name: "BSD-3-Clause",
allowed: true
};
+
+ public static BsdZeroClause: LicenseType = {
+ name: "BSD-Zero-Clause",
+ allowed: true
+ };
}
/** List of licenses texts. Add the licenses here if there is no text file with license
@@ -86,6 +91,10 @@
const packages: PackageInfo[] = [
{
+ name: "@polymer/decorators",
+ license: SharedLicenses.Polymer2017,
+ },
+ {
name: "@polymer/font-roboto",
license: SharedLicenses.Polymer2015,
},
@@ -283,7 +292,44 @@
{
name: "polymer-bridges",
license: SharedLicenses.Polymer2018
- }
+ },
+ {
+ name: "rxjs",
+ license: {
+ name: "rxjs",
+ type: LicenseTypes.Apache2_0,
+ packageLicenseFile: "LICENSE.txt"
+ },
+ // The following directories are not real packages, but contains package.json
+ nonPackages: [
+ "ajax", "fetch", "internal-compatibility", "operators", "testing",
+ "webSocket", "src/ajax", "src/fetch", "src/internal-compatibility",
+ "src/operators", "src/testing", "src/webSocket"],
+ },
+ {
+ name: "lit-element",
+ license: {
+ name: "lit-element",
+ type: LicenseTypes.Bsd3,
+ packageLicenseFile: "LICENSE"
+ },
+ },
+ {
+ name: "lit-html",
+ license: {
+ name: "lit-html",
+ type: LicenseTypes.Bsd3,
+ packageLicenseFile: "LICENSE"
+ },
+ },
+ {
+ name: "tslib",
+ license: {
+ name: "tslib",
+ type: LicenseTypes.BsdZeroClause,
+ packageLicenseFile: "LICENSE.txt"
+ },
+ },
];
export default packages;
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index 32560ff..fad72ef 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -3,32 +3,36 @@
"description": "Gerrit Code Review - Polygerrit dependencies",
"browser": true,
"dependencies": {
+ "@polymer/decorators": "^3.0.0",
"@polymer/font-roboto-local": "^3.0.2",
"@polymer/iron-a11y-keys-behavior": "^3.0.1",
- "@polymer/iron-autogrow-textarea": "^3.0.1",
+ "@polymer/iron-a11y-announcer": "^3.0.1",
+ "@polymer/iron-autogrow-textarea": "^3.0.3",
"@polymer/iron-dropdown": "^3.0.1",
- "@polymer/iron-fit-behavior": "^3.0.1",
+ "@polymer/iron-fit-behavior": "^3.0.2",
"@polymer/iron-icon": "^3.0.1",
"@polymer/iron-iconset-svg": "^3.0.1",
"@polymer/iron-input": "^3.0.1",
- "@polymer/iron-overlay-behavior": "^3.0.2",
+ "@polymer/iron-overlay-behavior": "^3.0.3",
"@polymer/iron-selector": "^3.0.1",
"@polymer/paper-button": "^3.0.1",
"@polymer/paper-dialog": "^3.0.1",
"@polymer/paper-dialog-behavior": "^3.0.1",
"@polymer/paper-dialog-scrollable": "^3.0.1",
- "@polymer/paper-input": "^3.0.2",
+ "@polymer/paper-input": "^3.2.1",
"@polymer/paper-item": "^3.0.1",
"@polymer/paper-listbox": "^3.0.1",
"@polymer/paper-tabs": "^3.1.0",
"@polymer/paper-toggle-button": "^3.0.1",
- "@polymer/polymer": "^3.3.0",
+ "@polymer/polymer": "^3.4.1",
"@webcomponents/shadycss": "^1.9.2",
"@webcomponents/webcomponentsjs": "^1.3.3",
+ "ba-linkify": "file:../../lib/ba-linkify/src/",
+ "lit-element": "^2.4.0",
"page": "^1.11.5",
"polymer-bridges": "file:../../polymer-bridges/",
- "ba-linkify": "file:../../lib/ba-linkify/src/",
- "polymer-resin": "^2.0.1"
+ "polymer-resin": "^2.0.1",
+ "rxjs": "^6.6.2"
},
"license": "Apache-2.0",
"private": true
diff --git a/polygerrit-ui/app/rollup.config.js b/polygerrit-ui/app/rollup.config.js
index db0e2f7..c8e9baa 100644
--- a/polygerrit-ui/app/rollup.config.js
+++ b/polygerrit-ui/app/rollup.config.js
@@ -75,7 +75,10 @@
context: 'window',
plugins: [resolve({
customResolveOptions: {
- moduleDirectory: 'external/ui_npm/node_modules'
+ // By default, it tries to use page.mjs file instead of page.js
+ // when importing 'page/page'.
+ extensions: ['.js'],
+ moduleDirectory: 'external/ui_npm/node_modules',
}
}), importLocalFontMetaUrlResolver()],
};
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index 74b9ac1..feb1a82 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,5 +1,5 @@
load("//tools/bzl:genrule2.bzl", "genrule2")
-load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
def _get_ts_compiled_path(outdir, file_name):
"""Calculates the typescript output path for a file_name.
@@ -34,7 +34,7 @@
result.append(_get_ts_compiled_path(outdir, f))
return result
-def compile_ts(name, srcs, ts_outdir):
+def compile_ts(name, srcs, ts_outdir, include_tests = False):
"""Compiles srcs files with the typescript compiler
Args:
@@ -50,16 +50,31 @@
# List of files produced by the typescript compiler
generated_js = _get_ts_output_files(ts_outdir, srcs)
+ all_srcs = srcs + [
+ ":tsconfig.json",
+ ":tsconfig_bazel.json",
+ "@ui_npm//:node_modules",
+ ]
+ ts_project = "tsconfig_bazel.json"
+
+ if include_tests:
+ all_srcs = all_srcs + [
+ ":tsconfig_bazel_test.json",
+ "@ui_dev_npm//:node_modules",
+ ]
+ ts_project = "tsconfig_bazel_test.json"
+
# Run the compiler
native.genrule(
name = ts_rule_name,
- srcs = srcs + [
- ":tsconfig.json",
- "@ui_npm//:node_modules",
- ],
+ srcs = all_srcs,
outs = generated_js,
cmd = " && ".join([
- "$(location //tools/node_tools:tsc-bin) --project $(location :tsconfig.json) --outdir $(RULEDIR)/" + ts_outdir + " --baseUrl ./external/ui_npm/node_modules",
+ "$(location //tools/node_tools:tsc-bin) --project $(location :" +
+ ts_project +
+ ") --outdir $(RULEDIR)/" +
+ ts_outdir +
+ " --baseUrl ./external/ui_npm/node_modules/",
]),
tools = ["//tools/node_tools:tsc-bin"],
)
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index 2ca1118..0ceca3c 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -6,6 +6,11 @@
bazel_bin=bazel
fi
+# At least temporarily we want to know what is going on even when all tests are
+# passing, so we have a better chance of debugging what happens in CI test runs
+# that were supposed to catch test failures, but did not.
${bazel_bin} test \
"$@" \
+ --test_verbose_timeout_warnings \
+ --test_output=all \
//polygerrit-ui:karma_test
diff --git a/polygerrit-ui/app/samples/bind-parameters.js b/polygerrit-ui/app/samples/bind-parameters.js
index 2c89064..30c7c3d 100644
--- a/polygerrit-ui/app/samples/bind-parameters.js
+++ b/polygerrit-ui/app/samples/bind-parameters.js
@@ -51,7 +51,7 @@
}
_onRevisionChanged(value) {
- console.log(`(attributeHelper.bind) revision number: ${value._number}`);
+ console.info(`(attributeHelper.bind) revision number: ${value._number}`);
}
}
diff --git a/polygerrit-ui/app/samples/repo-command.js b/polygerrit-ui/app/samples/repo-command.js
index 5aaea30..4f64059 100644
--- a/polygerrit-ui/app/samples/repo-command.js
+++ b/polygerrit-ui/app/samples/repo-command.js
@@ -50,8 +50,8 @@
connectedCallback() {
super.connectedCallback();
- console.log(this.repoName);
- console.log(this.config);
+ console.info(this.repoName);
+ console.info(this.config);
this.hidden = this.repoName !== 'All-Projects';
}
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.js b/polygerrit-ui/app/scripts/bundled-polymer.js
deleted file mode 100644
index 780d82a..0000000
--- a/polygerrit-ui/app/scripts/bundled-polymer.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-// This file is a replacement for the
-// polymer-bridges/polymer/polymer.html file. The polymer.html file loads
-// other scripts to setup different global variables. Because plugins
-// expects that Polymer is available we must setup all Polymer global
-// variables
-//
-// The bundled-polymer.js imports all scripts in the same order as the
-// polymer.html does and must be imported in all es6-modules instead
-// of the polymer.html file.
-
-import 'polymer-bridges/polymer/lib/utils/boot_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/resolve-url_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/settings_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-module_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/style-gather_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/path_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/case-map_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/async_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/wrap_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/properties-changed_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/property-accessors_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/template-stamp_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/property-effects_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/telemetry_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/properties-mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/debounce_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/gestures_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/gesture-event-listeners_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/dir-mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/render-status_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/unresolved_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/array-splice_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/flattened-nodes-observer_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/flush_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/polymer.dom_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/legacy-element-mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/class_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/polymer-fn_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/mutable-data_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/templatize_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/templatizer-behavior_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-bind_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/html-tag_bridge.js';
-import 'polymer-bridges/polymer/polymer-element_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-repeat_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-if_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/array-selector_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/custom-style_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/mutable-data-behavior_bridge.js';
-import 'polymer-bridges/polymer/polymer-legacy_bridge.js';
-import {importHref} from './import-href.js';
-
-window.Polymer.importHref = importHref;
diff --git a/polygerrit-ui/app/scripts/bundled-polymer.ts b/polygerrit-ui/app/scripts/bundled-polymer.ts
new file mode 100644
index 0000000..a52cc6b
--- /dev/null
+++ b/polygerrit-ui/app/scripts/bundled-polymer.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * 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.
+ */
+
+// This file is a replacement for the
+// polymer-bridges/polymer/polymer.html file. The polymer.html file loads
+// other scripts to setup different global variables. Because plugins
+// expects that Polymer is available we must setup all Polymer global
+// variables
+//
+// The bundled-polymer.js imports all scripts in the same order as the
+// polymer.html does and must be imported in all es6-modules instead
+// of the polymer.html file.
+
+import './js/bundled-polymer-bridges';
+
+import {importHref} from './import-href';
+
+window.Polymer = window.Polymer || {};
+window.Polymer.importHref = importHref;
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
deleted file mode 100644
index 248217c..0000000
--- a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 {getAccountDisplayName} from '../../utils/display-name-util.js';
-
-export class GrEmailSuggestionsProvider {
- constructor(restAPI) {
- this._restAPI = restAPI;
- }
-
- getSuggestions(input) {
- return this._restAPI.getSuggestedAccounts(`${input}`)
- .then(accounts => {
- if (!accounts) { return []; }
- return accounts;
- });
- }
-
- makeSuggestionItem(account) {
- return {
- name: getAccountDisplayName(null, account),
- value: {account, count: 1},
- };
- }
-}
-
diff --git a/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
new file mode 100644
index 0000000..e555ebe
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-email-suggestions-provider/gr-email-suggestions-provider.ts
@@ -0,0 +1,39 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {getAccountDisplayName} from '../../utils/display-name-util';
+import {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {AccountInfo} from '../../types/common';
+
+export class GrEmailSuggestionsProvider {
+ constructor(private _restAPI: RestApiService) {}
+
+ getSuggestions(input: string) {
+ return this._restAPI.getSuggestedAccounts(`${input}`).then(accounts => {
+ if (!accounts) {
+ return [];
+ }
+ return accounts;
+ });
+ }
+
+ makeSuggestionItem(account: AccountInfo) {
+ return {
+ name: getAccountDisplayName(undefined, account),
+ value: {account, count: 1},
+ };
+ }
+}
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
deleted file mode 100644
index 16b6aae..0000000
--- a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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.
- */
-
-export class GrGroupSuggestionsProvider {
- constructor(restAPI) {
- this._restAPI = restAPI;
- }
-
- getSuggestions(input) {
- return this._restAPI.getSuggestedGroups(`${input}`)
- .then(groups => {
- if (!groups) { return []; }
- const keys = Object.keys(groups);
- return keys.map(key => Object.assign({}, groups[key], {name: key}));
- });
- }
-
- makeSuggestionItem(suggestion) {
- return {name: suggestion.name,
- value: {group: {name: suggestion.name, id: suggestion.id}}};
- }
-}
diff --git a/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
new file mode 100644
index 0000000..df77c76
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-group-suggestions-provider/gr-group-suggestions-provider.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {GroupBaseInfo} from '../../types/common';
+
+export class GrGroupSuggestionsProvider {
+ constructor(private _restAPI: RestApiService) {}
+
+ getSuggestions(input: string) {
+ return this._restAPI.getSuggestedGroups(`${input}`).then(groups => {
+ if (!groups) {
+ return [];
+ }
+ const keys = Object.keys(groups);
+ return keys.map(key => {
+ return {...groups[key], name: key};
+ });
+ });
+ }
+
+ makeSuggestionItem(suggestion: GroupBaseInfo) {
+ return {
+ name: suggestion.name,
+ value: {group: {name: suggestion.name, id: suggestion.id}},
+ };
+ }
+}
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
deleted file mode 100644
index 1bbf1b0..0000000
--- a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.js
+++ /dev/null
@@ -1,106 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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 {getAccountDisplayName, getGroupDisplayName} from '../../utils/display-name-util.js';
-
-/**
- * @enum {string}
- */
-export const SUGGESTIONS_PROVIDERS_USERS_TYPES = {
- REVIEWER: 'reviewers',
- CC: 'ccs',
- ANY: 'any',
-};
-
-export class GrReviewerSuggestionsProvider {
- static create(restApi, changeNumber, usersType) {
- switch (usersType) {
- case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
- return new GrReviewerSuggestionsProvider(restApi, changeNumber,
- input => restApi.getChangeSuggestedReviewers(changeNumber,
- input));
- case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
- return new GrReviewerSuggestionsProvider(restApi, changeNumber,
- input => restApi.getChangeSuggestedCCs(changeNumber, input));
- case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
- return new GrReviewerSuggestionsProvider(restApi, changeNumber,
- input => restApi.getSuggestedAccounts(
- `cansee:${changeNumber} ${input}`));
- default:
- throw new Error(`Unknown users type: ${usersType}`);
- }
- }
-
- constructor(restAPI, changeNumber, apiCall) {
- this._changeNumber = changeNumber;
- this._apiCall = apiCall;
- this._restAPI = restAPI;
- }
-
- init() {
- if (this._initPromise) {
- return this._initPromise;
- }
- const getConfigPromise = this._restAPI.getConfig().then(cfg => {
- this._config = cfg;
- });
- const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
- this._loggedIn = loggedIn;
- });
- this._initPromise = Promise.all([getConfigPromise, getLoggedInPromise])
- .then(() => {
- this._initialized = true;
- });
- return this._initPromise;
- }
-
- getSuggestions(input) {
- if (!this._initialized || !this._loggedIn) {
- return Promise.resolve([]);
- }
-
- return this._apiCall(input)
- .then(reviewers => (reviewers || []));
- }
-
- makeSuggestionItem(suggestion) {
- if (suggestion.account) {
- // Reviewer is an account suggestion from getChangeSuggestedReviewers.
- return {
- name: getAccountDisplayName(this._config,
- suggestion.account),
- value: suggestion,
- };
- }
-
- if (suggestion.group) {
- // Reviewer is a group suggestion from getChangeSuggestedReviewers.
- return {
- name: getGroupDisplayName(suggestion.group),
- value: suggestion,
- };
- }
-
- if (suggestion._account_id) {
- // Reviewer is an account suggestion from getSuggestedAccounts.
- return {
- name: getAccountDisplayName(this._config,
- suggestion),
- value: {account: suggestion, count: 1},
- };
- }
- }
-}
diff --git a/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
new file mode 100644
index 0000000..6ab69bb
--- /dev/null
+++ b/polygerrit-ui/app/scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider.ts
@@ -0,0 +1,142 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {
+ getAccountDisplayName,
+ getGroupDisplayName,
+} from '../../utils/display-name-util';
+import {RestApiService} from '../../services/services/gr-rest-api/gr-rest-api';
+import {
+ AccountInfo,
+ isReviewerAccountSuggestion,
+ isReviewerGroupSuggestion,
+ NumericChangeId,
+ ServerInfo,
+ SuggestedReviewerInfo,
+ Suggestion,
+} from '../../types/common';
+import {assertNever} from '../../utils/common-util';
+
+// TODO(TS): enum name doesn't follow typescript style guid rules
+// Rename it
+export enum SUGGESTIONS_PROVIDERS_USERS_TYPES {
+ REVIEWER = 'reviewers',
+ CC = 'ccs',
+ ANY = 'any',
+}
+
+export function isAccountSuggestions(s: Suggestion): s is AccountInfo {
+ return (s as AccountInfo)._account_id !== undefined;
+}
+
+type ApiCallCallback = (input: string) => Promise<Suggestion[] | void>;
+
+export interface SuggestionItem {
+ name: string;
+ value: SuggestedReviewerInfo;
+}
+
+export class GrReviewerSuggestionsProvider {
+ static create(
+ restApi: RestApiService,
+ changeNumber: NumericChangeId,
+ userType: SUGGESTIONS_PROVIDERS_USERS_TYPES
+ ) {
+ switch (userType) {
+ case SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER:
+ return new GrReviewerSuggestionsProvider(restApi, input =>
+ restApi.getChangeSuggestedReviewers(changeNumber, input)
+ );
+ case SUGGESTIONS_PROVIDERS_USERS_TYPES.CC:
+ return new GrReviewerSuggestionsProvider(restApi, input =>
+ restApi.getChangeSuggestedCCs(changeNumber, input)
+ );
+ case SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY:
+ return new GrReviewerSuggestionsProvider(restApi, input =>
+ restApi.getSuggestedAccounts(`cansee:${changeNumber} ${input}`)
+ );
+ default:
+ throw new Error(`Unknown users type: ${userType}`);
+ }
+ }
+
+ private _initPromise?: Promise<void>;
+
+ private _config?: ServerInfo;
+
+ private _loggedIn = false;
+
+ private _initialized = false;
+
+ private constructor(
+ private readonly _restAPI: RestApiService,
+ private readonly _apiCall: ApiCallCallback
+ ) {}
+
+ init() {
+ if (this._initPromise) {
+ return this._initPromise;
+ }
+ const getConfigPromise = this._restAPI.getConfig().then(cfg => {
+ this._config = cfg;
+ });
+ const getLoggedInPromise = this._restAPI.getLoggedIn().then(loggedIn => {
+ this._loggedIn = loggedIn;
+ });
+ this._initPromise = Promise.all([
+ getConfigPromise,
+ getLoggedInPromise,
+ ]).then(() => {
+ this._initialized = true;
+ });
+ return this._initPromise;
+ }
+
+ getSuggestions(input: string): Promise<Suggestion[]> {
+ if (!this._initialized || !this._loggedIn) {
+ return Promise.resolve([]);
+ }
+
+ return this._apiCall(input).then(reviewers => reviewers || []);
+ }
+
+ makeSuggestionItem(suggestion: Suggestion): SuggestionItem {
+ if (isReviewerAccountSuggestion(suggestion)) {
+ // Reviewer is an account suggestion from getChangeSuggestedReviewers.
+ return {
+ name: getAccountDisplayName(this._config, suggestion.account),
+ value: suggestion,
+ };
+ }
+
+ if (isReviewerGroupSuggestion(suggestion)) {
+ // Reviewer is a group suggestion from getChangeSuggestedReviewers.
+ return {
+ name: getGroupDisplayName(suggestion.group),
+ value: suggestion,
+ };
+ }
+
+ if (isAccountSuggestions(suggestion)) {
+ // Reviewer is an account suggestion from getSuggestedAccounts.
+ return {
+ name: getAccountDisplayName(this._config, suggestion),
+ value: {account: suggestion, count: 1},
+ };
+ }
+ assertNever(suggestion, 'Received an incorrect suggestion');
+ }
+}
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.js b/polygerrit-ui/app/scripts/hiddenscroll.js
deleted file mode 100644
index a580b05..0000000
--- a/polygerrit-ui/app/scripts/hiddenscroll.js
+++ /dev/null
@@ -1,35 +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.
- */
-
-let hiddenscroll = undefined;
-
-window.addEventListener('WebComponentsReady', () => {
- const elem = document.createElement('div');
- elem.setAttribute(
- 'style', 'width:100px;height:100px;overflow:scroll');
- document.body.appendChild(elem);
- hiddenscroll = elem.offsetWidth === elem.clientWidth;
- elem.remove();
-});
-
-export function _setHiddenScroll(value) {
- hiddenscroll = value;
-}
-
-export function getHiddenScroll() {
- return hiddenscroll;
-}
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.ts b/polygerrit-ui/app/scripts/hiddenscroll.ts
new file mode 100644
index 0000000..b4364be
--- /dev/null
+++ b/polygerrit-ui/app/scripts/hiddenscroll.ts
@@ -0,0 +1,34 @@
+/**
+ * @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.
+ */
+
+let hiddenscroll: boolean | undefined = undefined;
+
+window.addEventListener('WebComponentsReady', () => {
+ const elem = document.createElement('div');
+ elem.setAttribute('style', 'width:100px;height:100px;overflow:scroll');
+ document.body.appendChild(elem);
+ hiddenscroll = elem.offsetWidth === elem.clientWidth;
+ elem.remove();
+});
+
+export function _setHiddenScroll(value: boolean) {
+ hiddenscroll = value;
+}
+
+export function getHiddenScroll() {
+ return hiddenscroll;
+}
diff --git a/polygerrit-ui/app/scripts/import-href.js b/polygerrit-ui/app/scripts/import-href.js
deleted file mode 100644
index 6ff40a5..0000000
--- a/polygerrit-ui/app/scripts/import-href.js
+++ /dev/null
@@ -1,108 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-// This file is a replacement for the
-// polymer-bridges/polymer/lib/utils/import-href.html file. The html
-// file contains code inside <script>...</script> and can't be imported
-// in es6 modules.
-
-// run a callback when HTMLImports are ready or immediately if
-// this api is not available.
-function whenImportsReady(cb) {
- if (window.HTMLImports) {
- HTMLImports.whenReady(cb);
- } else {
- cb();
- }
-}
-
-/**
- * Convenience method for importing an HTML document imperatively.
- *
- * This method creates a new `<link rel="import">` element with
- * the provided URL and appends it to the document to start loading.
- * In the `onload` callback, the `import` property of the `link`
- * element will contain the imported document contents.
- *
- * @memberof Polymer
- * @param {string} href URL to document to load.
- * @param {?function(!Event):void=} onload Callback to notify when an import successfully
- * loaded.
- * @param {?function(!ErrorEvent):void=} onerror Callback to notify when an import
- * unsuccessfully loaded.
- * @param {boolean=} optAsync True if the import should be loaded `async`.
- * Defaults to `false`.
- * @return {!HTMLLinkElement} The link element for the URL to be loaded.
- */
-export function importHref(href, onload, onerror, optAsync) {
- let link = /** @type {HTMLLinkElement} */
- (document.head.querySelector('link[href="' + href + '"][import-href]'));
- if (!link) {
- link = /** @type {HTMLLinkElement} */ (document.createElement('link'));
- link.rel = 'import';
- link.href = href;
- link.setAttribute('import-href', '');
- }
- // always ensure link has `async` attribute if user specified one,
- // even if it was previously not async. This is considered less confusing.
- if (optAsync) {
- link.setAttribute('async', '');
- }
- // NOTE: the link may now be in 3 states: (1) pending insertion,
- // (2) inflight, (3) already loaded. In each case, we need to add
- // event listeners to process callbacks.
- const cleanup = function() {
- link.removeEventListener('load', loadListener);
- link.removeEventListener('error', errorListener);
- };
- const loadListener = function(event) {
- cleanup();
- // In case of a successful load, cache the load event on the link so
- // that it can be used to short-circuit this method in the future when
- // it is called with the same href param.
- link.__dynamicImportLoaded = true;
- if (onload) {
- whenImportsReady(() => {
- onload(event);
- });
- }
- };
- const errorListener = function(event) {
- cleanup();
- // In case of an error, remove the link from the document so that it
- // will be automatically created again the next time `importHref` is
- // called.
- if (link.parentNode) {
- link.parentNode.removeChild(link);
- }
- if (onerror) {
- whenImportsReady(() => {
- onerror(event);
- });
- }
- };
- link.addEventListener('load', loadListener);
- link.addEventListener('error', errorListener);
- if (link.parentNode == null) {
- document.head.appendChild(link);
- // if the link already loaded, dispatch a fake load event
- // so that listeners are called and get a proper event argument.
- } else if (link.__dynamicImportLoaded) {
- link.dispatchEvent(new Event('load'));
- }
- return link;
-}
diff --git a/polygerrit-ui/app/scripts/import-href.ts b/polygerrit-ui/app/scripts/import-href.ts
new file mode 100644
index 0000000..3249c56
--- /dev/null
+++ b/polygerrit-ui/app/scripts/import-href.ts
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * 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.
+ */
+
+// This file is a replacement for the
+// polymer-bridges/polymer/lib/utils/import-href.html file. The html
+// file contains code inside <script>...</script> and can't be imported
+// in es6 modules.
+
+interface ImportHrefElement extends HTMLLinkElement {
+ __dynamicImportLoaded?: boolean;
+}
+
+// run a callback when HTMLImports are ready or immediately if
+// this api is not available.
+function whenImportsReady(cb: () => void) {
+ const win = window as Window;
+ if (win.HTMLImports) {
+ win.HTMLImports.whenReady(cb);
+ } else {
+ cb();
+ }
+}
+
+/**
+ * Convenience method for importing an HTML document imperatively.
+ *
+ * This method creates a new `<link rel="import">` element with
+ * the provided URL and appends it to the document to start loading.
+ * In the `onload` callback, the `import` property of the `link`
+ * element will contain the imported document contents.
+ *
+ * @memberof Polymer
+ * @param href URL to document to load.
+ * @param onload Callback to notify when an import successfully
+ * loaded.
+ * @param onerror Callback to notify when an import
+ * unsuccessfully loaded.
+ * @param async True if the import should be loaded `async`.
+ * Defaults to `false`.
+ * @return The link element for the URL to be loaded.
+ */
+export function importHref(
+ href: string,
+ onload: (e: Event) => void,
+ onerror: (e: Event) => void,
+ async = false
+): HTMLLinkElement {
+ let link = document.head.querySelector(
+ 'link[href="' + href + '"][import-href]'
+ ) as ImportHrefElement;
+ if (!link) {
+ link = document.createElement('link') as ImportHrefElement;
+ link.setAttribute('rel', 'import');
+ link.setAttribute('href', href);
+ link.setAttribute('import-href', '');
+ }
+ // always ensure link has `async` attribute if user specified one,
+ // even if it was previously not async. This is considered less confusing.
+ if (async) {
+ link.setAttribute('async', '');
+ }
+ // NOTE: the link may now be in 3 states: (1) pending insertion,
+ // (2) inflight, (3) already loaded. In each case, we need to add
+ // event listeners to process callbacks.
+ const cleanup = function () {
+ link.removeEventListener('load', loadListener);
+ link.removeEventListener('error', errorListener);
+ };
+ const loadListener = function (event: Event) {
+ cleanup();
+ // In case of a successful load, cache the load event on the link so
+ // that it can be used to short-circuit this method in the future when
+ // it is called with the same href param.
+ link.__dynamicImportLoaded = true;
+ if (onload) {
+ whenImportsReady(() => {
+ onload(event);
+ });
+ }
+ };
+ const errorListener = function (event: Event) {
+ cleanup();
+ // In case of an error, remove the link from the document so that it
+ // will be automatically created again the next time `importHref` is
+ // called.
+ if (link.parentNode) {
+ link.parentNode.removeChild(link);
+ }
+ if (onerror) {
+ whenImportsReady(() => {
+ onerror(event);
+ });
+ }
+ };
+ link.addEventListener('load', loadListener);
+ link.addEventListener('error', errorListener);
+ if (link.parentNode === null) {
+ document.head.appendChild(link);
+ // if the link already loaded, dispatch a fake load event
+ // so that listeners are called and get a proper event argument.
+ } else if (link.__dynamicImportLoaded) {
+ link.dispatchEvent(new Event('load'));
+ }
+ return link;
+}
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
new file mode 100644
index 0000000..7041300
--- /dev/null
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.d.ts
@@ -0,0 +1,19 @@
+/**
+ * @license
+ * 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.
+ */
+
+// We can't convert bundled-polymer.js to ts. To allow import
+// bundled-polymer.js from .ts files we should add this .d.ts file
diff --git a/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
new file mode 100644
index 0000000..d04b533
--- /dev/null
+++ b/polygerrit-ui/app/scripts/js/bundled-polymer-bridges.js
@@ -0,0 +1,75 @@
+/**
+ * @license
+ * 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.
+ */
+
+// This file can't be converted to TS - it imports some .js file which
+// can't be imported into typescript
+
+// This file is a replacement for the
+// polymer-bridges/polymer/polymer.html file. The polymer.html file loads
+// other scripts to setup different global variables. Because plugins
+// expects that Polymer is available we must setup all Polymer global
+// variables
+//
+// The bundled-polymer.js imports all scripts in the same order as the
+// polymer.html does and must be imported in all es6-modules instead
+// of the polymer.html file.
+
+import 'polymer-bridges/polymer/lib/utils/boot_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/resolve-url_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/settings_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/mixin_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/dom-module_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/style-gather_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/path_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/case-map_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/async_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/wrap_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/properties-changed_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/property-accessors_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/template-stamp_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/property-effects_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/telemetry_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/properties-mixin_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/debounce_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/gestures_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/gesture-event-listeners_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/dir-mixin_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/render-status_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/unresolved_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/array-splice_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/flattened-nodes-observer_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/flush_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/polymer.dom_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/legacy-element-mixin_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/class_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/polymer-fn_bridge.js';
+import 'polymer-bridges/polymer/lib/mixins/mutable-data_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/templatize_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/templatizer-behavior_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/dom-bind_bridge.js';
+import 'polymer-bridges/polymer/lib/utils/html-tag_bridge.js';
+import 'polymer-bridges/polymer/polymer-element_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/dom-repeat_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/dom-if_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/array-selector_bridge.js';
+import 'polymer-bridges/polymer/lib/elements/custom-style_bridge.js';
+import 'polymer-bridges/polymer/lib/legacy/mutable-data-behavior_bridge.js';
+import 'polymer-bridges/polymer/polymer-legacy_bridge.js';
+
+// This is needed due to the Polymer.IronFocusablesHelper in gr-overlay.ts
+import 'polymer-bridges/iron-overlay-behavior/iron-focusables-helper_bridge.js';
+
diff --git a/polygerrit-ui/app/scripts/polymer-resin-install.ts b/polygerrit-ui/app/scripts/polymer-resin-install.ts
new file mode 100644
index 0000000..ee03171
--- /dev/null
+++ b/polygerrit-ui/app/scripts/polymer-resin-install.ts
@@ -0,0 +1,72 @@
+/**
+ * @license
+ * 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.
+ */
+
+import 'polymer-resin/standalone/polymer-resin';
+
+export type SafeTypeBridge = (
+ value: unknown,
+ type: string,
+ fallback: unknown
+) => unknown;
+
+export type ReportHandler = (
+ isDisallowedValue: boolean,
+ printfFormatString: string,
+ ...printfArgs: unknown[]
+) => void;
+
+declare global {
+ interface Window {
+ security: {
+ polymer_resin: {
+ SafeType: {
+ CONSTANT: string;
+ HTML: string;
+ JAVASCRIPT: string;
+ RESOURCE_URL: string;
+ /** Unprivileged but possibly wrapped string. */
+ STRING: string;
+ STYLE: string;
+ URL: string;
+ };
+ CONSOLE_LOGGING_REPORT_HANDLER: ReportHandler;
+ install(options: {
+ UNSAFE_passThruDisallowedValues?: boolean;
+ allowedIdentifierPrefixes?: string[];
+ reportHandler?: ReportHandler;
+ safeTypesBridge?: SafeTypeBridge;
+ }): void;
+ };
+ };
+ }
+}
+
+const security = window.security;
+
+export const _testOnly_defaultResinReportHandler =
+ security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
+
+export function installPolymerResin(
+ safeTypesBridge: SafeTypeBridge,
+ reportHandler = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER
+) {
+ window.security.polymer_resin.install({
+ allowedIdentifierPrefixes: [''],
+ reportHandler,
+ safeTypesBridge,
+ });
+}
diff --git a/polygerrit-ui/app/scripts/rootElement.js b/polygerrit-ui/app/scripts/rootElement.js
deleted file mode 100644
index 4900ba2..0000000
--- a/polygerrit-ui/app/scripts/rootElement.js
+++ /dev/null
@@ -1,18 +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.
- */
-
-export const getRootElement = () => document.body;
diff --git a/polygerrit-ui/app/scripts/rootElement.ts b/polygerrit-ui/app/scripts/rootElement.ts
new file mode 100644
index 0000000..2217bf9
--- /dev/null
+++ b/polygerrit-ui/app/scripts/rootElement.ts
@@ -0,0 +1,21 @@
+/**
+ * @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.
+ */
+
+/**
+ * Returns the root element of the dom: body.
+ */
+export const getRootElement = () => document.body;
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
deleted file mode 100644
index e4be858..0000000
--- a/polygerrit-ui/app/scripts/util.js
+++ /dev/null
@@ -1,68 +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.
- */
-
-// TODO (dmfilippov): Each function must be exported separately. According to
-// the code style guide, a namespacing is not allowed.
-export const util = {
- getCookie(name) {
- const key = name + '=';
- const cookies = document.cookie.split(';');
- for (let i = 0; i < cookies.length; i++) {
- let c = cookies[i];
- while (c.charAt(0) === ' ') {
- c = c.substring(1);
- }
- if (c.startsWith(key)) {
- return c.substring(key.length, c.length);
- }
- }
- return '';
- },
-
- /**
- * Make the promise cancelable.
- *
- * Returns a promise with a `cancel()` method wrapped around `promise`.
- * Calling `cancel()` will reject the returned promise with
- * {isCancelled: true} synchronously. If the inner promise for a cancelled
- * promise resolves or rejects this is ignored.
- */
- makeCancelable: promise => {
- // True if the promise is either resolved or reject (possibly cancelled)
- let isDone = false;
-
- let rejectPromise;
-
- const wrappedPromise = new Promise((resolve, reject) => {
- rejectPromise = reject;
- promise.then(val => {
- if (!isDone) resolve(val);
- isDone = true;
- }, error => {
- if (!isDone) reject(error);
- isDone = true;
- });
- });
-
- wrappedPromise.cancel = () => {
- if (isDone) return;
- rejectPromise({isCanceled: true});
- isDone = true;
- };
- return wrappedPromise;
- },
-};
diff --git a/polygerrit-ui/app/scripts/util.ts b/polygerrit-ui/app/scripts/util.ts
new file mode 100644
index 0000000..bf7120f
--- /dev/null
+++ b/polygerrit-ui/app/scripts/util.ts
@@ -0,0 +1,77 @@
+/**
+ * @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.
+ */
+
+export interface CancelablePromise<T> extends Promise<T> {
+ cancel(): void;
+}
+
+// TODO (dmfilippov): Each function must be exported separately. According to
+// the code style guide, a namespacing is not allowed.
+export const util = {
+ getCookie(name: string) {
+ const key = name + '=';
+ const cookies = document.cookie.split(';');
+ for (let i = 0; i < cookies.length; i++) {
+ let c = cookies[i];
+ while (c.charAt(0) === ' ') {
+ c = c.substring(1);
+ }
+ if (c.startsWith(key)) {
+ return c.substring(key.length, c.length);
+ }
+ }
+ return '';
+ },
+
+ /**
+ * Make the promise cancelable.
+ *
+ * Returns a promise with a `cancel()` method wrapped around `promise`.
+ * Calling `cancel()` will reject the returned promise with
+ * {isCancelled: true} synchronously. If the inner promise for a cancelled
+ * promise resolves or rejects this is ignored.
+ */
+ makeCancelable<T>(promise: Promise<T>) {
+ // True if the promise is either resolved or reject (possibly cancelled)
+ let isDone = false;
+
+ let rejectPromise: (reason?: unknown) => void;
+
+ const wrappedPromise: CancelablePromise<T> = new Promise(
+ (resolve, reject) => {
+ rejectPromise = reject;
+ promise.then(
+ val => {
+ if (!isDone) resolve(val);
+ isDone = true;
+ },
+ error => {
+ if (!isDone) reject(error);
+ isDone = true;
+ }
+ );
+ }
+ ) as CancelablePromise<T>;
+
+ wrappedPromise.cancel = () => {
+ if (isDone) return;
+ rejectPromise({isCanceled: true});
+ isDone = true;
+ };
+ return wrappedPromise;
+ },
+};
diff --git a/polygerrit-ui/app/services/app-context-init.js b/polygerrit-ui/app/services/app-context-init.js
deleted file mode 100644
index 531c361..0000000
--- a/polygerrit-ui/app/services/app-context-init.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/**
- * @license
- * 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.
- */
-import {appContext} from './app-context.js';
-import {FlagsService} from './flags.js';
-import {GrReporting} from './gr-reporting/gr-reporting.js';
-import {EventEmitter} from './gr-event-interface/gr-event-interface.js';
-import {Auth} from './gr-auth.js';
-
-const initializedServices = new Map();
-
-function getService(serviceName, serviceInit) {
- if (!initializedServices.has(serviceName)) {
- initializedServices.set(serviceName, serviceInit());
- }
- return initializedServices.get(serviceName);
-}
-
-/**
- * The AppContext lazy initializator for all services
- */
-export function initAppContext() {
- const registeredServices = {};
- function addService(serviceName, serviceCreator) {
- if (registeredServices[serviceName]) {
- throw new Error(`Service ${serviceName} already registered.`);
- }
- registeredServices[serviceName] = {
- get() {
- return getService(serviceName, serviceCreator);
- },
- };
- }
-
- addService('flagsService', () => new FlagsService());
- addService('reportingService',
- () => new GrReporting(appContext.flagsService));
- addService('eventEmitter', () => new EventEmitter());
- addService('authService', () => new Auth(appContext.eventEmitter));
- Object.defineProperties(appContext, registeredServices);
-}
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
new file mode 100644
index 0000000..b249d16
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * 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.
+ */
+import {appContext, AppContext} from './app-context';
+import {FlagsServiceImplementation} from './flags/flags_impl';
+import {GrReporting} from './gr-reporting/gr-reporting_impl';
+import {EventEmitter} from './gr-event-interface/gr-event-interface_impl';
+import {Auth} from './gr-auth/gr-auth_impl';
+
+type ServiceName = keyof AppContext;
+type ServiceCreator<T> = () => T;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const initializedServices: Map<ServiceName, any> = new Map();
+
+function getService<K extends ServiceName>(
+ serviceName: K,
+ serviceCreator: ServiceCreator<AppContext[K]>
+): AppContext[K] {
+ if (!initializedServices.has(serviceName)) {
+ initializedServices.set(serviceName, serviceCreator());
+ }
+ return initializedServices.get(serviceName);
+}
+
+/**
+ * The AppContext lazy initializator for all services
+ */
+export function initAppContext() {
+ function populateAppContext(
+ serviceCreators: {[P in ServiceName]: ServiceCreator<AppContext[P]>}
+ ) {
+ const registeredServices = Object.keys(serviceCreators).reduce(
+ (registeredServices, key) => {
+ const serviceName = key as ServiceName;
+ const serviceCreator = serviceCreators[serviceName];
+ registeredServices[serviceName] = {
+ configurable: true, // Tests can mock properties
+ get() {
+ return getService(serviceName, serviceCreator);
+ },
+ };
+ return registeredServices;
+ },
+ {} as PropertyDescriptorMap
+ );
+ Object.defineProperties(appContext, registeredServices);
+ }
+
+ populateAppContext({
+ flagsService: () => new FlagsServiceImplementation(),
+ reportingService: () => new GrReporting(appContext.flagsService),
+ eventEmitter: () => new EventEmitter(),
+ authService: () => new Auth(appContext.eventEmitter),
+ });
+}
diff --git a/polygerrit-ui/app/services/app-context.js b/polygerrit-ui/app/services/app-context.js
deleted file mode 100644
index 3f86003..0000000
--- a/polygerrit-ui/app/services/app-context.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-/**
- * The AppContext holds immortal singleton instances of services. It's a
- * convenient way to provide singletons that can be swapped out for testing.
- *
- * AppContext is initialized in ./app-context-init.js
- */
-export const appContext = {
- flagsService: null,
- reportingService: null,
- eventEmitter: null,
- authService: null,
-};
\ No newline at end of file
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
new file mode 100644
index 0000000..c08ee7a
--- /dev/null
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * 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.
+ */
+import {FlagsService} from './flags/flags';
+import {EventEmitterService} from './gr-event-interface/gr-event-interface';
+import {ReportingService} from './gr-reporting/gr-reporting';
+import {AuthService} from './gr-auth/gr-auth';
+
+export interface AppContext {
+ flagsService: FlagsService;
+ reportingService: ReportingService;
+ eventEmitter: EventEmitterService;
+ authService: AuthService;
+}
+
+/**
+ * The AppContext holds immortal singleton instances of services. It's a
+ * convenient way to provide singletons that can be swapped out for testing.
+ *
+ * AppContext is initialized in ./app-context-init.js
+ *
+ * It is guaranteed that all fields in appContext are always initialized
+ * (except for shared gr-diff)
+ */
+export const appContext: AppContext = {} as AppContext;
diff --git a/polygerrit-ui/app/services/flags.js b/polygerrit-ui/app/services/flags.js
deleted file mode 100644
index 6313255..0000000
--- a/polygerrit-ui/app/services/flags.js
+++ /dev/null
@@ -1,56 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-/**
- * @enum
- * @desc Experiment ids used in Gerrit.
- */
-export const ExperimentIds = {
- PATCHSET_COMMENTS: 'UiFeature__patchset_comments',
-};
-
-/**
- * Flags service.
- *
- * Provides all related methods / properties regarding on feature flags.
- */
-export class FlagsService {
- constructor() {
- // stores all enabled experiments
- this._experiments = new Set();
- this._loadExperiments();
- }
-
- /**
- * @param {string} experimentId
- * @returns {boolean}
- */
- isEnabled(experimentId) {
- return this._experiments.has(experimentId);
- }
-
- _loadExperiments() {
- this._experiments = new Set(window.ENABLED_EXPERIMENTS);
- }
-
- /**
- * @returns {string[]} array of all enabled experiments.
- */
- get enabledExperiments() {
- return [...this._experiments];
- }
-}
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
new file mode 100644
index 0000000..047e9e0
--- /dev/null
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * 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.
+ */
+
+export interface FlagsService {
+ isEnabled(experimentId: string): boolean;
+ enabledExperiments: string[];
+}
+
+/**
+ * @desc Experiment ids used in Gerrit.
+ */
+export enum KnownExperimentId {
+ PATCHSET_COMMENTS = 'UiFeature__patchset_comments',
+ PATCHSET_CHOICE_FOR_COMMENT_LINKS = 'UiFeature__patchset_choice_for_comment_links',
+ NEW_CONTEXT_CONTROLS = 'UiFeature__new_context_controls',
+}
diff --git a/polygerrit-ui/app/services/flags/flags_impl.ts b/polygerrit-ui/app/services/flags/flags_impl.ts
new file mode 100644
index 0000000..fbfa833
--- /dev/null
+++ b/polygerrit-ui/app/services/flags/flags_impl.ts
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * 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.
+ */
+import {FlagsService} from './flags';
+
+declare global {
+ interface Window {
+ ENABLED_EXPERIMENTS: string[];
+ }
+}
+
+/**
+ * Flags service.
+ *
+ * Provides all related methods / properties regarding on feature flags.
+ */
+export class FlagsServiceImplementation implements FlagsService {
+ private readonly _experiments: Set<string>;
+
+ constructor() {
+ // stores all enabled experiments
+ this._experiments = this._loadExperiments();
+ }
+
+ isEnabled(experimentId: string): boolean {
+ return this._experiments.has(experimentId);
+ }
+
+ _loadExperiments(): Set<string> {
+ return new Set(window.ENABLED_EXPERIMENTS);
+ }
+
+ get enabledExperiments() {
+ return [...this._experiments];
+ }
+}
diff --git a/polygerrit-ui/app/services/flags/flags_test.js b/polygerrit-ui/app/services/flags/flags_test.js
new file mode 100644
index 0000000..33508af
--- /dev/null
+++ b/polygerrit-ui/app/services/flags/flags_test.js
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {FlagsServiceImplementation} from './flags_impl.js';
+
+suite('flags tests', () => {
+ let originalEnabledExperiments;
+ let flags;
+
+ suiteSetup(() => {
+ originalEnabledExperiments = window.ENABLED_EXPERIMENTS;
+ window.ENABLED_EXPERIMENTS = ['a', 'a'];
+ flags = new FlagsServiceImplementation();
+ });
+
+ suiteTeardown(() => {
+ window.ENABLED_EXPERIMENTS = originalEnabledExperiments;
+ });
+
+ test('isEnabled', () => {
+ assert.equal(flags.isEnabled('a'), true);
+ assert.equal(flags.isEnabled('random'), false);
+ });
+
+ test('enabledExperiments', () => {
+ assert.deepEqual(flags.enabledExperiments, ['a']);
+ });
+});
+
diff --git a/polygerrit-ui/app/services/flags_test.js b/polygerrit-ui/app/services/flags_test.js
deleted file mode 100644
index ae1033e..0000000
--- a/polygerrit-ui/app/services/flags_test.js
+++ /dev/null
@@ -1,44 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import '../test/common-test-setup-karma.js';
-import {FlagsService} from './flags.js';
-
-suite('flags tests', () => {
- let originalEnabledExperiments;
- let flags;
-
- suiteSetup(() => {
- originalEnabledExperiments = window.ENABLED_EXPERIMENTS;
- window.ENABLED_EXPERIMENTS = ['a', 'a'];
- flags = new FlagsService();
- });
-
- suiteTeardown(() => {
- window.ENABLED_EXPERIMENTS = originalEnabledExperiments;
- });
-
- test('isEnabled', () => {
- assert.equal(flags.isEnabled('a'), true);
- assert.equal(flags.isEnabled('random'), false);
- });
-
- test('enabledExperiments', () => {
- assert.deepEqual(flags.enabledExperiments, ['a']);
- });
-});
-
diff --git a/polygerrit-ui/app/services/gr-auth.js b/polygerrit-ui/app/services/gr-auth.js
deleted file mode 100644
index 1fba95c..0000000
--- a/polygerrit-ui/app/services/gr-auth.js
+++ /dev/null
@@ -1,262 +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 {getBaseUrl} from '../utils/url-util.js';
-
-const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
-const MAX_GET_TOKEN_RETRIES = 2;
-
-/**
- * Auth class.
- */
-export class Auth {
- constructor(eventEmitter) {
- this._type = null;
- this._cachedTokenPromise = null;
- this._defaultOptions = {};
- this._retriesLeft = MAX_GET_TOKEN_RETRIES;
- this._status = Auth.STATUS.UNDETERMINED;
- this._authCheckPromise = null;
- this._last_auth_check_time = Date.now();
- this.eventEmitter = eventEmitter;
- }
-
- get baseUrl() {
- return getBaseUrl();
- }
-
- /**
- * Returns if user is authed or not.
- *
- * @returns {!Promise<boolean>}
- */
- authCheck() {
- if (!this._authCheckPromise ||
- (Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS)
- ) {
- // Refetch after last check expired
- this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
- this._last_auth_check_time = Date.now();
- }
-
- return this._authCheckPromise.then(res => {
- // auth-check will return 204 if authed
- // treat the rest as unauthed
- if (res.status === 204) {
- this._setStatus(Auth.STATUS.AUTHED);
- return true;
- } else {
- this._setStatus(Auth.STATUS.NOT_AUTHED);
- return false;
- }
- }).catch(e => {
- this._setStatus(Auth.STATUS.ERROR);
- // Reset _authCheckPromise to avoid caching the failed promise
- this._authCheckPromise = null;
- return false;
- });
- }
-
- clearCache() {
- this._authCheckPromise = null;
- }
-
- /**
- * @param {Auth.STATUS} status
- */
- _setStatus(status) {
- if (this._status === status) return;
-
- if (this._status === Auth.STATUS.AUTHED) {
- this.eventEmitter.emit('auth-error', {
- message: Auth.CREDS_EXPIRED_MSG, action: 'Refresh credentials',
- });
- }
- this._status = status;
- }
-
- get status() {
- return this._status;
- }
-
- get isAuthed() {
- return this._status === Auth.STATUS.AUTHED;
- }
-
- _getToken() {
- return Promise.resolve(this._cachedTokenPromise);
- }
-
- /**
- * Enable cross-domain authentication using OAuth access token.
- *
- * @param {
- * function(): !Promise<{
- * access_token: string,
- * expires_at: number
- * }>
- * } getToken
- * @param {?{credentials:string}} defaultOptions
- */
- setup(getToken, defaultOptions) {
- this._retriesLeft = MAX_GET_TOKEN_RETRIES;
- if (getToken) {
- this._type = Auth.TYPE.ACCESS_TOKEN;
- this._cachedTokenPromise = null;
- this._getToken = getToken;
- }
- this._defaultOptions = {};
- if (defaultOptions) {
- for (const p of ['credentials']) {
- this._defaultOptions[p] = defaultOptions[p];
- }
- }
- }
-
- /**
- * Perform network fetch with authentication.
- *
- * @param {string} url
- * @param {Object=} opt_options
- * @return {!Promise<!Response>}
- */
- fetch(url, opt_options) {
- const options = Object.assign({
- headers: new Headers(),
- }, this._defaultOptions, opt_options);
- if (this._type === Auth.TYPE.ACCESS_TOKEN) {
- return this._getAccessToken().then(
- accessToken =>
- this._fetchWithAccessToken(url, options, accessToken)
- );
- } else {
- return this._fetchWithXsrfToken(url, options);
- }
- }
-
- _getCookie(name) {
- const key = name + '=';
- let result = '';
- document.cookie.split(';').some(c => {
- c = c.trim();
- if (c.startsWith(key)) {
- result = c.substring(key.length);
- return true;
- }
- });
- return result;
- }
-
- _isTokenValid(token) {
- if (!token) { return false; }
- if (!token.access_token || !token.expires_at) { return false; }
-
- const expiration = new Date(parseInt(token.expires_at, 10) * 1000);
- if (Date.now() >= expiration.getTime()) { return false; }
-
- return true;
- }
-
- _fetchWithXsrfToken(url, options) {
- if (options.method && options.method !== 'GET') {
- const token = this._getCookie('XSRF_TOKEN');
- if (token) {
- options.headers.append('X-Gerrit-Auth', token);
- }
- }
- options.credentials = 'same-origin';
- return fetch(url, options);
- }
-
- /**
- * @return {!Promise<string>}
- */
- _getAccessToken() {
- if (!this._cachedTokenPromise) {
- this._cachedTokenPromise = this._getToken();
- }
- return this._cachedTokenPromise.then(token => {
- if (this._isTokenValid(token)) {
- this._retriesLeft = MAX_GET_TOKEN_RETRIES;
- return token.access_token;
- }
- if (this._retriesLeft > 0) {
- this._retriesLeft--;
- this._cachedTokenPromise = null;
- return this._getAccessToken();
- }
- // Fall back to anonymous access.
- return null;
- });
- }
-
- _fetchWithAccessToken(url, options, accessToken) {
- const params = [];
-
- if (accessToken) {
- params.push(`access_token=${accessToken}`);
- const baseUrl = this.baseUrl;
- const pathname = baseUrl ?
- url.substring(url.indexOf(baseUrl) + baseUrl.length) : url;
- if (!pathname.startsWith('/a/')) {
- url = url.replace(pathname, '/a' + pathname);
- }
- }
-
- const method = options.method || 'GET';
- let contentType = options.headers.get('Content-Type');
-
- // For all requests with body, ensure json content type.
- if (!contentType && options.body) {
- contentType = 'application/json';
- }
-
- if (method !== 'GET') {
- options.method = 'POST';
- params.push(`$m=${method}`);
- // If a request is not GET, and does not have a body, ensure text/plain
- // content type.
- if (!contentType) {
- contentType = 'text/plain';
- }
- }
-
- if (contentType) {
- options.headers.set('Content-Type', 'text/plain');
- params.push(`$ct=${encodeURIComponent(contentType)}`);
- }
-
- if (params.length) {
- url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
- }
- return fetch(url, options);
- }
-}
-
-Auth.TYPE = {
- XSRF_TOKEN: 'xsrf_token',
- ACCESS_TOKEN: 'access_token',
-};
-
-/** @enum {number} */
-Auth.STATUS = {
- UNDETERMINED: 0,
- AUTHED: 1,
- NOT_AUTHED: 2,
- ERROR: 3,
-};
-
-Auth.CREDS_EXPIRED_MSG = 'Credentials expired.';
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth.ts b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
new file mode 100644
index 0000000..f7fdadf
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth.ts
@@ -0,0 +1,68 @@
+/**
+ * @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.
+ */
+
+export enum AuthType {
+ XSRF_TOKEN = 'xsrf_token',
+ ACCESS_TOKEN = 'access_token',
+}
+
+export enum AuthStatus {
+ UNDETERMINED = 0,
+ AUTHED = 1,
+ NOT_AUTHED = 2,
+ ERROR = 3,
+}
+
+export interface Token {
+ access_token?: string;
+ expires_at?: string;
+}
+
+export type GetTokenCallback = () => Promise<Token | null>;
+
+export interface DefaultAuthOptions {
+ credentials: RequestCredentials;
+}
+
+export interface AuthRequestInit extends RequestInit {
+ // RequestInit define headers as HeadersInit, i.e.
+ // Headers | string[][] | Record<string, string>
+ // Auth class supports only Headers in options
+ headers?: Headers;
+}
+
+export interface AuthService {
+ baseUrl: string;
+ isAuthed: boolean;
+
+ /**
+ * Returns if user is authed or not.
+ */
+ authCheck(): Promise<boolean>;
+
+ clearCache(): void;
+
+ /**
+ * Enable cross-domain authentication using OAuth access token.
+ */
+ setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions): void;
+
+ /**
+ * Perform network fetch with authentication.
+ */
+ fetch(url: string, opt_options?: AuthRequestInit): Promise<Response>;
+}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
new file mode 100644
index 0000000..8fe7c35
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -0,0 +1,291 @@
+/**
+ * @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 {getBaseUrl} from '../../utils/url-util';
+import {EventEmitterService} from '../gr-event-interface/gr-event-interface';
+import {
+ AuthRequestInit,
+ AuthService,
+ AuthStatus,
+ AuthType,
+ DefaultAuthOptions,
+ GetTokenCallback,
+ Token,
+} from './gr-auth';
+
+const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
+const MAX_GET_TOKEN_RETRIES = 2;
+
+interface ValidToken extends Token {
+ access_token: string;
+ expires_at: string;
+}
+
+interface AuthRequestInitWithHeaders extends AuthRequestInit {
+ // RequestInit define headers as optional property with a type
+ // Headers | string[][] | Record<string, string>
+ // In Auth class headers property is always set and has type Headers
+ headers: Headers;
+}
+
+/**
+ * Auth class.
+ */
+export class Auth implements AuthService {
+ // TODO(dmfilippov): Remove Type and Status properties, expose AuthType and
+ // AuthStatus to API
+ static TYPE = {
+ XSRF_TOKEN: AuthType.XSRF_TOKEN,
+ ACCESS_TOKEN: AuthType.ACCESS_TOKEN,
+ };
+
+ static STATUS = {
+ UNDETERMINED: AuthStatus.UNDETERMINED,
+ AUTHED: AuthStatus.AUTHED,
+ NOT_AUTHED: AuthStatus.NOT_AUTHED,
+ ERROR: AuthStatus.ERROR,
+ };
+
+ static CREDS_EXPIRED_MSG = 'Credentials expired.';
+
+ private _authCheckPromise?: Promise<Response>;
+
+ private _last_auth_check_time: number = Date.now();
+
+ private _status = AuthStatus.UNDETERMINED;
+
+ private _retriesLeft = MAX_GET_TOKEN_RETRIES;
+
+ private _cachedTokenPromise: Promise<Token | null> | null = null;
+
+ private _type?: AuthType;
+
+ private _defaultOptions: AuthRequestInit = {};
+
+ private _getToken: GetTokenCallback;
+
+ public eventEmitter: EventEmitterService;
+
+ constructor(eventEmitter: EventEmitterService) {
+ this._getToken = () => Promise.resolve(this._cachedTokenPromise);
+ this.eventEmitter = eventEmitter;
+ }
+
+ get baseUrl() {
+ return getBaseUrl();
+ }
+
+ /**
+ * Returns if user is authed or not.
+ */
+ authCheck(): Promise<boolean> {
+ if (
+ !this._authCheckPromise ||
+ Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS
+ ) {
+ // Refetch after last check expired
+ this._authCheckPromise = fetch(`${this.baseUrl}/auth-check`);
+ this._last_auth_check_time = Date.now();
+ }
+
+ return this._authCheckPromise
+ .then(res => {
+ // auth-check will return 204 if authed
+ // treat the rest as unauthed
+ if (res.status === 204) {
+ this._setStatus(Auth.STATUS.AUTHED);
+ return true;
+ } else {
+ this._setStatus(Auth.STATUS.NOT_AUTHED);
+ return false;
+ }
+ })
+ .catch(() => {
+ this._setStatus(AuthStatus.ERROR);
+ // Reset _authCheckPromise to avoid caching the failed promise
+ this._authCheckPromise = undefined;
+ return false;
+ });
+ }
+
+ clearCache() {
+ this._authCheckPromise = undefined;
+ }
+
+ private _setStatus(status: AuthStatus) {
+ if (this._status === status) return;
+
+ if (this._status === AuthStatus.AUTHED) {
+ this.eventEmitter.emit('auth-error', {
+ message: Auth.CREDS_EXPIRED_MSG,
+ action: 'Refresh credentials',
+ });
+ }
+ this._status = status;
+ }
+
+ get status() {
+ return this._status;
+ }
+
+ get isAuthed() {
+ return this._status === Auth.STATUS.AUTHED;
+ }
+
+ /**
+ * Enable cross-domain authentication using OAuth access token.
+ */
+ setup(getToken: GetTokenCallback, defaultOptions: DefaultAuthOptions) {
+ this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+ if (getToken) {
+ this._type = AuthType.ACCESS_TOKEN;
+ this._cachedTokenPromise = null;
+ this._getToken = getToken;
+ }
+ this._defaultOptions = {};
+ if (defaultOptions) {
+ this._defaultOptions.credentials = defaultOptions.credentials;
+ }
+ }
+
+ /**
+ * Perform network fetch with authentication.
+ */
+ fetch(url: string, opt_options?: AuthRequestInit): Promise<Response> {
+ const options: AuthRequestInitWithHeaders = {
+ headers: new Headers(),
+ ...this._defaultOptions,
+ ...opt_options,
+ };
+ if (this._type === AuthType.ACCESS_TOKEN) {
+ return this._getAccessToken().then(accessToken =>
+ this._fetchWithAccessToken(url, options, accessToken)
+ );
+ } else {
+ return this._fetchWithXsrfToken(url, options);
+ }
+ }
+
+ private _getCookie(name: string): string {
+ const key = name + '=';
+ let result = '';
+ document.cookie.split(';').some(c => {
+ c = c.trim();
+ if (c.startsWith(key)) {
+ result = c.substring(key.length);
+ return true;
+ }
+ return false;
+ });
+ return result;
+ }
+
+ private _isTokenValid(token: Token | null): token is ValidToken {
+ if (!token) {
+ return false;
+ }
+ if (!token.access_token || !token.expires_at) {
+ return false;
+ }
+
+ const expiration = new Date(Number(token.expires_at) * 1000);
+ if (Date.now() >= expiration.getTime()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private _fetchWithXsrfToken(
+ url: string,
+ options: AuthRequestInitWithHeaders
+ ): Promise<Response> {
+ if (options.method && options.method !== 'GET') {
+ const token = this._getCookie('XSRF_TOKEN');
+ if (token) {
+ options.headers.append('X-Gerrit-Auth', token);
+ }
+ }
+ options.credentials = 'same-origin';
+ return fetch(url, options);
+ }
+
+ private _getAccessToken(): Promise<string | null> {
+ if (!this._cachedTokenPromise) {
+ this._cachedTokenPromise = this._getToken();
+ }
+ return this._cachedTokenPromise.then(token => {
+ if (this._isTokenValid(token)) {
+ this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+ return token.access_token;
+ }
+ if (this._retriesLeft > 0) {
+ this._retriesLeft--;
+ this._cachedTokenPromise = null;
+ return this._getAccessToken();
+ }
+ // Fall back to anonymous access.
+ return null;
+ });
+ }
+
+ private _fetchWithAccessToken(
+ url: string,
+ options: AuthRequestInitWithHeaders,
+ accessToken: string | null
+ ): Promise<Response> {
+ const params = [];
+
+ if (accessToken) {
+ params.push(`access_token=${accessToken}`);
+ const baseUrl = this.baseUrl;
+ const pathname = baseUrl
+ ? url.substring(url.indexOf(baseUrl) + baseUrl.length)
+ : url;
+ if (!pathname.startsWith('/a/')) {
+ url = url.replace(pathname, '/a' + pathname);
+ }
+ }
+
+ const method = options.method || 'GET';
+ let contentType = options.headers.get('Content-Type');
+
+ // For all requests with body, ensure json content type.
+ if (!contentType && options.body) {
+ contentType = 'application/json';
+ }
+
+ if (method !== 'GET') {
+ options.method = 'POST';
+ params.push(`$m=${method}`);
+ // If a request is not GET, and does not have a body, ensure text/plain
+ // content type.
+ if (!contentType) {
+ contentType = 'text/plain';
+ }
+ }
+
+ if (contentType) {
+ options.headers.set('Content-Type', 'text/plain');
+ params.push(`$ct=${encodeURIComponent(contentType)}`);
+ }
+
+ if (params.length) {
+ url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
+ }
+ return fetch(url, options);
+ }
+}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
new file mode 100644
index 0000000..80938ad
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
@@ -0,0 +1,374 @@
+/**
+ * @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 '../../test/common-test-setup-karma.js';
+import {Auth} from './gr-auth_impl.js';
+import {appContext} from '../app-context.js';
+import {stubBaseUrl} from '../../test/test-utils.js';
+
+suite('gr-auth', () => {
+ let auth;
+
+ setup(() => {
+ auth = appContext.authService;
+ });
+
+ suite('Auth class methods', () => {
+ let fakeFetch;
+ setup(() => {
+ auth = new Auth(appContext.eventEmitter);
+ fakeFetch = sinon.stub(window, 'fetch');
+ });
+
+ test('auth-check returns 403', done => {
+ fakeFetch.returns(Promise.resolve({status: 403}));
+ auth.authCheck().then(authed => {
+ assert.isFalse(authed);
+ assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+ done();
+ });
+ });
+
+ test('auth-check returns 204', done => {
+ fakeFetch.returns(Promise.resolve({status: 204}));
+ auth.authCheck().then(authed => {
+ assert.isTrue(authed);
+ assert.equal(auth.status, Auth.STATUS.AUTHED);
+ done();
+ });
+ });
+
+ test('auth-check returns 502', done => {
+ fakeFetch.returns(Promise.resolve({status: 502}));
+ auth.authCheck().then(authed => {
+ assert.isFalse(authed);
+ assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+ done();
+ });
+ });
+
+ test('auth-check failed', done => {
+ fakeFetch.returns(Promise.reject(new Error('random error')));
+ auth.authCheck().then(authed => {
+ assert.isFalse(authed);
+ assert.equal(auth.status, Auth.STATUS.ERROR);
+ done();
+ });
+ });
+ });
+
+ suite('cache and events behavior', () => {
+ let fakeFetch;
+ let clock;
+ setup(() => {
+ auth = new Auth(appContext.eventEmitter);
+ clock = sinon.useFakeTimers();
+ fakeFetch = sinon.stub(window, 'fetch');
+ });
+
+ test('cache auth-check result', done => {
+ fakeFetch.returns(Promise.resolve({status: 403}));
+ auth.authCheck().then(authed => {
+ assert.isFalse(authed);
+ assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+ fakeFetch.returns(Promise.resolve({status: 204}));
+ auth.authCheck().then(authed2 => {
+ assert.isFalse(authed);
+ assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+ done();
+ });
+ });
+ });
+
+ test('clearCache should refetch auth-check result', done => {
+ fakeFetch.returns(Promise.resolve({status: 403}));
+ auth.authCheck().then(authed => {
+ assert.isFalse(authed);
+ assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+ fakeFetch.returns(Promise.resolve({status: 204}));
+ auth.clearCache();
+ auth.authCheck().then(authed2 => {
+ assert.isTrue(authed2);
+ assert.equal(auth.status, Auth.STATUS.AUTHED);
+ done();
+ });
+ });
+ });
+
+ test('cache expired on auth-check after certain time', done => {
+ fakeFetch.returns(Promise.resolve({status: 403}));
+ auth.authCheck().then(authed => {
+ assert.isFalse(authed);
+ assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+ clock.tick(1000 * 10000);
+ fakeFetch.returns(Promise.resolve({status: 204}));
+ auth.authCheck().then(authed2 => {
+ assert.isTrue(authed2);
+ assert.equal(auth.status, Auth.STATUS.AUTHED);
+ done();
+ });
+ });
+ });
+
+ test('no cache if auth-check failed', done => {
+ fakeFetch.returns(Promise.reject(new Error('random error')));
+ auth.authCheck().then(authed => {
+ assert.isFalse(authed);
+ assert.equal(auth.status, Auth.STATUS.ERROR);
+ assert.equal(fakeFetch.callCount, 1);
+ auth.authCheck().then(() => {
+ assert.equal(fakeFetch.callCount, 2);
+ done();
+ });
+ });
+ });
+
+ test('fire event when switch from authed to unauthed', done => {
+ fakeFetch.returns(Promise.resolve({status: 204}));
+ auth.authCheck().then(authed => {
+ assert.isTrue(authed);
+ assert.equal(auth.status, Auth.STATUS.AUTHED);
+ clock.tick(1000 * 10000);
+ fakeFetch.returns(Promise.resolve({status: 403}));
+ const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+ auth.authCheck().then(authed2 => {
+ assert.isFalse(authed2);
+ assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+ assert.isTrue(emitStub.called);
+ done();
+ });
+ });
+ });
+
+ test('fire event when switch from authed to error', done => {
+ fakeFetch.returns(Promise.resolve({status: 204}));
+ auth.authCheck().then(authed => {
+ assert.isTrue(authed);
+ assert.equal(auth.status, Auth.STATUS.AUTHED);
+ clock.tick(1000 * 10000);
+ fakeFetch.returns(Promise.reject(new Error('random error')));
+ const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+ auth.authCheck().then(authed2 => {
+ assert.isFalse(authed2);
+ assert.isTrue(emitStub.called);
+ assert.equal(auth.status, Auth.STATUS.ERROR);
+ done();
+ });
+ });
+ });
+
+ test('no event from non-authed to other status', done => {
+ fakeFetch.returns(Promise.resolve({status: 403}));
+ auth.authCheck().then(authed => {
+ assert.isFalse(authed);
+ assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+ clock.tick(1000 * 10000);
+ fakeFetch.returns(Promise.resolve({status: 204}));
+ const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+ auth.authCheck().then(authed2 => {
+ assert.isTrue(authed2);
+ assert.isFalse(emitStub.called);
+ assert.equal(auth.status, Auth.STATUS.AUTHED);
+ done();
+ });
+ });
+ });
+
+ test('no event from non-authed to other status', done => {
+ fakeFetch.returns(Promise.resolve({status: 403}));
+ auth.authCheck().then(authed => {
+ assert.isFalse(authed);
+ assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+ clock.tick(1000 * 10000);
+ fakeFetch.returns(Promise.reject(new Error('random error')));
+ const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+ auth.authCheck().then(authed2 => {
+ assert.isFalse(authed2);
+ assert.isFalse(emitStub.called);
+ assert.equal(auth.status, Auth.STATUS.ERROR);
+ done();
+ });
+ });
+ });
+ });
+
+ suite('default (xsrf token header)', () => {
+ setup(() => {
+ sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+ });
+
+ test('GET', done => {
+ auth.fetch('/url', {bar: 'bar'}).then(() => {
+ const [url, options] = fetch.lastCall.args;
+ assert.equal(url, '/url');
+ assert.equal(options.credentials, 'same-origin');
+ done();
+ });
+ });
+
+ test('POST', done => {
+ sinon.stub(auth, '_getCookie')
+ .withArgs('XSRF_TOKEN')
+ .returns('foobar');
+ auth.fetch('/url', {method: 'POST'}).then(() => {
+ const [url, options] = fetch.lastCall.args;
+ assert.equal(url, '/url');
+ assert.equal(options.credentials, 'same-origin');
+ assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
+ done();
+ });
+ });
+ });
+
+ suite('cors (access token)', () => {
+ setup(() => {
+ sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+ });
+
+ let getToken;
+
+ const makeToken = opt_accessToken => {
+ return {
+ access_token: opt_accessToken || 'zbaz',
+ expires_at: new Date(Date.now() + 10e8).getTime(),
+ };
+ };
+
+ setup(() => {
+ getToken = sinon.stub();
+ getToken.returns(Promise.resolve(makeToken()));
+ auth.setup(getToken);
+ });
+
+ test('base url support', done => {
+ const baseUrl = 'http://foo';
+ stubBaseUrl(baseUrl);
+ auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
+ const [url] = fetch.lastCall.args;
+ assert.equal(url, 'http://foo/a/url?access_token=zbaz');
+ done();
+ });
+ });
+
+ test('fetch not signed in', done => {
+ getToken.returns(Promise.resolve());
+ auth.fetch('/url', {bar: 'bar'}).then(() => {
+ const [url, options] = fetch.lastCall.args;
+ assert.equal(url, '/url');
+ assert.equal(options.bar, 'bar');
+ assert.equal(Object.keys(options.headers).length, 0);
+ done();
+ });
+ });
+
+ test('fetch signed in', done => {
+ auth.fetch('/url', {bar: 'bar'}).then(() => {
+ const [url, options] = fetch.lastCall.args;
+ assert.equal(url, '/a/url?access_token=zbaz');
+ assert.equal(options.bar, 'bar');
+ done();
+ });
+ });
+
+ test('getToken calls are cached', done => {
+ Promise.all([
+ auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
+ assert.equal(getToken.callCount, 1);
+ done();
+ });
+ });
+
+ test('getToken refreshes token', done => {
+ sinon.stub(auth, '_isTokenValid');
+ auth._isTokenValid
+ .onFirstCall().returns(true)
+ .onSecondCall()
+ .returns(false)
+ .onThirdCall()
+ .returns(true);
+ auth.fetch('/url-one')
+ .then(() => {
+ getToken.returns(Promise.resolve(makeToken('bzzbb')));
+ return auth.fetch('/url-two');
+ })
+ .then(() => {
+ const [[firstUrl], [secondUrl]] = fetch.args;
+ assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
+ assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
+ done();
+ });
+ });
+
+ test('signed in token error falls back to anonymous', done => {
+ getToken.returns(Promise.resolve('rubbish'));
+ auth.fetch('/url', {bar: 'bar'}).then(() => {
+ const [url, options] = fetch.lastCall.args;
+ assert.equal(url, '/url');
+ assert.equal(options.bar, 'bar');
+ done();
+ });
+ });
+
+ test('_isTokenValid', () => {
+ assert.isFalse(auth._isTokenValid());
+ assert.isFalse(auth._isTokenValid({}));
+ assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
+ assert.isFalse(auth._isTokenValid({
+ access_token: 'foo',
+ expires_at: Date.now()/1000 - 1,
+ }));
+ assert.isTrue(auth._isTokenValid({
+ access_token: 'foo',
+ expires_at: Date.now()/1000 + 1,
+ }));
+ });
+
+ test('HTTP PUT with content type', done => {
+ const originalOptions = {
+ method: 'PUT',
+ headers: new Headers({'Content-Type': 'mail/pigeon'}),
+ };
+ auth.fetch('/url', originalOptions).then(() => {
+ assert.isTrue(getToken.called);
+ const [url, options] = fetch.lastCall.args;
+ assert.include(url, '$ct=mail%2Fpigeon');
+ assert.include(url, '$m=PUT');
+ assert.include(url, 'access_token=zbaz');
+ assert.equal(options.method, 'POST');
+ assert.equal(options.headers.get('Content-Type'), 'text/plain');
+ done();
+ });
+ });
+
+ test('HTTP PUT without content type', done => {
+ const originalOptions = {
+ method: 'PUT',
+ };
+ auth.fetch('/url', originalOptions).then(() => {
+ assert.isTrue(getToken.called);
+ const [url, options] = fetch.lastCall.args;
+ assert.include(url, '$ct=text%2Fplain');
+ assert.include(url, '$m=PUT');
+ assert.include(url, 'access_token=zbaz');
+ assert.equal(options.method, 'POST');
+ assert.equal(options.headers.get('Content-Type'), 'text/plain');
+ done();
+ });
+ });
+ });
+});
+
diff --git a/polygerrit-ui/app/services/gr-auth_test.js b/polygerrit-ui/app/services/gr-auth_test.js
deleted file mode 100644
index 541cd42..0000000
--- a/polygerrit-ui/app/services/gr-auth_test.js
+++ /dev/null
@@ -1,378 +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 '../test/common-test-setup-karma.js';
-import {Auth} from './gr-auth.js';
-import {appContext} from './app-context.js';
-import {stubBaseUrl} from '../test/test-utils.js';
-
-suite('gr-auth', () => {
- let auth;
-
- setup(() => {
- auth = appContext.authService;
- });
-
- suite('Auth class methods', () => {
- let fakeFetch;
- setup(() => {
- auth = new Auth(appContext.eventEmitter);
- fakeFetch = sinon.stub(window, 'fetch');
- });
-
- test('auth-check returns 403', done => {
- fakeFetch.returns(Promise.resolve({status: 403}));
- auth.authCheck().then(authed => {
- assert.isFalse(authed);
- assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
- done();
- });
- });
-
- test('auth-check returns 204', done => {
- fakeFetch.returns(Promise.resolve({status: 204}));
- auth.authCheck().then(authed => {
- assert.isTrue(authed);
- assert.equal(auth.status, Auth.STATUS.AUTHED);
- done();
- });
- });
-
- test('auth-check returns 502', done => {
- fakeFetch.returns(Promise.resolve({status: 502}));
- auth.authCheck().then(authed => {
- assert.isFalse(authed);
- assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
- done();
- });
- });
-
- test('auth-check failed', done => {
- fakeFetch.returns(Promise.reject(new Error('random error')));
- auth.authCheck().then(authed => {
- assert.isFalse(authed);
- assert.equal(auth.status, Auth.STATUS.ERROR);
- done();
- });
- });
- });
-
- suite('cache and events behavior', () => {
- let fakeFetch;
- let clock;
- setup(() => {
- auth = new Auth(appContext.eventEmitter);
- clock = sinon.useFakeTimers();
- fakeFetch = sinon.stub(window, 'fetch');
- });
-
- test('cache auth-check result', done => {
- fakeFetch.returns(Promise.resolve({status: 403}));
- auth.authCheck().then(authed => {
- assert.isFalse(authed);
- assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
- fakeFetch.returns(Promise.resolve({status: 204}));
- auth.authCheck().then(authed2 => {
- assert.isFalse(authed);
- assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
- done();
- });
- });
- });
-
- test('clearCache should refetch auth-check result', done => {
- fakeFetch.returns(Promise.resolve({status: 403}));
- auth.authCheck().then(authed => {
- assert.isFalse(authed);
- assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
- fakeFetch.returns(Promise.resolve({status: 204}));
- auth.clearCache();
- auth.authCheck().then(authed2 => {
- assert.isTrue(authed2);
- assert.equal(auth.status, Auth.STATUS.AUTHED);
- done();
- });
- });
- });
-
- test('cache expired on auth-check after certain time', done => {
- fakeFetch.returns(Promise.resolve({status: 403}));
- auth.authCheck().then(authed => {
- assert.isFalse(authed);
- assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
- clock.tick(1000 * 10000);
- fakeFetch.returns(Promise.resolve({status: 204}));
- auth.authCheck().then(authed2 => {
- assert.isTrue(authed2);
- assert.equal(auth.status, Auth.STATUS.AUTHED);
- done();
- });
- });
- });
-
- test('no cache if auth-check failed', done => {
- fakeFetch.returns(Promise.reject(new Error('random error')));
- auth.authCheck().then(authed => {
- assert.isFalse(authed);
- assert.equal(auth.status, Auth.STATUS.ERROR);
- assert.equal(fakeFetch.callCount, 1);
- auth.authCheck().then(() => {
- assert.equal(fakeFetch.callCount, 2);
- done();
- });
- });
- });
-
- test('fire event when switch from authed to unauthed', done => {
- fakeFetch.returns(Promise.resolve({status: 204}));
- auth.authCheck().then(authed => {
- assert.isTrue(authed);
- assert.equal(auth.status, Auth.STATUS.AUTHED);
- clock.tick(1000 * 10000);
- fakeFetch.returns(Promise.resolve({status: 403}));
- const emitStub = sinon.stub();
- appContext.eventEmitter.emit = emitStub;
- auth.authCheck().then(authed2 => {
- assert.isFalse(authed2);
- assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
- assert.isTrue(emitStub.called);
- done();
- });
- });
- });
-
- test('fire event when switch from authed to error', done => {
- fakeFetch.returns(Promise.resolve({status: 204}));
- auth.authCheck().then(authed => {
- assert.isTrue(authed);
- assert.equal(auth.status, Auth.STATUS.AUTHED);
- clock.tick(1000 * 10000);
- fakeFetch.returns(Promise.reject(new Error('random error')));
- const emitStub = sinon.stub();
- appContext.eventEmitter.emit = emitStub;
- auth.authCheck().then(authed2 => {
- assert.isFalse(authed2);
- assert.isTrue(emitStub.called);
- assert.equal(auth.status, Auth.STATUS.ERROR);
- done();
- });
- });
- });
-
- test('no event from non-authed to other status', done => {
- fakeFetch.returns(Promise.resolve({status: 403}));
- auth.authCheck().then(authed => {
- assert.isFalse(authed);
- assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
- clock.tick(1000 * 10000);
- fakeFetch.returns(Promise.resolve({status: 204}));
- const emitStub = sinon.stub();
- appContext.eventEmitter.emit = emitStub;
- auth.authCheck().then(authed2 => {
- assert.isTrue(authed2);
- assert.isFalse(emitStub.called);
- assert.equal(auth.status, Auth.STATUS.AUTHED);
- done();
- });
- });
- });
-
- test('no event from non-authed to other status', done => {
- fakeFetch.returns(Promise.resolve({status: 403}));
- auth.authCheck().then(authed => {
- assert.isFalse(authed);
- assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
- clock.tick(1000 * 10000);
- fakeFetch.returns(Promise.reject(new Error('random error')));
- const emitStub = sinon.stub();
- appContext.eventEmitter.emit = emitStub;
- auth.authCheck().then(authed2 => {
- assert.isFalse(authed2);
- assert.isFalse(emitStub.called);
- assert.equal(auth.status, Auth.STATUS.ERROR);
- done();
- });
- });
- });
- });
-
- suite('default (xsrf token header)', () => {
- setup(() => {
- sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
- });
-
- test('GET', done => {
- auth.fetch('/url', {bar: 'bar'}).then(() => {
- const [url, options] = fetch.lastCall.args;
- assert.equal(url, '/url');
- assert.equal(options.credentials, 'same-origin');
- done();
- });
- });
-
- test('POST', done => {
- sinon.stub(auth, '_getCookie')
- .withArgs('XSRF_TOKEN')
- .returns('foobar');
- auth.fetch('/url', {method: 'POST'}).then(() => {
- const [url, options] = fetch.lastCall.args;
- assert.equal(url, '/url');
- assert.equal(options.credentials, 'same-origin');
- assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
- done();
- });
- });
- });
-
- suite('cors (access token)', () => {
- setup(() => {
- sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
- });
-
- let getToken;
-
- const makeToken = opt_accessToken => {
- return {
- access_token: opt_accessToken || 'zbaz',
- expires_at: new Date(Date.now() + 10e8).getTime(),
- };
- };
-
- setup(() => {
- getToken = sinon.stub();
- getToken.returns(Promise.resolve(makeToken()));
- auth.setup(getToken);
- });
-
- test('base url support', done => {
- const baseUrl = 'http://foo';
- stubBaseUrl(baseUrl);
- auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
- const [url] = fetch.lastCall.args;
- assert.equal(url, 'http://foo/a/url?access_token=zbaz');
- done();
- });
- });
-
- test('fetch not signed in', done => {
- getToken.returns(Promise.resolve());
- auth.fetch('/url', {bar: 'bar'}).then(() => {
- const [url, options] = fetch.lastCall.args;
- assert.equal(url, '/url');
- assert.equal(options.bar, 'bar');
- assert.equal(Object.keys(options.headers).length, 0);
- done();
- });
- });
-
- test('fetch signed in', done => {
- auth.fetch('/url', {bar: 'bar'}).then(() => {
- const [url, options] = fetch.lastCall.args;
- assert.equal(url, '/a/url?access_token=zbaz');
- assert.equal(options.bar, 'bar');
- done();
- });
- });
-
- test('getToken calls are cached', done => {
- Promise.all([
- auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
- assert.equal(getToken.callCount, 1);
- done();
- });
- });
-
- test('getToken refreshes token', done => {
- sinon.stub(auth, '_isTokenValid');
- auth._isTokenValid
- .onFirstCall().returns(true)
- .onSecondCall()
- .returns(false)
- .onThirdCall()
- .returns(true);
- auth.fetch('/url-one')
- .then(() => {
- getToken.returns(Promise.resolve(makeToken('bzzbb')));
- return auth.fetch('/url-two');
- })
- .then(() => {
- const [[firstUrl], [secondUrl]] = fetch.args;
- assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
- assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
- done();
- });
- });
-
- test('signed in token error falls back to anonymous', done => {
- getToken.returns(Promise.resolve('rubbish'));
- auth.fetch('/url', {bar: 'bar'}).then(() => {
- const [url, options] = fetch.lastCall.args;
- assert.equal(url, '/url');
- assert.equal(options.bar, 'bar');
- done();
- });
- });
-
- test('_isTokenValid', () => {
- assert.isFalse(auth._isTokenValid());
- assert.isFalse(auth._isTokenValid({}));
- assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
- assert.isFalse(auth._isTokenValid({
- access_token: 'foo',
- expires_at: Date.now()/1000 - 1,
- }));
- assert.isTrue(auth._isTokenValid({
- access_token: 'foo',
- expires_at: Date.now()/1000 + 1,
- }));
- });
-
- test('HTTP PUT with content type', done => {
- const originalOptions = {
- method: 'PUT',
- headers: new Headers({'Content-Type': 'mail/pigeon'}),
- };
- auth.fetch('/url', originalOptions).then(() => {
- assert.isTrue(getToken.called);
- const [url, options] = fetch.lastCall.args;
- assert.include(url, '$ct=mail%2Fpigeon');
- assert.include(url, '$m=PUT');
- assert.include(url, 'access_token=zbaz');
- assert.equal(options.method, 'POST');
- assert.equal(options.headers.get('Content-Type'), 'text/plain');
- done();
- });
- });
-
- test('HTTP PUT without content type', done => {
- const originalOptions = {
- method: 'PUT',
- };
- auth.fetch('/url', originalOptions).then(() => {
- assert.isTrue(getToken.called);
- const [url, options] = fetch.lastCall.args;
- assert.include(url, '$ct=text%2Fplain');
- assert.include(url, '$m=PUT');
- assert.include(url, 'access_token=zbaz');
- assert.equal(options.method, 'POST');
- assert.equal(options.headers.get('Content-Type'), 'text/plain');
- done();
- });
- });
- });
-});
-
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.js
deleted file mode 100644
index 7705874..0000000
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.js
+++ /dev/null
@@ -1,134 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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.
- */
-
-/**
- * An lite implementation of
- * https://nodejs.org/api/events.html#events_class_eventemitter.
- *
- * This is unrelated to the native DOM events, you should use it when you want
- * to enable EventEmitter interface on any class.
- *
- * @example
- *
- * class YourClass extends EventEmitter {
- * // now all instance of YourClass will have this EventEmitter interface
- * }
- *
- */
-export class EventEmitter {
- constructor() {
- /**
- * Shared events map from name to the listeners.
- *
- * @type {!Object<string, Array<eventCallback>>}
- */
- this._listenersMap = new Map();
- }
-
- /**
- * Register an event listener to an event.
- *
- * @param {string} eventName
- * @param {eventCallback} cb
- * @returns {Function} Unsubscribe method
- */
- addListener(eventName, cb) {
- if (!eventName || !cb) {
- console.warn('A valid eventname and callback is required!');
- return;
- }
-
- const listeners = this._listenersMap.get(eventName) || [];
- listeners.push(cb);
- this._listenersMap.set(eventName, listeners);
-
- return () => {
- this.off(eventName, cb);
- };
- }
-
- // Alias for addListener.
- on(eventName, cb) {
- return this.addListener(eventName, cb);
- }
-
- // Attach event handler only once. Automatically removed.
- once(eventName, cb) {
- const onceWrapper = (...args) => {
- cb(...args);
- this.off(eventName, onceWrapper);
- };
- return this.on(eventName, onceWrapper);
- }
-
- /**
- * De-register an event listener to an event.
- *
- * @param {string} eventName
- * @param {eventCallback} cb
- */
- removeListener(eventName, cb) {
- let listeners = this._listenersMap.get(eventName) || [];
- listeners = listeners.filter(listener => listener !== cb);
- this._listenersMap.set(eventName, listeners);
- }
-
- // Alias to removeListener
- off(eventName, cb) {
- this.removeListener(eventName, cb);
- }
-
- /**
- * Synchronously calls each of the listeners registered for
- * the event named eventName, in the order they were registered,
- * passing the supplied detail to each.
- *
- * Returns true if the event had listeners, false otherwise.
- *
- * @param {string} eventName
- * @param {*} detail
- */
- emit(eventName, detail) {
- const listeners = this._listenersMap.get(eventName) || [];
- for (const listener of listeners) {
- try {
- listener(detail);
- } catch (e) {
- console.error(e);
- }
- }
- return listeners.length !== 0;
- }
-
- // Alias to emit.
- dispatch(eventName, detail) {
- return this.emit(eventName, detail);
- }
-
- /**
- * Remove listeners for a specific event or all.
- *
- * @param {string} eventName if not provided, will remove all
- */
- removeAllListeners(eventName) {
- if (eventName) {
- this._listenersMap.set(eventName, []);
- } else {
- this._listenersMap = new Map();
- }
- }
-}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
new file mode 100644
index 0000000..d59a022
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface.ts
@@ -0,0 +1,67 @@
+/**
+ * @license
+ * 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.
+ */
+
+export type EventCallback = (...args: any) => void;
+export type UnsubscribeMethod = () => void;
+
+export interface EventEmitterService {
+ /**
+ * Register an event listener to an event.
+ */
+ addListener(eventName: string, cb: EventCallback): UnsubscribeMethod;
+
+ /**
+ * Alias for addListener.
+ */
+ on(eventName: string, cb: EventCallback): UnsubscribeMethod;
+
+ /**
+ * Attach event handler only once. Automatically removed.
+ */
+ once(eventName: string, cb: EventCallback): UnsubscribeMethod;
+
+ /**
+ * De-register an event listener to an event.
+ */
+ removeListener(eventName: string, cb: EventCallback): void;
+
+ /**
+ * Alias to removeListener
+ */
+ off(eventName: string, cb: EventCallback): void;
+
+ /**
+ * Synchronously calls each of the listeners registered for
+ * the event named eventName, in the order they were registered,
+ * passing the supplied detail to each.
+ *
+ * @returns true if the event had listeners, false otherwise.
+ */
+ emit(eventName: string, detail: any): boolean;
+
+ /**
+ * Alias to emit.
+ */
+ dispatch(eventName: string, detail: any): boolean;
+
+ /**
+ * Remove listeners for a specific event or all.
+ *
+ * @param eventName if not provided, will remove all
+ */
+ removeAllListeners(eventName: string): void;
+}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
new file mode 100644
index 0000000..72afbda
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_impl.ts
@@ -0,0 +1,130 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {
+ EventCallback,
+ EventEmitterService,
+ UnsubscribeMethod,
+} from './gr-event-interface';
+/**
+ * An lite implementation of
+ * https://nodejs.org/api/events.html#events_class_eventemitter.
+ *
+ * This is unrelated to the native DOM events, you should use it when you want
+ * to enable EventEmitter interface on any class.
+ *
+ * @example
+ *
+ * class YourClass extends EventEmitter {
+ * // now all instance of YourClass will have this EventEmitter interface
+ * }
+ *
+ */
+export class EventEmitter implements EventEmitterService {
+ private _listenersMap = new Map<string, EventCallback[]>();
+
+ /**
+ * Register an event listener to an event.
+ */
+ addListener(eventName: string, cb: EventCallback): UnsubscribeMethod {
+ if (!eventName || !cb) {
+ console.warn('A valid eventname and callback is required!');
+ return () => {};
+ }
+
+ const listeners = this._listenersMap.get(eventName) || [];
+ listeners.push(cb);
+ this._listenersMap.set(eventName, listeners);
+
+ return () => {
+ this.off(eventName, cb);
+ };
+ }
+
+ /**
+ * Alias for addListener.
+ */
+ on(eventName: string, cb: EventCallback): UnsubscribeMethod {
+ return this.addListener(eventName, cb);
+ }
+
+ /**
+ * Attach event handler only once. Automatically removed.
+ */
+ once(eventName: string, cb: EventCallback): UnsubscribeMethod {
+ const onceWrapper = (...args: any[]) => {
+ cb(...args);
+ this.off(eventName, onceWrapper);
+ };
+ return this.on(eventName, onceWrapper);
+ }
+
+ /**
+ * De-register an event listener to an event.
+ */
+ removeListener(eventName: string, cb: EventCallback): void {
+ let listeners = this._listenersMap.get(eventName) || [];
+ listeners = listeners.filter(listener => listener !== cb);
+ this._listenersMap.set(eventName, listeners);
+ }
+
+ /**
+ * Alias to removeListener
+ */
+ off(eventName: string, cb: EventCallback): void {
+ this.removeListener(eventName, cb);
+ }
+
+ /**
+ * Synchronously calls each of the listeners registered for
+ * the event named eventName, in the order they were registered,
+ * passing the supplied detail to each.
+ *
+ * @returns true if the event had listeners, false otherwise.
+ */
+ emit(eventName: string, detail: any): boolean {
+ const listeners = this._listenersMap.get(eventName) || [];
+ for (const listener of listeners) {
+ try {
+ listener(detail);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ return listeners.length !== 0;
+ }
+
+ /**
+ * Alias to emit.
+ */
+ dispatch(eventName: string, detail: any): boolean {
+ return this.emit(eventName, detail);
+ }
+
+ /**
+ * Remove listeners for a specific event or all.
+ *
+ * @param eventName if not provided, will remove all
+ */
+ removeAllListeners(eventName: string): void {
+ if (eventName) {
+ this._listenersMap.set(eventName, []);
+ } else {
+ this._listenersMap = new Map();
+ }
+ }
+}
diff --git a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
index 1cdd6e3..32590e0 100644
--- a/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
+++ b/polygerrit-ui/app/services/gr-event-interface/gr-event-interface_test.js
@@ -17,61 +17,59 @@
import '../../test/common-test-setup-karma.js';
import '../../elements/shared/gr-js-api-interface/gr-js-api-interface.js';
-import {EventEmitter} from './gr-event-interface.js';
-import {_testOnly_initGerritPluginApi} from '../../elements/shared/gr-js-api-interface/gr-gerrit.js';
+import {EventEmitter} from './gr-event-interface_impl.js';
const basicFixture = fixtureFromElement('gr-js-api-interface');
-const pluginApi = _testOnly_initGerritPluginApi();
-
suite('gr-event-interface tests', () => {
+ let gerrit;
setup(() => {
-
+ gerrit = window.Gerrit;
});
suite('test on Gerrit', () => {
setup(() => {
basicFixture.instantiate();
- pluginApi.removeAllListeners();
+ gerrit.removeAllListeners();
});
test('communicate between plugin and Gerrit', done => {
const eventName = 'test-plugin-event';
let p;
- pluginApi.on(eventName, e => {
+ gerrit.on(eventName, e => {
assert.equal(e.value, 'test');
assert.equal(e.plugin, p);
done();
});
- pluginApi.install(plugin => {
+ gerrit.install(plugin => {
p = plugin;
- pluginApi.emit(eventName, {value: 'test', plugin});
+ gerrit.emit(eventName, {value: 'test', plugin});
}, '0.1',
'http://test.com/plugins/testplugin/static/test.js');
});
test('listen on events from core', done => {
const eventName = 'test-plugin-event';
- pluginApi.on(eventName, e => {
+ gerrit.on(eventName, e => {
assert.equal(e.value, 'test');
done();
});
- pluginApi.emit(eventName, {value: 'test'});
+ gerrit.emit(eventName, {value: 'test'});
});
test('communicate across plugins', done => {
const eventName = 'test-plugin-event';
- pluginApi.install(plugin => {
- pluginApi.on(eventName, e => {
+ gerrit.install(plugin => {
+ gerrit.on(eventName, e => {
assert.equal(e.plugin.getPluginName(), 'testB');
done();
});
}, '0.1',
'http://test.com/plugins/testA/static/testA.js');
- pluginApi.install(plugin => {
- pluginApi.emit(eventName, {plugin});
+ gerrit.install(plugin => {
+ gerrit.emit(eventName, {plugin});
}, '0.1',
'http://test.com/plugins/testB/static/testB.js');
});
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting.js
deleted file mode 100644
index 42d112e..0000000
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.js
+++ /dev/null
@@ -1,654 +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.
- */
-
-// Latency reporting constants.
-
-const TIMING = {
- TYPE: 'timing-report',
- CATEGORY: {
- UI_LATENCY: 'UI Latency',
- RPC: 'RPC Timing',
- },
- EVENT: {
- APP_STARTED: 'App Started',
- },
-};
-
-const LIFECYCLE = {
- TYPE: 'lifecycle',
- CATEGORY: {
- DEFAULT: 'Default',
- EXTENSION_DETECTED: 'Extension detected',
- PLUGINS_INSTALLED: 'Plugins installed',
- },
-};
-
-const INTERACTION = {
- TYPE: 'interaction',
- CATEGORY: {
- DEFAULT: 'Default',
- VISIBILITY: 'Visibility',
- },
-};
-
-const NAVIGATION = {
- TYPE: 'nav-report',
- CATEGORY: {
- LOCATION_CHANGED: 'Location Changed',
- },
- EVENT: {
- PAGE: 'Page',
- },
-};
-
-const ERROR = {
- TYPE: 'error',
- CATEGORY: {
- EXCEPTION: 'exception',
- ERROR_DIALOG: 'Error Dialog',
- },
-};
-
-const TIMER = {
- CHANGE_DISPLAYED: 'ChangeDisplayed',
- CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
- DASHBOARD_DISPLAYED: 'DashboardDisplayed',
- DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent',
- DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
- DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded',
- FILE_LIST_DISPLAYED: 'FileListDisplayed',
- PLUGINS_LOADED: 'PluginsLoaded',
- STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
- STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded',
- STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
- STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent',
- STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
- STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded',
- STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
- WEB_COMPONENTS_READY: 'WebComponentsReady',
- METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
-};
-
-const STARTUP_TIMERS = {};
-STARTUP_TIMERS[TIMER.PLUGINS_LOADED] = 0;
-STARTUP_TIMERS[TIMER.METRICS_PLUGIN_LOADED] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_CHANGE_DISPLAYED] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_CHANGE_LOAD_FULL] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_DASHBOARD_DISPLAYED] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_DISPLAYED] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_DIFF_VIEW_LOAD_FULL] = 0;
-STARTUP_TIMERS[TIMER.STARTUP_FILE_LIST_DISPLAYED] = 0;
-STARTUP_TIMERS[TIMING.EVENT.APP_STARTED] = 0;
-// WebComponentsReady timer is triggered from gr-router.
-STARTUP_TIMERS[TIMER.WEB_COMPONENTS_READY] = 0;
-
-const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
-const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
-const SLOW_RPC_THRESHOLD = 500;
-
-export function initErrorReporter(appContext) {
- const reportingService = appContext.reportingService;
- const onError = function(oldOnError, msg, url, line, column, error) {
- if (oldOnError) {
- oldOnError(msg, url, line, column, error);
- }
- if (error) {
- line = line || error.lineNumber;
- column = column || error.columnNumber;
- let shortenedErrorStack = msg;
- if (error.stack) {
- const errorStackLines = error.stack.split('\n');
- shortenedErrorStack = errorStackLines.slice(0,
- Math.min(3, errorStackLines.length)).join('\n');
- }
- msg = shortenedErrorStack || error.toString();
- }
- const payload = {
- url,
- line,
- column,
- error,
- };
- reportingService.reporter(ERROR.TYPE, ERROR.CATEGORY.EXCEPTION,
- msg, payload);
- return true;
- };
-
- const catchErrors = function(opt_context) {
- const context = opt_context || window;
- context.onerror = onError.bind(null, context.onerror);
- context.addEventListener('unhandledrejection', e => {
- const msg = e.reason.message;
- const payload = {
- error: e.reason,
- };
- reportingService.reporter(ERROR.TYPE,
- ERROR.CATEGORY.EXCEPTION, msg, payload);
- });
- };
-
- catchErrors();
-
- // for testing
- return {catchErrors};
-}
-
-export function initPerformanceReporter(appContext) {
- const reportingService = appContext.reportingService;
- // PerformanceObserver interface is a browser API.
- if (window.PerformanceObserver) {
- const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
- // Safari doesn't support longtask yet
- if (supportedEntryTypes.includes('longtask')) {
- const catchLongJsTasks = new PerformanceObserver(list => {
- for (const task of list.getEntries()) {
- // We are interested in longtask longer than 200 ms (default is 50 ms)
- if (task.duration > 200) {
- reportingService.reporter(TIMING.TYPE,
- TIMING.CATEGORY.UI_LATENCY, `Task ${task.name}`,
- Math.round(task.duration), {}, false);
- }
- }
- });
- catchLongJsTasks.observe({entryTypes: ['longtask']});
- }
- }
-}
-
-export function initVisibilityReporter(appContext) {
- const reportingService = appContext.reportingService;
- document.addEventListener('visibilitychange', () => {
- reportingService.onVisibilityChange();
- });
-}
-
-// Calculates the time of Gerrit being in a background tab. When Gerrit reports
-// a pageLoad metric it’s attached to its details for latency analysis.
-// It resets on locationChange.
-class HiddenDurationTimer {
- constructor() {
- this.reset();
- }
-
- reset() {
- this.accHiddenDurationMs = 0;
- this.lastVisibleTimestampMs = 0;
- }
-
- onVisibilityChange() {
- if (document.visibilityState === 'hidden') {
- this.lastVisibleTimestampMs = now();
- } else if (document.visibilityState === 'visible') {
- if (this.lastVisibleTimestampMs !== null) {
- this.accHiddenDurationMs += now() - this.lastVisibleTimestampMs;
- // Set to null for guarding against two 'visible' events in a row.
- this.lastVisibleTimestampMs = null;
- }
- }
- }
-
- get hiddenDurationMs() {
- if (document.visibilityState === 'hidden'
- && this.lastVisibleTimestampMs !== null) {
- return this.accHiddenDurationMs + now() - this.lastVisibleTimestampMs;
- }
- return this.accHiddenDurationMs;
- }
-}
-
-export function now() {
- return Math.round(window.performance.now());
-}
-
-export class GrReporting {
- constructor(flagsService) {
- this._flagsService = flagsService;
- this._baselines = STARTUP_TIMERS;
- this._timers = {
- timeBetweenDraftActions: null,
- };
- this._reportRepoName = undefined;
- this._pending = [];
- this._slowRpcList = [];
- this.hiddenDurationTimer = new HiddenDurationTimer();
- }
-
- get performanceTiming() {
- return window.performance.timing;
- }
-
- get slowRpcSnapshot() {
- return (this._slowRpcList || []).slice();
- }
-
- _arePluginsLoaded() {
- return this._baselines &&
- !this._baselines.hasOwnProperty(TIMER.PLUGINS_LOADED);
- }
-
- _isMetricsPluginLoaded() {
- return this._arePluginsLoaded() || this._baselines &&
- !this._baselines.hasOwnProperty(TIMER.METRICS_PLUGIN_LOADED);
- }
-
- /**
- * Reporter reports events. Events will be queued if metrics plugin is not
- * yet installed.
- *
- * @param {string} type
- * @param {string} category
- * @param {string} eventName
- * @param {string|number} eventValue
- * @param {Object} eventDetails
- * @param {boolean|undefined} opt_noLog If true, the event will not be
- * logged to the JS console.
- */
- reporter(type, category, eventName, eventValue, eventDetails, opt_noLog) {
- const eventInfo = this._createEventInfo(type, category,
- eventName, eventValue, eventDetails);
- if (type === ERROR.TYPE && category === ERROR.CATEGORY.EXCEPTION) {
- console.error(eventValue && eventValue.error || eventName);
- }
-
- // We report events immediately when metrics plugin is loaded
- if (this._isMetricsPluginLoaded() && !this._pending.length) {
- this._reportEvent(eventInfo, opt_noLog);
- } else {
- // We cache until metrics plugin is loaded
- this._pending.push([eventInfo, opt_noLog]);
- if (this._isMetricsPluginLoaded()) {
- this._pending.forEach(([eventInfo, opt_noLog]) => {
- this._reportEvent(eventInfo, opt_noLog);
- });
- this._pending = [];
- }
- }
- }
-
- _reportEvent(eventInfo, opt_noLog) {
- const {type, value, name} = eventInfo;
- document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
- if (opt_noLog) { return; }
- if (type !== ERROR.TYPE) {
- if (value !== undefined) {
- console.log(`Reporting: ${name}: ${value}`);
- } else {
- console.log(`Reporting: ${name}`);
- }
- }
- }
-
- _createEventInfo(type, category, name, value, eventDetails) {
- const eventInfo = {
- type,
- category,
- name,
- value,
- eventStart: now(),
- };
-
- if (typeof(eventDetails) === 'object' &&
- Object.entries(eventDetails).length !== 0) {
- eventInfo.eventDetails = JSON.stringify(eventDetails);
- }
-
- if (this._reportRepoName) {
- eventInfo.repoName = this._reportRepoName;
- }
-
- const isInBackgroundTab = document.visibilityState === 'hidden';
- if (isInBackgroundTab !== undefined) {
- eventInfo.inBackgroundTab = isInBackgroundTab;
- }
-
- if (this._flagsService.enabledExperiments.length) {
- eventInfo.enabledExperiments =
- JSON.stringify(this._flagsService.enabledExperiments);
- }
-
- return eventInfo;
- }
-
- /**
- * User-perceived app start time, should be reported when the app is ready.
- */
- appStarted() {
- this.timeEnd(TIMING.EVENT.APP_STARTED);
- this._reportNavResTimes();
- }
-
- onVisibilityChange() {
- this.hiddenDurationTimer.onVisibilityChange();
- const eventName = `Visibility changed to ${document.visibilityState}`;
- this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.VISIBILITY,
- eventName, undefined, {
- hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
- }, true);
- }
-
- /**
- * Browser's navigation and resource timings
- */
- _reportNavResTimes() {
- const perfEvents = Object.keys(this.performanceTiming.toJSON());
- perfEvents.forEach(
- eventName => this._reportPerformanceTiming(eventName)
- );
- }
-
- _reportPerformanceTiming(eventName, eventDetails) {
- const eventTiming = this.performanceTiming[eventName];
- if (eventTiming > 0) {
- const elapsedTime = eventTiming -
- this.performanceTiming.navigationStart;
- // NavResTime - Navigation and resource timings.
- this.reporter(TIMING.TYPE, TIMING.CATEGORY.UI_LATENCY,
- `NavResTime - ${eventName}`, elapsedTime, eventDetails, true);
- }
- }
-
- beforeLocationChanged() {
- for (const prop of Object.keys(this._baselines)) {
- delete this._baselines[prop];
- }
- this.time(TIMER.CHANGE_DISPLAYED);
- this.time(TIMER.CHANGE_LOAD_FULL);
- this.time(TIMER.DASHBOARD_DISPLAYED);
- this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
- this.time(TIMER.DIFF_VIEW_DISPLAYED);
- this.time(TIMER.DIFF_VIEW_LOAD_FULL);
- this.time(TIMER.FILE_LIST_DISPLAYED);
- this._reportRepoName = undefined;
- // reset slow rpc list since here start page loads which report these rpcs
- this._slowRpcList = [];
- this.hiddenDurationTimer.reset();
- }
-
- locationChanged(page) {
- this.reporter(NAVIGATION.TYPE, NAVIGATION.CATEGORY.LOCATION_CHANGED,
- NAVIGATION.EVENT.PAGE, page);
- }
-
- dashboardDisplayed() {
- if (this._baselines.hasOwnProperty(TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
- this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED, this._pageLoadDetails());
- } else {
- this.timeEnd(TIMER.DASHBOARD_DISPLAYED, this._pageLoadDetails());
- }
- }
-
- changeDisplayed() {
- if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_DISPLAYED)) {
- this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
- } else {
- this.timeEnd(TIMER.CHANGE_DISPLAYED, this._pageLoadDetails());
- }
- }
-
- changeFullyLoaded() {
- if (this._baselines.hasOwnProperty(TIMER.STARTUP_CHANGE_LOAD_FULL)) {
- this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL);
- } else {
- this.timeEnd(TIMER.CHANGE_LOAD_FULL);
- }
- }
-
- diffViewDisplayed() {
- if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) {
- this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
- } else {
- this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
- }
- }
-
- diffViewFullyLoaded() {
- if (this._baselines.hasOwnProperty(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
- this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
- } else {
- this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
- }
- }
-
- diffViewContentDisplayed() {
- if (this._baselines.hasOwnProperty(
- TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)) {
- this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
- } else {
- this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
- }
- }
-
- fileListDisplayed() {
- if (this._baselines.hasOwnProperty(TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
- this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
- } else {
- this.timeEnd(TIMER.FILE_LIST_DISPLAYED);
- }
- }
-
- _pageLoadDetails() {
- const details = {
- rpcList: this.slowRpcSnapshot,
- };
-
- if (window.screen) {
- details.screenSize = {
- width: window.screen.width,
- height: window.screen.height,
- };
- }
-
- if (document && document.documentElement) {
- details.viewport = {
- width: document.documentElement.clientWidth,
- height: document.documentElement.clientHeight,
- };
- }
-
- if (window.performance && window.performance.memory) {
- const toMb = bytes => Math.round((bytes / (1024 * 1024)) * 100) / 100;
- details.usedJSHeapSizeMb =
- toMb(window.performance.memory.usedJSHeapSize);
- }
-
- details.hiddenDurationMs = this.hiddenDurationTimer.hiddenDurationMs;
- return details;
- }
-
- reportExtension(name) {
- this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.EXTENSION_DETECTED, name);
- }
-
- pluginLoaded(name) {
- if (name.startsWith('metrics-')) {
- this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
- }
- }
-
- pluginsLoaded(pluginsList) {
- this.timeEnd(TIMER.PLUGINS_LOADED);
- this.reporter(
- LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
- LIFECYCLE.CATEGORY.PLUGINS_INSTALLED, undefined,
- {pluginsList: pluginsList || []}, true);
- }
-
- /**
- * Reset named timer.
- */
- time(name) {
- this._baselines[name] = now();
- window.performance.mark(`${name}-start`);
- }
-
- /**
- * Finish named timer and report it to server.
- */
- timeEnd(name, eventDetails) {
- if (!this._baselines.hasOwnProperty(name)) { return; }
- const baseTime = this._baselines[name];
- delete this._baselines[name];
- this._reportTiming(name, now() - baseTime, eventDetails);
-
- // Finalize the interval. Either from a registered start mark or
- // the navigation start time (if baseTime is 0).
- if (baseTime !== 0) {
- window.performance.measure(name, `${name}-start`);
- } else {
- // Microsft Edge does not handle the 2nd param correctly
- // (if undefined).
- window.performance.measure(name);
- }
- }
-
- /**
- * Reports just line timeEnd, but additionally reports an average given a
- * denominator and a separate reporiting name for the average.
- *
- * @param {string} name Timing name.
- * @param {string} averageName Average timing name.
- * @param {number} denominator Number by which to divide the total to
- * compute the average.
- */
- timeEndWithAverage(name, averageName, denominator) {
- if (!this._baselines.hasOwnProperty(name)) { return; }
- const baseTime = this._baselines[name];
- this.timeEnd(name);
-
- // Guard against division by zero.
- if (!denominator) { return; }
- const time = now() - baseTime;
- this._reportTiming(averageName, time / denominator);
- }
-
- /**
- * Send a timing report with an arbitrary time value.
- *
- * @param {string} name Timing name.
- * @param {number} time The time to report as an integer of milliseconds.
- * @param {Object} eventDetails non sensitive details
- */
- _reportTiming(name, time, eventDetails) {
- this.reporter(TIMING.TYPE, TIMING.CATEGORY.UI_LATENCY, name, time,
- eventDetails);
- }
-
- /**
- * Get a timer object to for reporing a user timing. The start time will be
- * the time that the object has been created, and the end time will be the
- * time that the "end" method is called on the object.
- *
- * @param {string} name Timing name.
- * @returns {!Object} The timer object.
- */
- getTimer(name) {
- let called = false;
- let start;
- let max = null;
-
- const timer = {
-
- // Clear the timer and reset the start time.
- reset: () => {
- called = false;
- start = now();
- return timer;
- },
-
- // Stop the timer and report the intervening time.
- end: () => {
- if (called) {
- throw new Error(`Timer for "${name}" already ended.`);
- }
- called = true;
- const time = now() - start;
-
- // If a maximum is specified and the time exceeds it, do not report.
- if (max && time > max) { return timer; }
-
- this._reportTiming(name, time);
- return timer;
- },
-
- // Set a maximum reportable time. If a maximum is set and the timer is
- // ended after the specified amount of time, the value is not reported.
- withMaximum(maximum) {
- max = maximum;
- return timer;
- },
- };
-
- // The timer is initialized to its creation time.
- return timer.reset();
- }
-
- /**
- * Log timing information for an RPC.
- *
- * @param {string} anonymizedUrl The URL of the RPC with tokens obfuscated.
- * @param {number} elapsed The time elapsed of the RPC.
- */
- reportRpcTiming(anonymizedUrl, elapsed) {
- this.reporter(TIMING.TYPE, TIMING.CATEGORY.RPC, 'RPC-' + anonymizedUrl,
- elapsed, {}, true);
- if (elapsed >= SLOW_RPC_THRESHOLD) {
- this._slowRpcList.push({anonymizedUrl, elapsed});
- }
- }
-
- reportLifeCycle(eventName, details) {
- this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.DEFAULT, eventName,
- undefined, details, true);
- }
-
- reportInteraction(eventName, details) {
- this.reporter(INTERACTION.TYPE, INTERACTION.CATEGORY.DEFAULT, eventName,
- undefined, details, true);
- }
-
- /**
- * A draft interaction was started. Update the time-betweeen-draft-actions
- * timer.
- */
- recordDraftInteraction() {
- // If there is no timer defined, then this is the first interaction.
- // Set up the timer so that it's ready to record the intervening time when
- // called again.
- const timer = this._timers.timeBetweenDraftActions;
- if (!timer) {
- // Create a timer with a maximum length.
- this._timers.timeBetweenDraftActions = this.getTimer(DRAFT_ACTION_TIMER)
- .withMaximum(DRAFT_ACTION_TIMER_MAX);
- return;
- }
-
- // Mark the time and reinitialize the timer.
- timer.end().reset();
- }
-
- reportErrorDialog(message) {
- this.reporter(ERROR.TYPE, ERROR.CATEGORY.ERROR_DIALOG,
- 'ErrorDialog: ' + message, {error: new Error(message)});
- }
-
- setRepoName(repoName) {
- this._reportRepoName = repoName;
- }
-}
-
-export const DEFAULT_STARTUP_TIMERS = Object.assign({}, STARTUP_TIMERS);
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
new file mode 100644
index 0000000..e6139e5
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -0,0 +1,101 @@
+/**
+ * @license
+ * 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.
+ */
+
+export type EventValue = string | number | {error?: Error};
+
+// TODO(dmfilippov): TS-fix-any use more specific type instead if possible
+export type EventDetails = any;
+
+export const PORTING_COMMENTS_DIFF_LATENCY_LABEL = 'PortingCommentsDiffLatency';
+export const PORTING_COMMENTS_CHANGE_LATENCY_LABEL =
+ 'PortingCommentsChangeLatency';
+
+export interface Timer {
+ reset(): this;
+ end(): this;
+ withMaximum(maximum: number): this;
+}
+
+export interface ReportingService {
+ reporter(
+ type: string,
+ category: string,
+ eventName: string,
+ eventValue?: EventValue,
+ eventDetails?: EventDetails,
+ opt_noLog?: boolean
+ ): void;
+
+ appStarted(): void;
+ onVisibilityChange(): void;
+ beforeLocationChanged(): void;
+ locationChanged(page: string): void;
+ dashboardDisplayed(): void;
+ changeDisplayed(): void;
+ changeFullyLoaded(): void;
+ diffViewDisplayed(): void;
+ diffViewFullyLoaded(): void;
+ diffViewContentDisplayed(): void;
+ fileListDisplayed(): void;
+ reportExtension(name: string): void;
+ pluginLoaded(name: string): void;
+ pluginsLoaded(pluginsList?: string[]): void;
+ /**
+ * Reset named timer.
+ */
+ time(name: string): void;
+ /**
+ * Finish named timer and report it to server.
+ */
+ timeEnd(name: string, eventDetails?: EventDetails): void;
+ /**
+ * Reports just line timeEnd, but additionally reports an average given a
+ * denominator and a separate reporiting name for the average.
+ *
+ * @param name Timing name.
+ * @param averageName Average timing name.
+ * @param denominator Number by which to divide the total to
+ * compute the average.
+ */
+ timeEndWithAverage(
+ name: string,
+ averageName: string,
+ denominator: number
+ ): void;
+ /**
+ * Get a timer object to for reporing a user timing. The start time will be
+ * the time that the object has been created, and the end time will be the
+ * time that the "end" method is called on the object.
+ */
+ getTimer(name: string): Timer;
+ /**
+ * Log timing information for an RPC.
+ *
+ * @param anonymizedUrl The URL of the RPC with tokens obfuscated.
+ * @param elapsed The time elapsed of the RPC.
+ */
+ reportRpcTiming(anonymizedUrl: string, elapsed: number): void;
+ reportLifeCycle(eventName: string, details?: EventDetails): void;
+ reportInteraction(eventName: string, details?: EventDetails): void;
+ /**
+ * A draft interaction was started. Update the time-betweeen-draft-actions
+ * timer.
+ */
+ recordDraftInteraction(): void;
+ reportErrorDialog(message: string): void;
+ setRepoName(repoName: string): void;
+}
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
new file mode 100644
index 0000000..f3aacdf
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -0,0 +1,823 @@
+/**
+ * @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 {AppContext} from '../app-context';
+import {FlagsService} from '../flags/flags';
+import {
+ EventDetails,
+ EventValue,
+ ReportingService,
+ Timer,
+} from './gr-reporting';
+import {hasOwnProperty} from '../../utils/common-util';
+
+// Latency reporting constants.
+
+const TIMING = {
+ TYPE: 'timing-report',
+ CATEGORY: {
+ UI_LATENCY: 'UI Latency',
+ RPC: 'RPC Timing',
+ },
+ EVENT: {
+ APP_STARTED: 'App Started',
+ },
+};
+
+const LIFECYCLE = {
+ TYPE: 'lifecycle',
+ CATEGORY: {
+ DEFAULT: 'Default',
+ EXTENSION_DETECTED: 'Extension detected',
+ PLUGINS_INSTALLED: 'Plugins installed',
+ VISIBILITY: 'Visibility',
+ },
+};
+
+const INTERACTION = {
+ TYPE: 'interaction',
+ CATEGORY: {
+ DEFAULT: 'Default',
+ VISIBILITY: 'Visibility',
+ },
+};
+
+const NAVIGATION = {
+ TYPE: 'nav-report',
+ CATEGORY: {
+ LOCATION_CHANGED: 'Location Changed',
+ },
+ EVENT: {
+ PAGE: 'Page',
+ },
+};
+
+const ERROR = {
+ TYPE: 'error',
+ CATEGORY: {
+ EXCEPTION: 'exception',
+ ERROR_DIALOG: 'Error Dialog',
+ },
+};
+
+const TIMER = {
+ CHANGE_DISPLAYED: 'ChangeDisplayed',
+ CHANGE_LOAD_FULL: 'ChangeFullyLoaded',
+ DASHBOARD_DISPLAYED: 'DashboardDisplayed',
+ DIFF_VIEW_CONTENT_DISPLAYED: 'DiffViewOnlyContent',
+ DIFF_VIEW_DISPLAYED: 'DiffViewDisplayed',
+ DIFF_VIEW_LOAD_FULL: 'DiffViewFullyLoaded',
+ FILE_LIST_DISPLAYED: 'FileListDisplayed',
+ PLUGINS_LOADED: 'PluginsLoaded',
+ STARTUP_CHANGE_DISPLAYED: 'StartupChangeDisplayed',
+ STARTUP_CHANGE_LOAD_FULL: 'StartupChangeFullyLoaded',
+ STARTUP_DASHBOARD_DISPLAYED: 'StartupDashboardDisplayed',
+ STARTUP_DIFF_VIEW_CONTENT_DISPLAYED: 'StartupDiffViewOnlyContent',
+ STARTUP_DIFF_VIEW_DISPLAYED: 'StartupDiffViewDisplayed',
+ STARTUP_DIFF_VIEW_LOAD_FULL: 'StartupDiffViewFullyLoaded',
+ STARTUP_FILE_LIST_DISPLAYED: 'StartupFileListDisplayed',
+ WEB_COMPONENTS_READY: 'WebComponentsReady',
+ METRICS_PLUGIN_LOADED: 'MetricsPluginLoaded',
+};
+
+const STARTUP_TIMERS = {
+ [TIMER.PLUGINS_LOADED]: 0,
+ [TIMER.METRICS_PLUGIN_LOADED]: 0,
+ [TIMER.STARTUP_CHANGE_DISPLAYED]: 0,
+ [TIMER.STARTUP_CHANGE_LOAD_FULL]: 0,
+ [TIMER.STARTUP_DASHBOARD_DISPLAYED]: 0,
+ [TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED]: 0,
+ [TIMER.STARTUP_DIFF_VIEW_DISPLAYED]: 0,
+ [TIMER.STARTUP_DIFF_VIEW_LOAD_FULL]: 0,
+ [TIMER.STARTUP_FILE_LIST_DISPLAYED]: 0,
+ [TIMING.EVENT.APP_STARTED]: 0,
+ // WebComponentsReady timer is triggered from gr-router.
+ [TIMER.WEB_COMPONENTS_READY]: 0,
+};
+
+const DRAFT_ACTION_TIMER = 'TimeBetweenDraftActions';
+const DRAFT_ACTION_TIMER_MAX = 2 * 60 * 1000; // 2 minutes.
+const SLOW_RPC_THRESHOLD = 500;
+
+export function initErrorReporter(appContext: AppContext) {
+ const reportingService = appContext.reportingService;
+ // TODO(dmfilippo): TS-fix-any oldOnError - define correct type
+ const onError = function (
+ oldOnError: Function,
+ msg: Event | string,
+ url?: string,
+ line?: number,
+ column?: number,
+ error?: Error
+ ) {
+ if (oldOnError) {
+ oldOnError(msg, url, line, column, error);
+ }
+ if (error) {
+ line = line || error.lineNumber;
+ column = column || error.columnNumber;
+ let shortenedErrorStack = msg;
+ if (error.stack) {
+ const errorStackLines = error.stack.split('\n');
+ shortenedErrorStack = errorStackLines
+ .slice(0, Math.min(3, errorStackLines.length))
+ .join('\n');
+ }
+ msg = shortenedErrorStack || error.toString();
+ }
+ const payload = {
+ url,
+ line,
+ column,
+ error,
+ };
+ reportingService.reporter(
+ ERROR.TYPE,
+ ERROR.CATEGORY.EXCEPTION,
+ `${msg}`,
+ payload
+ );
+ return true;
+ };
+ // TODO(dmfilippov): TS-fix-any unclear what is context
+ const catchErrors = function (opt_context?: any) {
+ const context = opt_context || window;
+ const oldOnError = context.onerror;
+ context.onerror = (
+ event: Event | string,
+ source?: string,
+ lineno?: number,
+ colno?: number,
+ error?: Error
+ ) => {
+ return onError(oldOnError, event, source, lineno, colno, error);
+ };
+ context.addEventListener(
+ 'unhandledrejection',
+ (e: PromiseRejectionEvent) => {
+ const msg = e.reason.message;
+ const payload = {
+ error: e.reason,
+ };
+ reportingService.reporter(
+ ERROR.TYPE,
+ ERROR.CATEGORY.EXCEPTION,
+ msg,
+ payload
+ );
+ }
+ );
+ };
+
+ catchErrors();
+
+ // for testing
+ return {catchErrors};
+}
+
+export function initPerformanceReporter(appContext: AppContext) {
+ const reportingService = appContext.reportingService;
+ // PerformanceObserver interface is a browser API.
+ if (window.PerformanceObserver) {
+ const supportedEntryTypes = PerformanceObserver.supportedEntryTypes || [];
+ // Safari doesn't support longtask yet
+ if (supportedEntryTypes.includes('longtask')) {
+ const catchLongJsTasks = new PerformanceObserver(list => {
+ for (const task of list.getEntries()) {
+ // We are interested in longtask longer than 200 ms (default is 50 ms)
+ if (task.duration > 200) {
+ reportingService.reporter(
+ TIMING.TYPE,
+ TIMING.CATEGORY.UI_LATENCY,
+ `Task ${task.name}`,
+ Math.round(task.duration),
+ {},
+ false
+ );
+ }
+ }
+ });
+ catchLongJsTasks.observe({entryTypes: ['longtask']});
+ }
+ }
+}
+
+export function initVisibilityReporter(appContext: AppContext) {
+ const reportingService = appContext.reportingService;
+ document.addEventListener('visibilitychange', () => {
+ reportingService.onVisibilityChange();
+ });
+}
+
+// Calculates the time of Gerrit being in a background tab. When Gerrit reports
+// a pageLoad metric it’s attached to its details for latency analysis.
+// It resets on locationChange.
+class HiddenDurationTimer {
+ public accHiddenDurationMs = 0;
+
+ public lastVisibleTimestampMs: number | null = null;
+
+ constructor() {
+ this.reset();
+ }
+
+ reset() {
+ this.accHiddenDurationMs = 0;
+ this.lastVisibleTimestampMs = 0;
+ }
+
+ onVisibilityChange() {
+ if (document.visibilityState === 'hidden') {
+ this.lastVisibleTimestampMs = now();
+ } else if (document.visibilityState === 'visible') {
+ if (this.lastVisibleTimestampMs !== null) {
+ this.accHiddenDurationMs += now() - this.lastVisibleTimestampMs;
+ // Set to null for guarding against two 'visible' events in a row.
+ this.lastVisibleTimestampMs = null;
+ }
+ }
+ }
+
+ get hiddenDurationMs() {
+ if (
+ document.visibilityState === 'hidden' &&
+ this.lastVisibleTimestampMs !== null
+ ) {
+ return this.accHiddenDurationMs + now() - this.lastVisibleTimestampMs;
+ }
+ return this.accHiddenDurationMs;
+ }
+}
+
+export function now() {
+ return Math.round(window.performance.now());
+}
+
+type PeformanceTimingEventName = keyof Omit<PerformanceTiming, 'toJSON'>;
+
+interface EventInfo {
+ type: string;
+ category: string;
+ name: string;
+ value?: EventValue;
+ eventStart: number;
+ eventDetails?: string;
+ repoName?: string;
+ inBackgroundTab?: boolean;
+ enabledExperiments?: string;
+}
+
+interface PageLoadDetails {
+ rpcList: SlowRpcCall[];
+ hiddenDurationMs: number;
+ screenSize?: {width: number; height: number};
+ viewport?: {width: number; height: number};
+ usedJSHeapSizeMb?: number;
+}
+
+interface SlowRpcCall {
+ anonymizedUrl: string;
+ elapsed: number;
+}
+
+type PendingReportInfo = [EventInfo, boolean | undefined];
+
+export class GrReporting implements ReportingService {
+ private readonly _flagsService: FlagsService;
+
+ private readonly _baselines = STARTUP_TIMERS;
+
+ private _reportRepoName: string | undefined;
+
+ private _timers: {timeBetweenDraftActions: Timer | null} = {
+ timeBetweenDraftActions: null,
+ };
+
+ private _pending: PendingReportInfo[] = [];
+
+ private _slowRpcList: SlowRpcCall[] = [];
+
+ public readonly hiddenDurationTimer = new HiddenDurationTimer();
+
+ constructor(flagsService: FlagsService) {
+ this._flagsService = flagsService;
+ }
+
+ private get performanceTiming() {
+ return window.performance.timing;
+ }
+
+ private get slowRpcSnapshot() {
+ return (this._slowRpcList || []).slice();
+ }
+
+ private _arePluginsLoaded() {
+ return (
+ this._baselines && !hasOwnProperty(this._baselines, TIMER.PLUGINS_LOADED)
+ );
+ }
+
+ private _isMetricsPluginLoaded() {
+ return (
+ this._arePluginsLoaded() ||
+ (this._baselines &&
+ !hasOwnProperty(this._baselines, TIMER.METRICS_PLUGIN_LOADED))
+ );
+ }
+
+ /**
+ * Reporter reports events. Events will be queued if metrics plugin is not
+ * yet installed.
+ *
+ * @param noLog If true, the event will not be logged to the JS console.
+ */
+ reporter(
+ type: string,
+ category: string,
+ eventName: string,
+ eventValue?: EventValue,
+ eventDetails?: EventDetails,
+ noLog?: boolean
+ ) {
+ const eventInfo = this._createEventInfo(
+ type,
+ category,
+ eventName,
+ eventValue,
+ eventDetails
+ );
+ if (type === ERROR.TYPE && category === ERROR.CATEGORY.EXCEPTION) {
+ console.error((eventValue && (eventValue as any).error) || eventName);
+ }
+
+ // We report events immediately when metrics plugin is loaded
+ if (this._isMetricsPluginLoaded() && !this._pending.length) {
+ this._reportEvent(eventInfo, noLog);
+ } else {
+ // We cache until metrics plugin is loaded
+ this._pending.push([eventInfo, noLog]);
+ if (this._isMetricsPluginLoaded()) {
+ this._pending.forEach(([eventInfo, opt_noLog]) => {
+ this._reportEvent(eventInfo, opt_noLog);
+ });
+ this._pending = [];
+ }
+ }
+ }
+
+ private _reportEvent(eventInfo: EventInfo, opt_noLog?: boolean) {
+ const {type, value, name} = eventInfo;
+ document.dispatchEvent(new CustomEvent(type, {detail: eventInfo}));
+ if (opt_noLog) {
+ return;
+ }
+ if (type !== ERROR.TYPE) {
+ if (value !== undefined) {
+ console.info(`Reporting: ${name}: ${value}`);
+ } else {
+ console.info(`Reporting: ${name}`);
+ }
+ }
+ }
+
+ private _createEventInfo(
+ type: string,
+ category: string,
+ name: string,
+ value?: EventValue,
+ eventDetails?: EventDetails
+ ): EventInfo {
+ const eventInfo: EventInfo = {
+ type,
+ category,
+ name,
+ value,
+ eventStart: now(),
+ };
+
+ if (
+ typeof eventDetails === 'object' &&
+ Object.entries(eventDetails).length !== 0
+ ) {
+ eventInfo.eventDetails = JSON.stringify(eventDetails);
+ }
+
+ if (this._reportRepoName) {
+ eventInfo.repoName = this._reportRepoName;
+ }
+
+ const isInBackgroundTab = document.visibilityState === 'hidden';
+ if (isInBackgroundTab !== undefined) {
+ eventInfo.inBackgroundTab = isInBackgroundTab;
+ }
+
+ if (this._flagsService.enabledExperiments.length) {
+ eventInfo.enabledExperiments = JSON.stringify(
+ this._flagsService.enabledExperiments
+ );
+ }
+
+ return eventInfo;
+ }
+
+ /**
+ * User-perceived app start time, should be reported when the app is ready.
+ */
+ appStarted() {
+ this.timeEnd(TIMING.EVENT.APP_STARTED);
+ this._reportNavResTimes();
+ }
+
+ onVisibilityChange() {
+ this.hiddenDurationTimer.onVisibilityChange();
+ const eventName = `Visibility changed to ${document.visibilityState}`;
+ this.reporter(
+ LIFECYCLE.TYPE,
+ LIFECYCLE.CATEGORY.VISIBILITY,
+ eventName,
+ undefined,
+ {
+ hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
+ },
+ true
+ );
+ }
+
+ /**
+ * Browser's navigation and resource timings
+ */
+ private _reportNavResTimes() {
+ const perfEvents = Object.keys(this.performanceTiming.toJSON());
+ perfEvents.forEach(eventName =>
+ this._reportPerformanceTiming(eventName as PeformanceTimingEventName)
+ );
+ }
+
+ private _reportPerformanceTiming(
+ eventName: PeformanceTimingEventName,
+ eventDetails?: EventDetails
+ ) {
+ const eventTiming = this.performanceTiming[eventName];
+ if (eventTiming > 0) {
+ const elapsedTime = eventTiming - this.performanceTiming.navigationStart;
+ // NavResTime - Navigation and resource timings.
+ this.reporter(
+ TIMING.TYPE,
+ TIMING.CATEGORY.UI_LATENCY,
+ `NavResTime - ${eventName}`,
+ elapsedTime,
+ eventDetails,
+ true
+ );
+ }
+ }
+
+ beforeLocationChanged() {
+ for (const prop of Object.keys(this._baselines)) {
+ delete this._baselines[prop];
+ }
+ this.time(TIMER.CHANGE_DISPLAYED);
+ this.time(TIMER.CHANGE_LOAD_FULL);
+ this.time(TIMER.DASHBOARD_DISPLAYED);
+ this.time(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+ this.time(TIMER.DIFF_VIEW_DISPLAYED);
+ this.time(TIMER.DIFF_VIEW_LOAD_FULL);
+ this.time(TIMER.FILE_LIST_DISPLAYED);
+ this._reportRepoName = undefined;
+ // reset slow rpc list since here start page loads which report these rpcs
+ this._slowRpcList = [];
+ this.hiddenDurationTimer.reset();
+ }
+
+ locationChanged(page: string) {
+ this.reporter(
+ NAVIGATION.TYPE,
+ NAVIGATION.CATEGORY.LOCATION_CHANGED,
+ NAVIGATION.EVENT.PAGE,
+ page
+ );
+ }
+
+ dashboardDisplayed() {
+ if (hasOwnProperty(this._baselines, TIMER.STARTUP_DASHBOARD_DISPLAYED)) {
+ this.timeEnd(TIMER.STARTUP_DASHBOARD_DISPLAYED, this._pageLoadDetails());
+ } else {
+ this.timeEnd(TIMER.DASHBOARD_DISPLAYED, this._pageLoadDetails());
+ }
+ }
+
+ changeDisplayed() {
+ if (hasOwnProperty(this._baselines, TIMER.STARTUP_CHANGE_DISPLAYED)) {
+ this.timeEnd(TIMER.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
+ } else {
+ this.timeEnd(TIMER.CHANGE_DISPLAYED, this._pageLoadDetails());
+ }
+ }
+
+ changeFullyLoaded() {
+ if (hasOwnProperty(this._baselines, TIMER.STARTUP_CHANGE_LOAD_FULL)) {
+ this.timeEnd(TIMER.STARTUP_CHANGE_LOAD_FULL);
+ } else {
+ this.timeEnd(TIMER.CHANGE_LOAD_FULL);
+ }
+ }
+
+ diffViewDisplayed() {
+ if (hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_DISPLAYED)) {
+ this.timeEnd(TIMER.STARTUP_DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
+ } else {
+ this.timeEnd(TIMER.DIFF_VIEW_DISPLAYED, this._pageLoadDetails());
+ }
+ }
+
+ diffViewFullyLoaded() {
+ if (hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_LOAD_FULL)) {
+ this.timeEnd(TIMER.STARTUP_DIFF_VIEW_LOAD_FULL);
+ } else {
+ this.timeEnd(TIMER.DIFF_VIEW_LOAD_FULL);
+ }
+ }
+
+ diffViewContentDisplayed() {
+ if (
+ hasOwnProperty(this._baselines, TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED)
+ ) {
+ this.timeEnd(TIMER.STARTUP_DIFF_VIEW_CONTENT_DISPLAYED);
+ } else {
+ this.timeEnd(TIMER.DIFF_VIEW_CONTENT_DISPLAYED);
+ }
+ }
+
+ fileListDisplayed() {
+ if (hasOwnProperty(this._baselines, TIMER.STARTUP_FILE_LIST_DISPLAYED)) {
+ this.timeEnd(TIMER.STARTUP_FILE_LIST_DISPLAYED);
+ } else {
+ this.timeEnd(TIMER.FILE_LIST_DISPLAYED);
+ }
+ }
+
+ private _pageLoadDetails(): PageLoadDetails {
+ const details: PageLoadDetails = {
+ rpcList: this.slowRpcSnapshot,
+ hiddenDurationMs: this.hiddenDurationTimer.accHiddenDurationMs,
+ };
+
+ if (window.screen) {
+ details.screenSize = {
+ width: window.screen.width,
+ height: window.screen.height,
+ };
+ }
+
+ if (document?.documentElement) {
+ details.viewport = {
+ width: document.documentElement.clientWidth,
+ height: document.documentElement.clientHeight,
+ };
+ }
+
+ if (window.performance?.memory) {
+ const toMb = (bytes: number) =>
+ Math.round((bytes / (1024 * 1024)) * 100) / 100;
+ details.usedJSHeapSizeMb = toMb(window.performance.memory.usedJSHeapSize);
+ }
+
+ details.hiddenDurationMs = this.hiddenDurationTimer.hiddenDurationMs;
+ return details;
+ }
+
+ reportExtension(name: string) {
+ this.reporter(LIFECYCLE.TYPE, LIFECYCLE.CATEGORY.EXTENSION_DETECTED, name);
+ }
+
+ pluginLoaded(name: string) {
+ if (name.startsWith('metrics-')) {
+ this.timeEnd(TIMER.METRICS_PLUGIN_LOADED);
+ }
+ }
+
+ pluginsLoaded(pluginsList?: string[]) {
+ this.timeEnd(TIMER.PLUGINS_LOADED);
+ this.reporter(
+ LIFECYCLE.TYPE,
+ LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
+ LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
+ undefined,
+ {pluginsList: pluginsList || []},
+ true
+ );
+ }
+
+ /**
+ * Reset named timer.
+ */
+ time(name: string) {
+ this._baselines[name] = now();
+ window.performance.mark(`${name}-start`);
+ }
+
+ /**
+ * Finish named timer and report it to server.
+ */
+ timeEnd(name: string, eventDetails?: EventDetails) {
+ if (!hasOwnProperty(this._baselines, name)) {
+ return;
+ }
+ const baseTime = this._baselines[name];
+ delete this._baselines[name];
+ this._reportTiming(name, now() - baseTime, eventDetails);
+
+ // Finalize the interval. Either from a registered start mark or
+ // the navigation start time (if baseTime is 0).
+ if (baseTime !== 0) {
+ window.performance.measure(name, `${name}-start`);
+ } else {
+ // Microsft Edge does not handle the 2nd param correctly
+ // (if undefined).
+ window.performance.measure(name);
+ }
+ }
+
+ /**
+ * Reports just line timeEnd, but additionally reports an average given a
+ * denominator and a separate reporiting name for the average.
+ *
+ * @param name Timing name.
+ * @param averageName Average timing name.
+ * @param denominator Number by which to divide the total to
+ * compute the average.
+ */
+ timeEndWithAverage(name: string, averageName: string, denominator: number) {
+ if (!hasOwnProperty(this._baselines, name)) {
+ return;
+ }
+ const baseTime = this._baselines[name];
+ this.timeEnd(name);
+
+ // Guard against division by zero.
+ if (!denominator) {
+ return;
+ }
+ const time = now() - baseTime;
+ this._reportTiming(averageName, time / denominator);
+ }
+
+ /**
+ * Send a timing report with an arbitrary time value.
+ *
+ * @param name Timing name.
+ * @param time The time to report as an integer of milliseconds.
+ * @param eventDetails non sensitive details
+ */
+ private _reportTiming(
+ name: string,
+ time: number,
+ eventDetails?: EventDetails
+ ) {
+ this.reporter(
+ TIMING.TYPE,
+ TIMING.CATEGORY.UI_LATENCY,
+ name,
+ time,
+ eventDetails
+ );
+ }
+
+ /**
+ * Get a timer object to for reporing a user timing. The start time will be
+ * the time that the object has been created, and the end time will be the
+ * time that the "end" method is called on the object.
+ */
+ getTimer(name: string): Timer {
+ let called = false;
+ let start: number;
+ let max: number | null = null;
+
+ const timer: Timer = {
+ // Clear the timer and reset the start time.
+ reset: () => {
+ called = false;
+ start = now();
+ return timer;
+ },
+
+ // Stop the timer and report the intervening time.
+ end: () => {
+ if (called) {
+ throw new Error(`Timer for "${name}" already ended.`);
+ }
+ called = true;
+ const time = now() - start;
+
+ // If a maximum is specified and the time exceeds it, do not report.
+ if (max && time > max) {
+ return timer;
+ }
+
+ this._reportTiming(name, time);
+ return timer;
+ },
+
+ // Set a maximum reportable time. If a maximum is set and the timer is
+ // ended after the specified amount of time, the value is not reported.
+ withMaximum(maximum) {
+ max = maximum;
+ return timer;
+ },
+ };
+
+ // The timer is initialized to its creation time.
+ return timer.reset();
+ }
+
+ /**
+ * Log timing information for an RPC.
+ *
+ * @param anonymizedUrl The URL of the RPC with tokens obfuscated.
+ * @param elapsed The time elapsed of the RPC.
+ */
+ reportRpcTiming(anonymizedUrl: string, elapsed: number) {
+ this.reporter(
+ TIMING.TYPE,
+ TIMING.CATEGORY.RPC,
+ 'RPC-' + anonymizedUrl,
+ elapsed,
+ {},
+ true
+ );
+ if (elapsed >= SLOW_RPC_THRESHOLD) {
+ this._slowRpcList.push({anonymizedUrl, elapsed});
+ }
+ }
+
+ reportLifeCycle(eventName: string, details: EventDetails) {
+ this.reporter(
+ LIFECYCLE.TYPE,
+ LIFECYCLE.CATEGORY.DEFAULT,
+ eventName,
+ undefined,
+ details,
+ true
+ );
+ }
+
+ reportInteraction(eventName: string, details: EventDetails) {
+ this.reporter(
+ INTERACTION.TYPE,
+ INTERACTION.CATEGORY.DEFAULT,
+ eventName,
+ undefined,
+ details,
+ true
+ );
+ }
+
+ /**
+ * A draft interaction was started. Update the time-betweeen-draft-actions
+ * timer.
+ */
+ recordDraftInteraction() {
+ // If there is no timer defined, then this is the first interaction.
+ // Set up the timer so that it's ready to record the intervening time when
+ // called again.
+ const timer = this._timers.timeBetweenDraftActions;
+ if (!timer) {
+ // Create a timer with a maximum length.
+ this._timers.timeBetweenDraftActions = this.getTimer(
+ DRAFT_ACTION_TIMER
+ ).withMaximum(DRAFT_ACTION_TIMER_MAX);
+ return;
+ }
+
+ // Mark the time and reinitialize the timer.
+ timer.end().reset();
+ }
+
+ reportErrorDialog(message: string) {
+ this.reporter(
+ ERROR.TYPE,
+ ERROR.CATEGORY.ERROR_DIALOG,
+ 'ErrorDialog: ' + message,
+ {error: new Error(message)}
+ );
+ }
+
+ setRepoName(repoName: string) {
+ this._reportRepoName = repoName;
+ }
+}
+
+export const DEFAULT_STARTUP_TIMERS = {...STARTUP_TIMERS};
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.js
deleted file mode 100644
index 1ef2483..0000000
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.js
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * @license
- * 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.
- */
-export const grReportingMock = {
- appStarted: () => {},
- beforeLocationChanged: () => {},
- changeDisplayed: () => {},
- changeFullyLoaded: () => {},
- dashboardDisplayed: () => {},
- diffViewContentDisplayed: () => {},
- diffViewDisplayed: () => {},
- diffViewFullyLoaded: () => {},
- fileListDisplayed: () => {},
- getTimer: () => {
- return {end: () => {}};
- },
- locationChanged: () => {},
- onVisibilityChange: () => {},
- pluginLoaded: () => {},
- pluginsLoaded: () => {},
- recordDraftInteraction: () => {},
- reporter: () => {},
- reportErrorDialog: () => {},
- reportExtension: () => {},
- reportInteraction: () => {},
- reportLifeCycle: () => {},
- reportRpcTiming: () => {},
- setRepoName: () => {},
- time: () => {},
- timeEnd: () => {},
- timeEndWithAverage: () => {},
-};
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
new file mode 100644
index 0000000..924ddd9
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -0,0 +1,61 @@
+/**
+ * @license
+ * 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.
+ */
+import {ReportingService, Timer} from './gr-reporting';
+
+export class MockTimer implements Timer {
+ end(): this {
+ return this;
+ }
+
+ reset(): this {
+ return this;
+ }
+
+ withMaximum(_: number): this {
+ return this;
+ }
+}
+
+export const grReportingMock: ReportingService = {
+ appStarted: () => {},
+ beforeLocationChanged: () => {},
+ changeDisplayed: () => {},
+ changeFullyLoaded: () => {},
+ dashboardDisplayed: () => {},
+ diffViewContentDisplayed: () => {},
+ diffViewDisplayed: () => {},
+ diffViewFullyLoaded: () => {},
+ fileListDisplayed: () => {},
+ getTimer: () => {
+ return new MockTimer();
+ },
+ locationChanged: () => {},
+ onVisibilityChange: () => {},
+ pluginLoaded: () => {},
+ pluginsLoaded: () => {},
+ recordDraftInteraction: () => {},
+ reporter: () => {},
+ reportErrorDialog: () => {},
+ reportExtension: () => {},
+ reportInteraction: () => {},
+ reportLifeCycle: () => {},
+ reportRpcTiming: () => {},
+ setRepoName: () => {},
+ time: () => {},
+ timeEnd: () => {},
+ timeEndWithAverage: () => {},
+};
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js
index 7c70e10..73f8580 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock_test.js
@@ -16,7 +16,7 @@
*/
import '../../test/common-test-setup-karma.js';
-import {GrReporting} from './gr-reporting.js';
+import {GrReporting} from './gr-reporting_impl.js';
import {grReportingMock} from './gr-reporting_mock.js';
suite('gr-reporting_mock tests', () => {
test('mocks all public methods', () => {
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
index 01ba3cb..08e4a55 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_test.js
@@ -16,7 +16,7 @@
*/
import '../../test/common-test-setup-karma.js';
-import {GrReporting, DEFAULT_STARTUP_TIMERS, initErrorReporter} from './gr-reporting.js';
+import {GrReporting, DEFAULT_STARTUP_TIMERS, initErrorReporter} from './gr-reporting_impl.js';
import {appContext} from '../app-context.js';
suite('gr-reporting tests', () => {
let service;
@@ -29,7 +29,7 @@
setup(() => {
clock = sinon.useFakeTimers(NOW_TIME);
service = new GrReporting(appContext.flagsService);
- service._baselines = Object.assign({}, DEFAULT_STARTUP_TIMERS);
+ service._baselines = {...DEFAULT_STARTUP_TIMERS};
sinon.stub(service, 'reporter');
});
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
new file mode 100644
index 0000000..08dbb16
--- /dev/null
+++ b/polygerrit-ui/app/services/services/gr-rest-api/gr-rest-api.ts
@@ -0,0 +1,877 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {
+ AccountDetailInfo,
+ AccountExternalIdInfo,
+ AccountInfo,
+ NumericChangeId,
+ ServerInfo,
+ ProjectInfo,
+ AccountCapabilityInfo,
+ SuggestedReviewerInfo,
+ GroupNameToGroupInfoMap,
+ ParsedJSON,
+ PatchSetNum,
+ RequestPayload,
+ PreferencesInput,
+ DiffPreferencesInfo,
+ EditPreferencesInfo,
+ DiffPreferenceInput,
+ SshKeyInfo,
+ RepoName,
+ BranchName,
+ BranchInput,
+ TagInput,
+ GpgKeysInput,
+ GpgKeyId,
+ GpgKeyInfo,
+ PreferencesInfo,
+ EmailInfo,
+ ProjectAccessInfo,
+ CapabilityInfoMap,
+ ProjectAccessInput,
+ ChangeInfo,
+ ProjectInfoWithName,
+ GroupId,
+ GroupInfo,
+ GroupOptionsInput,
+ BranchInfo,
+ ConfigInfo,
+ ReviewInput,
+ EditInfo,
+ ChangeId,
+ DashboardInfo,
+ ProjectAccessInfoMap,
+ IncludedInInfo,
+ RobotCommentInfo,
+ CommentInfo,
+ PathToCommentsInfoMap,
+ PathToRobotCommentsInfoMap,
+ CommentInput,
+ GroupInput,
+ PluginInfo,
+ DocResult,
+ ContributorAgreementInfo,
+ ContributorAgreementInput,
+ Password,
+ ProjectWatchInfo,
+ NameToProjectInfoMap,
+ ProjectInput,
+ AccountId,
+ ChangeMessageId,
+ GroupAuditEventInfo,
+ EncodedGroupId,
+ Base64FileContent,
+ UrlEncodedCommentId,
+ TagInfo,
+ GitRef,
+ ConfigInput,
+ RelatedChangesInfo,
+ SubmittedTogetherInfo,
+ EmailAddress,
+ FixId,
+ FilePathToDiffInfoMap,
+ DiffInfo,
+ BlameInfo,
+ PatchRange,
+ ImagesForDiff,
+ ActionNameToActionInfoMap,
+ RevisionId,
+ GroupName,
+ DashboardId,
+ HashtagsInput,
+ Hashtag,
+ FileNameToFileInfoMap,
+ TopMenuEntryInfo,
+ MergeableInfo,
+ CommitInfo,
+} from '../../../types/common';
+import {ParsedChangeInfo} from '../../../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+import {HttpMethod, IgnoreWhitespaceType} from '../../../constants/constants';
+
+export type ErrorCallback = (response?: Response | null, err?: Error) => void;
+export type CancelConditionCallback = () => boolean;
+
+// TODO(TS): remove when GrReplyDialog converted to typescript
+export interface GrReplyDialog {
+ getLabelValue(label: string): string;
+ setLabelValue(label: string, value: string): void;
+ send(includeComments?: boolean, startReview?: boolean): Promise<unknown>;
+ setPluginMessage(message: string): void;
+}
+
+// Copied from gr-change-actions.js
+export enum ActionType {
+ CHANGE = 'change',
+ REVISION = 'revision',
+}
+
+// Copied from gr-change-actions.js
+export enum ActionPriority {
+ CHANGE = 2,
+ DEFAULT = 0,
+ PRIMARY = 3,
+ REVIEW = -3,
+ REVISION = 1,
+}
+
+export interface GetDiffCommentsOutput {
+ baseComments: CommentInfo[];
+ comments: CommentInfo[];
+}
+
+export interface GetDiffRobotCommentsOutput {
+ baseComments: RobotCommentInfo[];
+ comments: RobotCommentInfo[];
+}
+
+export interface RestApiService {
+ // TODO(TS): unclear what is a second parameter. Looks like it is a mistake
+ // and it must be removed
+ dispatchEvent(event: Event, detail?: unknown): boolean;
+ getConfig(noCache?: boolean): Promise<ServerInfo | undefined>;
+ getLoggedIn(): Promise<boolean>;
+ getPreferences(): Promise<PreferencesInfo | undefined>;
+ getVersion(): Promise<string | undefined>;
+ getAccount(): Promise<AccountDetailInfo | undefined>;
+ getAccountCapabilities(
+ params?: string[]
+ ): Promise<AccountCapabilityInfo | undefined>;
+ getExternalIds(): Promise<AccountExternalIdInfo[] | undefined>;
+ deleteAccountIdentity(id: string[]): Promise<unknown>;
+ getRepos(
+ filter: string | undefined,
+ reposPerPage: number,
+ offset?: number
+ ): Promise<ProjectInfoWithName[] | undefined>;
+
+ send(
+ method: HttpMethod,
+ url: string,
+ body?: RequestPayload,
+ errFn?: null | undefined,
+ contentType?: string,
+ headers?: Record<string, string>
+ ): Promise<Response>;
+
+ send(
+ method: HttpMethod,
+ url: string,
+ body?: RequestPayload,
+ errFn?: ErrorCallback,
+ contentType?: string,
+ headers?: Record<string, string>
+ ): Promise<Response | void>;
+
+ getResponseObject(response: Response): Promise<ParsedJSON>;
+
+ getChangeSuggestedReviewers(
+ changeNum: NumericChangeId,
+ input: string,
+ errFn?: ErrorCallback
+ ): Promise<SuggestedReviewerInfo[] | undefined>;
+ getChangeSuggestedCCs(
+ changeNum: NumericChangeId,
+ input: string,
+ errFn?: ErrorCallback
+ ): Promise<SuggestedReviewerInfo[] | undefined>;
+ getSuggestedAccounts(
+ input: string,
+ n?: number,
+ errFn?: ErrorCallback
+ ): Promise<AccountInfo[] | undefined>;
+ getSuggestedGroups(
+ input: string,
+ n?: number,
+ errFn?: ErrorCallback
+ ): Promise<GroupNameToGroupInfoMap | undefined>;
+ executeChangeAction(
+ changeNum: NumericChangeId,
+ method: HttpMethod | undefined,
+ endpoint: string,
+ patchNum?: PatchSetNum,
+ payload?: RequestPayload,
+ errFn?: ErrorCallback
+ ): Promise<Response | undefined>;
+ getRepoBranches(
+ filter: string,
+ repo: RepoName,
+ reposBranchesPerPage: number,
+ offset?: number,
+ errFn?: ErrorCallback
+ ): Promise<BranchInfo[] | undefined>;
+
+ getChangeDetail(
+ changeNum: number | string,
+ opt_errFn?: ErrorCallback,
+ opt_cancelCondition?: Function
+ ): Promise<ParsedChangeInfo | null | undefined>;
+
+ getChange(
+ changeNum: ChangeId | NumericChangeId,
+ errFn: ErrorCallback
+ ): Promise<ChangeInfo | null>;
+
+ savePreferences(prefs: PreferencesInput): Promise<Response>;
+
+ getDiffPreferences(): Promise<DiffPreferencesInfo | undefined>;
+
+ saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response>;
+ saveDiffPreferences(
+ prefs: DiffPreferenceInput,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+ saveDiffPreferences(
+ prefs: DiffPreferenceInput,
+ errFn?: ErrorCallback
+ ): Promise<Response>;
+
+ getEditPreferences(): Promise<EditPreferencesInfo | undefined>;
+
+ saveEditPreferences(prefs: EditPreferencesInfo): Promise<Response>;
+ saveEditPreferences(
+ prefs: EditPreferencesInfo,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+ saveEditPreferences(
+ prefs: EditPreferencesInfo,
+ errFn?: ErrorCallback
+ ): Promise<Response>;
+
+ getAccountEmails(): Promise<EmailInfo[] | undefined>;
+ deleteAccountEmail(email: string): Promise<Response>;
+ setPreferredAccountEmail(email: string, errFn?: ErrorCallback): Promise<void>;
+
+ getAccountSSHKeys(): Promise<SshKeyInfo[] | undefined>;
+ deleteAccountSSHKey(key: string): void;
+ addAccountSSHKey(key: string): Promise<SshKeyInfo>;
+
+ createRepoBranch(
+ name: RepoName,
+ branch: BranchName,
+ revision: BranchInput
+ ): Promise<Response>;
+
+ createRepoBranch(
+ name: RepoName,
+ branch: BranchName,
+ revision: BranchInput,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ createRepoTag(
+ name: RepoName,
+ tag: string,
+ revision: TagInput
+ ): Promise<Response>;
+
+ createRepoTag(
+ name: RepoName,
+ tag: string,
+ revision: TagInput,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+ addAccountGPGKey(key: GpgKeysInput): Promise<Record<string, GpgKeyInfo>>;
+ deleteAccountGPGKey(id: GpgKeyId): Promise<Response>;
+ getAccountGPGKeys(): Promise<Record<string, GpgKeyInfo>>;
+ probePath(path: string): Promise<boolean>;
+
+ saveFileUploadChangeEdit(
+ changeNum: NumericChangeId,
+ path: string,
+ content: string
+ ): Promise<Response | undefined>;
+
+ deleteFileInChangeEdit(
+ changeNum: NumericChangeId,
+ path: string
+ ): Promise<Response | undefined>;
+
+ restoreFileInChangeEdit(
+ changeNum: NumericChangeId,
+ restore_path: string
+ ): Promise<Response | undefined>;
+
+ renameFileInChangeEdit(
+ changeNum: NumericChangeId,
+ old_path: string,
+ new_path: string
+ ): Promise<Response | undefined>;
+
+ queryChangeFiles(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ query: string
+ ): Promise<string[] | undefined>;
+
+ getRepoAccessRights(
+ repoName: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<ProjectAccessInfo | undefined>;
+
+ createRepo(config: ProjectInput & {name: RepoName}): Promise<Response>;
+ createRepo(
+ config: ProjectInput & {name: RepoName},
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+ createRepo(config: ProjectInput, errFn?: ErrorCallback): Promise<Response>;
+
+ getRepo(
+ repo: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<ProjectInfo | undefined>;
+
+ getRepoDashboards(
+ repo: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<DashboardInfo[] | undefined>;
+
+ getRepoAccess(repo: RepoName): Promise<ProjectAccessInfoMap | undefined>;
+
+ getProjectConfig(
+ repo: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<ConfigInfo | undefined>;
+
+ getCapabilities(
+ errFn?: ErrorCallback
+ ): Promise<CapabilityInfoMap | undefined>;
+
+ setRepoAccessRights(
+ repoName: RepoName,
+ repoInfo: ProjectAccessInput
+ ): Promise<Response>;
+
+ setRepoAccessRightsForReview(
+ projectName: RepoName,
+ projectInfo: ProjectAccessInput
+ ): Promise<ChangeInfo>;
+
+ getGroups(
+ filter: string,
+ groupsPerPage: number,
+ offset?: number
+ ): Promise<GroupNameToGroupInfoMap | undefined>;
+
+ getGroupConfig(
+ group: GroupId | GroupName,
+ errFn?: ErrorCallback
+ ): Promise<GroupInfo | undefined>;
+
+ getIsAdmin(): Promise<boolean | undefined>;
+
+ getIsGroupOwner(groupName: GroupName): Promise<boolean>;
+
+ saveGroupName(
+ groupId: GroupId | GroupName,
+ name: GroupName
+ ): Promise<Response>;
+
+ saveGroupOwner(
+ groupId: GroupId | GroupName,
+ ownerId: string
+ ): Promise<Response>;
+
+ saveGroupDescription(
+ groupId: GroupId,
+ description: string
+ ): Promise<Response>;
+
+ saveGroupOptions(
+ groupId: GroupId,
+ options: GroupOptionsInput
+ ): Promise<Response>;
+
+ saveChangeReview(
+ changeNum: ChangeId | NumericChangeId,
+ patchNum: RevisionId,
+ review: ReviewInput
+ ): Promise<Response>;
+ saveChangeReview(
+ changeNum: ChangeId | NumericChangeId,
+ patchNum: RevisionId,
+ review: ReviewInput,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+ saveChangeReview(
+ changeNum: ChangeId | NumericChangeId,
+ patchNum: RevisionId,
+ review: ReviewInput,
+ errFn?: ErrorCallback
+ ): Promise<Response>;
+
+ getChangeEdit(
+ changeNum: NumericChangeId,
+ downloadCommands?: boolean
+ ): Promise<false | EditInfo | undefined>;
+
+ getChangeActionURL(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum | undefined,
+ endpoint: string
+ ): Promise<string>;
+
+ createChange(
+ project: RepoName,
+ branch: BranchName,
+ subject: string,
+ topic?: string,
+ isPrivate?: boolean,
+ workInProgress?: boolean,
+ baseChange?: ChangeId,
+ baseCommit?: string
+ ): Promise<ChangeInfo | undefined>;
+
+ getChangeIncludedIn(
+ changeNum: NumericChangeId
+ ): Promise<IncludedInInfo | undefined>;
+
+ getFromProjectLookup(
+ changeNum: NumericChangeId
+ ): Promise<RepoName | undefined>;
+
+ saveDiffDraft(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ draft: CommentInput
+ ): Promise<Response>;
+
+ getDiffChangeDetail(
+ changeNum: NumericChangeId,
+ errFn?: ErrorCallback,
+ cancelCondition?: CancelConditionCallback
+ ): Promise<ChangeInfo | undefined | null>;
+
+ getPortedComments(
+ changeNum: NumericChangeId,
+ revision: RevisionId
+ ): Promise<PathToCommentsInfoMap | undefined>;
+
+ getPortedDrafts(
+ changeNum: NumericChangeId,
+ revision: RevisionId
+ ): Promise<PathToCommentsInfoMap | undefined>;
+
+ getDiffComments(
+ changeNum: NumericChangeId
+ ): Promise<PathToCommentsInfoMap | undefined>;
+ getDiffComments(
+ changeNum: NumericChangeId,
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ path: string
+ ): Promise<GetDiffCommentsOutput>;
+ getDiffComments(
+ changeNum: NumericChangeId,
+ basePatchNum?: PatchSetNum,
+ patchNum?: PatchSetNum,
+ path?: string
+ ):
+ | Promise<PathToCommentsInfoMap | undefined>
+ | Promise<GetDiffCommentsOutput>;
+
+ getDiffRobotComments(
+ changeNum: NumericChangeId
+ ): Promise<PathToRobotCommentsInfoMap | undefined>;
+ getDiffRobotComments(
+ changeNum: NumericChangeId,
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ path: string
+ ): Promise<GetDiffRobotCommentsOutput>;
+ getDiffRobotComments(
+ changeNum: NumericChangeId,
+ basePatchNum?: PatchSetNum,
+ patchNum?: PatchSetNum,
+ path?: string
+ ):
+ | Promise<GetDiffRobotCommentsOutput>
+ | Promise<PathToRobotCommentsInfoMap | undefined>;
+
+ getDiffDrafts(
+ changeNum: NumericChangeId
+ ): Promise<PathToCommentsInfoMap | undefined>;
+ getDiffDrafts(
+ changeNum: NumericChangeId,
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ path: string
+ ): Promise<GetDiffCommentsOutput>;
+ getDiffDrafts(
+ changeNum: NumericChangeId,
+ basePatchNum?: PatchSetNum,
+ patchNum?: PatchSetNum,
+ path?: string
+ ):
+ | Promise<GetDiffCommentsOutput>
+ | Promise<PathToCommentsInfoMap | undefined>;
+
+ createGroup(config: GroupInput & {name: string}): Promise<Response>;
+ createGroup(
+ config: GroupInput & {name: string},
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+ createGroup(config: GroupInput, errFn?: ErrorCallback): Promise<Response>;
+
+ getPlugins(
+ filter: string,
+ pluginsPerPage: number,
+ offset?: number,
+ errFn?: ErrorCallback
+ ): Promise<{[pluginName: string]: PluginInfo} | undefined>;
+
+ getChanges(
+ changesPerPage?: number,
+ query?: string,
+ offset?: 'n,z' | number,
+ options?: string
+ ): Promise<ChangeInfo[] | undefined>;
+ getChanges(
+ changesPerPage?: number,
+ query?: string[],
+ offset?: 'n,z' | number,
+ options?: string
+ ): Promise<ChangeInfo[][] | undefined>;
+ /**
+ * @return If opt_query is an
+ * array, _fetchJSON will return an array of arrays of changeInfos. If it
+ * is unspecified or a string, _fetchJSON will return an array of
+ * changeInfos.
+ */
+ getChanges(
+ changesPerPage?: number,
+ query?: string | string[],
+ offset?: 'n,z' | number,
+ options?: string
+ ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined>;
+
+ getDocumentationSearches(filter: string): Promise<DocResult[] | undefined>;
+
+ getAccountAgreements(): Promise<ContributorAgreementInfo[] | undefined>;
+
+ getAccountGroups(): Promise<GroupInfo[] | undefined>;
+
+ getAccountDetails(userId: AccountId): Promise<AccountDetailInfo | undefined>;
+
+ getAccountStatus(userId: AccountId): Promise<string | undefined>;
+
+ saveAccountAgreement(name: ContributorAgreementInput): Promise<Response>;
+
+ generateAccountHttpPassword(): Promise<Password>;
+
+ setAccountName(name: string, errFn?: ErrorCallback): Promise<void>;
+
+ setAccountUsername(username: string, errFn?: ErrorCallback): Promise<void>;
+
+ getWatchedProjects(): Promise<ProjectWatchInfo[] | undefined>;
+
+ saveWatchedProjects(
+ projects: ProjectWatchInfo[],
+ errFn?: ErrorCallback
+ ): Promise<ProjectWatchInfo[]>;
+
+ deleteWatchedProjects(
+ projects: ProjectWatchInfo[]
+ ): Promise<Response | undefined>;
+ deleteWatchedProjects(
+ projects: ProjectWatchInfo[],
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+ deleteWatchedProjects(
+ projects: ProjectWatchInfo[],
+ errFn?: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ getSuggestedProjects(
+ inputVal: string,
+ n?: number,
+ errFn?: ErrorCallback
+ ): Promise<NameToProjectInfoMap | undefined>;
+
+ invalidateGroupsCache(): void;
+ invalidateReposCache(): void;
+ invalidateAccountsCache(): void;
+ removeFromAttentionSet(
+ changeNum: NumericChangeId,
+ user: AccountId,
+ reason: string
+ ): Promise<Response>;
+ addToAttentionSet(
+ changeNum: NumericChangeId,
+ user: AccountId | undefined | null,
+ reason: string
+ ): Promise<Response>;
+ setAccountDisplayName(
+ displayName: string,
+ errFn?: ErrorCallback
+ ): Promise<void>;
+ setAccountStatus(status: string, errFn?: ErrorCallback): Promise<void>;
+ getAvatarChangeUrl(): Promise<string | undefined>;
+ setDescription(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ desc: string
+ ): Promise<Response>;
+ deleteVote(
+ changeNum: NumericChangeId,
+ account: AccountId,
+ label: string
+ ): Promise<Response>;
+
+ deleteComment(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ commentID: UrlEncodedCommentId,
+ reason: string
+ ): Promise<CommentInfo>;
+ deleteDiffDraft(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ draft: {id: UrlEncodedCommentId}
+ ): Promise<Response>;
+
+ deleteChangeCommitMessage(
+ changeNum: NumericChangeId,
+ messageId: ChangeMessageId
+ ): Promise<Response>;
+
+ removeChangeReviewer(
+ changeNum: NumericChangeId,
+ reviewerID: AccountId | EmailAddress | GroupId
+ ): Promise<Response | undefined>;
+
+ getGroupAuditLog(
+ group: EncodedGroupId,
+ errFn?: ErrorCallback
+ ): Promise<GroupAuditEventInfo[] | undefined>;
+
+ getGroupMembers(
+ groupName: GroupId | GroupName,
+ errFn?: ErrorCallback
+ ): Promise<AccountInfo[] | undefined>;
+
+ getIncludedGroup(
+ groupName: GroupId | GroupName
+ ): Promise<GroupInfo[] | undefined>;
+
+ saveGroupMember(
+ groupName: GroupId | GroupName,
+ groupMember: AccountId
+ ): Promise<AccountInfo>;
+
+ saveIncludedGroup(
+ groupName: GroupId | GroupName,
+ includedGroup: GroupId,
+ errFn?: ErrorCallback
+ ): Promise<GroupInfo | undefined>;
+
+ deleteGroupMember(
+ groupName: GroupId | GroupName,
+ groupMember: AccountId
+ ): Promise<Response>;
+
+ deleteIncludedGroup(
+ groupName: GroupId | GroupName,
+ includedGroup: GroupId
+ ): Promise<Response>;
+
+ runRepoGC(
+ repo: RepoName,
+ errFn?: ErrorCallback
+ ): Promise<Response | undefined>;
+ getFileContent(
+ changeNum: NumericChangeId,
+ path: string,
+ patchNum: PatchSetNum
+ ): Promise<Response | Base64FileContent | undefined>;
+
+ saveChangeEdit(
+ changeNum: NumericChangeId,
+ path: string,
+ contents: string
+ ): Promise<Response>;
+ getRepoTags(
+ filter: string,
+ repo: RepoName,
+ reposTagsPerPage: number,
+ offset?: number,
+ errFn?: ErrorCallback
+ ): Promise<TagInfo[]>;
+
+ setRepoHead(repo: RepoName, ref: GitRef): Promise<Response>;
+ deleteRepoTags(repo: RepoName, ref: GitRef): Promise<Response>;
+ deleteRepoBranches(repo: RepoName, ref: GitRef): Promise<Response>;
+ saveRepoConfig(repo: RepoName, config: ConfigInput): Promise<Response>;
+
+ getRelatedChanges(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<RelatedChangesInfo | undefined>;
+
+ getChangesSubmittedTogether(
+ changeNum: NumericChangeId
+ ): Promise<SubmittedTogetherInfo | undefined>;
+
+ getChangeConflicts(
+ changeNum: NumericChangeId
+ ): Promise<ChangeInfo[] | undefined>;
+
+ getChangeCherryPicks(
+ project: RepoName,
+ changeID: ChangeId,
+ changeNum: NumericChangeId
+ ): Promise<ChangeInfo[] | undefined>;
+
+ getChangesWithSameTopic(
+ topic: string,
+ changeNum: NumericChangeId
+ ): Promise<ChangeInfo[] | undefined>;
+
+ hasPendingDiffDrafts(): number;
+ awaitPendingDiffDrafts(): Promise<void>;
+
+ getRobotCommentFixPreview(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ fixId: FixId
+ ): Promise<FilePathToDiffInfoMap | undefined>;
+
+ applyFixSuggestion(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ fixId: string
+ ): Promise<Response>;
+
+ getDiff(
+ changeNum: NumericChangeId,
+ basePatchNum: PatchSetNum,
+ patchNum: PatchSetNum,
+ path: string,
+ whitespace?: IgnoreWhitespaceType,
+ errFn?: ErrorCallback
+ ): Promise<DiffInfo | undefined>;
+
+ getBlame(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ path: string,
+ base?: boolean
+ ): Promise<BlameInfo[] | undefined>;
+
+ getImagesForDiff(
+ changeNum: NumericChangeId,
+ diff: DiffInfo,
+ patchRange: PatchRange
+ ): Promise<ImagesForDiff>;
+
+ getChangeRevisionActions(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<ActionNameToActionInfoMap | undefined>;
+
+ confirmEmail(token: string): Promise<string | null>;
+
+ getDefaultPreferences(): Promise<PreferencesInfo | undefined>;
+
+ addAccountEmail(email: string): Promise<Response>;
+
+ addAccountEmail(
+ email: string,
+ errFn?: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ saveChangeReviewed(
+ changeNum: NumericChangeId,
+ reviewed: boolean
+ ): Promise<Response | undefined>;
+
+ saveChangeStarred(
+ changeNum: NumericChangeId,
+ starred: boolean
+ ): Promise<Response>;
+
+ getDashboard(
+ project: RepoName,
+ dashboard: DashboardId,
+ errFn?: ErrorCallback
+ ): Promise<DashboardInfo | undefined>;
+
+ deleteDraftComments(query: string): Promise<Response>;
+
+ setAssignee(
+ changeNum: NumericChangeId,
+ assignee: AccountId
+ ): Promise<Response>;
+
+ deleteAssignee(changeNum: NumericChangeId): Promise<Response>;
+
+ setChangeHashtag(
+ changeNum: NumericChangeId,
+ hashtag: HashtagsInput
+ ): Promise<Hashtag[]>;
+
+ setChangeTopic(
+ changeNum: NumericChangeId,
+ topic: string | null
+ ): Promise<string>;
+
+ getChangeFiles(
+ changeNum: NumericChangeId,
+ patchRange: PatchRange
+ ): Promise<FileNameToFileInfoMap | undefined>;
+
+ getChangeOrEditFiles(
+ changeNum: NumericChangeId,
+ patchRange: PatchRange
+ ): Promise<FileNameToFileInfoMap | undefined>;
+
+ getReviewedFiles(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<string[] | undefined>;
+
+ saveFileReviewed(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ path: string,
+ reviewed: boolean
+ ): Promise<Response>;
+
+ saveFileReviewed(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum,
+ path: string,
+ reviewed: boolean,
+ errFn: ErrorCallback
+ ): Promise<Response | undefined>;
+
+ getTopMenus(errFn?: ErrorCallback): Promise<TopMenuEntryInfo[] | undefined>;
+
+ setInProjectLookup(changeNum: NumericChangeId, project: RepoName): void;
+ getMergeable(changeNum: NumericChangeId): Promise<MergeableInfo | undefined>;
+
+ putChangeCommitMessage(
+ changeNum: NumericChangeId,
+ message: string
+ ): Promise<Response>;
+
+ getChangeCommitInfo(
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
+ ): Promise<CommitInfo | undefined>;
+}
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index 25d7f52..6de4e6f 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -32,6 +32,13 @@
gr-change-list-item:focus {
background-color: var(--selection-background-color);
}
+ gr-change-list-item[highlight] {
+ background-color: var(--assignee-highlight-color);
+ }
+ gr-change-list-item[highlight][selected],
+ gr-change-list-item[highlight]:focus {
+ background-color: var(--assignee-highlight-selection-color);
+ }
.groupTitle td,
.cell {
vertical-align: middle;
@@ -74,6 +81,9 @@
gr-change-star {
vertical-align: middle;
}
+ .owner {
+ --account-max-length: 120px;
+ }
.branch,
.star,
.label,
@@ -81,6 +91,8 @@
.owner,
.assignee,
.updated,
+ .submitted,
+ .waiting,
.size,
.status,
.repo {
@@ -111,8 +123,7 @@
}
@media only screen and (max-width: 150em) {
.assignee,
- .branch,
- .owner {
+ .branch {
overflow: hidden;
max-width: 18rem;
text-overflow: ellipsis;
@@ -126,8 +137,7 @@
}
@media only screen and (max-width: 100em) {
.assignee,
- .branch,
- .owner {
+ .branch {
max-width: 10rem;
}
}
@@ -162,6 +172,8 @@
.repo,
.branch,
.updated,
+ .submitted,
+ .waiting,
.label,
.assignee,
.groupHeader .star,
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 04dca9c..695ae24 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -135,6 +135,7 @@
/* Stopgap solution until we remove hidden$ attributes. */
+ :host([hidden]),
[hidden] {
display: none !important;
}
@@ -176,6 +177,19 @@
font-weight: var(--font-weight-bold);
}
+ .assistive-tech-only {
+ user-select: none;
+ clip: rect(1px, 1px, 1px, 1px);
+ height: 1px;
+ margin: 0;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ white-space: nowrap;
+ width: 1px;
+ z-index: -1000;
+ }
+
/** BEGIN: loading spiner */
.loadingSpin {
border: 2px solid var(--disabled-button-background-color);
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index f48e43f..9586c09 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -18,12 +18,16 @@
// Mark the file as a module. Otherwise typescript assumes this is a script
// and $_documentContainer is a global variable.
// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+import {
+ createStyle,
+ safeStyleSheet,
+ setInnerHtml,
+} from '../../utils/inner-html-util';
-const $_documentContainer = document.createElement('template');
+const customStyle = document.createElement('custom-style');
+customStyle.setAttribute('id', 'light-theme');
-$_documentContainer.innerHTML = `
-<custom-style id="light-theme"><style is="custom-style">
+const styleSheet = safeStyleSheet`
html {
/**
* When adding a new color variable make sure to also add it to the other
@@ -34,13 +38,14 @@
* Note that plugins might be using these variables, so removing a variable
* can be a breaking change that should go into the release notes.
*/
-
+
/* text colors */
--primary-text-color: black;
--link-color: #2a66d9;
--comment-text-color: black;
--deemphasized-text-color: #5F6368;
--default-button-text-color: #2a66d9;
+ --chip-selected-text-color: var(--default-button-text-color);
--error-text-color: red;
--primary-button-text-color: white;
/* Used on text color for change list that doesn't need user's attention. */
@@ -50,7 +55,7 @@
--tooltip-text-color: white;
--negative-red-text-color: #d93025;
--positive-green-text-color: #188038;
-
+
/* background colors */
/* primary background colors */
--background-color-primary: #ffffff;
@@ -70,6 +75,10 @@
--view-background-color: var(--background-color-primary);
/* unique background colors */
--assignee-highlight-color: #fcfad6;
+ /* TODO: Find a nicer way to combine the --assignee-highlight-color and the
+ --selection-background-color than to just invent another unique color. */
+ --assignee-highlight-selection-color: #f6f4d0;
+ --chip-selected-background-color: #e8f0fe;
--edit-mode-background-color: #ebf5fb;
--emphasis-color: #fff9c4;
--hover-background-color: rgba(161, 194, 250, 0.2);
@@ -87,11 +96,11 @@
--vote-color-neutral: #ebf5fb;
--vote-color-recommended: #c9dfaf;
--vote-color-rejected: #f7a1ad;
-
+
/* misc colors */
--border-color: #e8e8e8;
--comment-separator-color: #dadce0;
-
+
/* status colors */
--status-merged: #188038;
--status-abandoned: #5f6368;
@@ -101,7 +110,7 @@
--status-active: #1976d2;
--status-ready: #b80672;
--status-custom: #681da8;
-
+
/* fonts */
--font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
--header-font-family: 'Open Sans', 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
@@ -113,7 +122,7 @@
--font-size-h3: 1.143rem; /* 16px */
--font-size-h2: 1.429rem; /* 20px */
--font-size-h1: 1.714rem; /* 24px */
- --line-height-code: 1.334; /* 16px */
+ --line-height-code: 1.143rem; /* 16px */
--line-height-mono: 1.286rem; /* 18px */
--line-height-small: 1.143rem; /* 16px */
--line-height-normal: 1.429rem; /* 20px */
@@ -125,7 +134,8 @@
--font-weight-h1: 400;
--font-weight-h2: 400;
--font-weight-h3: 400;
-
+ --context-control-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
+
/* spacing */
--spacing-xxs: 1px;
--spacing-xs: 2px;
@@ -134,7 +144,7 @@
--spacing-l: 12px;
--spacing-xl: 16px;
--spacing-xxl: 24px;
-
+
/* header and footer */
--footer-background-color: transparent;
--footer-border-top: none;
@@ -148,7 +158,7 @@
--header-text-color: black;
--header-title-content: 'Gerrit';
--header-title-font-size: 1.75rem;
-
+
/* diff colors */
--dark-add-highlight-color: #aaf2aa;
--dark-rebased-add-highlight-color: #d7d7f9;
@@ -165,11 +175,12 @@
--diff-trailing-whitespace-indicator: #ff9ad2;
--light-add-highlight-color: #d8fed8;
--light-rebased-add-highlight-color: #eef;
+ --light-moved-add-highlight-color: #eef;
--light-remove-add-highlight-color: #fff8dc;
--light-remove-highlight-color: #ffebee;
--coverage-covered: #e0f2f1;
--coverage-not-covered: #ffd1a4;
-
+
/* syntax colors */
--syntax-attr-color: #219;
--syntax-attribute-color: var(--primary-text-color);
@@ -197,18 +208,18 @@
--syntax-title-color: #0000c0;
--syntax-type-color: #2a66d9;
--syntax-variable-color: var(--primary-text-color);
-
+
/* elevation */
--elevation-level-1: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 1px 3px 1px rgba(60, 64, 67, .15);
--elevation-level-2: 0px 1px 2px 0px rgba(60, 64, 67, .30), 0px 2px 6px 2px rgba(60, 64, 67, .15);
--elevation-level-3: 0px 1px 3px 0px rgba(60, 64, 67, .30), 0px 4px 8px 3px rgba(60, 64, 67, .15);
--elevation-level-4: 0px 2px 3px 0px rgba(60, 64, 67, .30), 0px 6px 10px 4px rgba(60, 64, 67, .15);
--elevation-level-5: 0px 4px 4px 0px rgba(60, 64, 67, .30), 0px 8px 12px 6px rgba(60, 64, 67, .15);
-
+
/* misc */
--border-radius: 4px;
--reply-overlay-z-index: 1000;
-
+
/* paper and iron component overrides */
--iron-overlay-backdrop-background-color: black;
--iron-overlay-backdrop-opacity: 0.32;
@@ -226,7 +237,8 @@
--spacing-xl: 12px;
--spacing-xxl: 16px;
}
- }
-</style></custom-style>`;
+ }`;
-document.head.appendChild($_documentContainer.content);
+setInnerHtml(customStyle, createStyle(styleSheet));
+
+document.head.appendChild(customStyle);
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 684f2fe..4d3e6d8 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -15,10 +15,17 @@
* limitations under the License.
*/
+import {
+ createStyle,
+ safeStyleSheet,
+ setInnerHtml,
+} from '../../utils/inner-html-util';
+
function getStyleEl() {
- const $_documentContainer = document.createElement('template');
- $_documentContainer.innerHTML = `
- <custom-style id="dark-theme"><style is="custom-style">
+ const customStyle = document.createElement('custom-style');
+ customStyle.setAttribute('id', 'dark-theme');
+
+ const styleSheet = safeStyleSheet`
html {
/**
* Sections and variables must stay consistent with app-theme.js.
@@ -35,6 +42,7 @@
--comment-text-color: var(--primary-text-color);
--deemphasized-text-color: #9aa0a6;
--default-button-text-color: #8ab4f8;
+ --chip-selected-text-color: #d2e3fc;
--error-text-color: red;
--primary-button-text-color: black;
/* Used on text color for change list doesn't need user's attention. */
@@ -54,6 +62,8 @@
/* empty, because inheriting from app-theme is just fine
/* unique background colors */
--assignee-highlight-color: #3a361c;
+ --assignee-highlight-selection-color: #423e24;
+ --chip-selected-background-color: #3c4455;
--edit-mode-background-color: #5c0a36;
--emphasis-color: #383f4a;
--hover-background-color: rgba(161, 194, 250, 0.2);
@@ -115,6 +125,7 @@
--diff-trailing-whitespace-indicator: #ff9ad2;
--light-add-highlight-color: #0f401f;
--light-rebased-add-highlight-color: #487165;
+ --light-moved-add-highlight-color: #487165;
--light-remove-add-highlight-color: #2f3f2f;
--light-remove-highlight-color: #320404;
--coverage-covered: #112826;
@@ -153,16 +164,17 @@
/* paper and iron component overrides */
--iron-overlay-backdrop-background-color: white;
- /* rules applied to <html> */
+ /* rules applied to html */
background-color: var(--view-background-color);
}
- </style></custom-style>`;
+ `;
- return $_documentContainer;
+ setInnerHtml(customStyle, createStyle(styleSheet));
+ return customStyle;
}
export function applyTheme() {
- document.head.appendChild(getStyleEl().content);
+ document.head.appendChild(getStyleEl());
}
export function removeTheme() {
diff --git a/polygerrit-ui/app/styles/themes/dark-theme_test.js b/polygerrit-ui/app/styles/themes/dark-theme_test.js
new file mode 100644
index 0000000..4f6466f
--- /dev/null
+++ b/polygerrit-ui/app/styles/themes/dark-theme_test.js
@@ -0,0 +1,28 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {applyTheme, removeTheme} from './dark-theme.js';
+
+suite('dark-theme_test.js', () => {
+ test('apply and remove theme', () => {
+ applyTheme();
+ assert.equal(document.head.querySelectorAll('#dark-theme').length, 1);
+ removeTheme();
+ assert.equal(document.head.querySelectorAll('#dark-theme').length, 0);
+ });
+});
diff --git a/polygerrit-ui/app/test/@types/sinon-esm.d.ts b/polygerrit-ui/app/test/@types/sinon-esm.d.ts
new file mode 100644
index 0000000..9074a7a
--- /dev/null
+++ b/polygerrit-ui/app/test/@types/sinon-esm.d.ts
@@ -0,0 +1,27 @@
+/**
+ * @license
+ * 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.
+ */
+
+declare module 'sinon/pkg/sinon-esm' {
+ // sinon-esm doesn't have it's own d.ts, reexport all types from sinon
+ // This is a trick - @types/sinon adds interfaces and sinon instance
+ // to a global variables/namespace. We reexport it here, so we
+ // can use in our code when importing sinon-esm
+ // eslint-disable-next-line import/no-default-export
+ export default sinon;
+ const sinon: Sinon.SinonStatic;
+ export {SinonSpy, SinonFakeTimers, SinonStubbedMember};
+}
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.js b/polygerrit-ui/app/test/common-test-setup-karma.js
deleted file mode 100644
index cc934fc..0000000
--- a/polygerrit-ui/app/test/common-test-setup-karma.js
+++ /dev/null
@@ -1,188 +0,0 @@
-/**
- * @license
- * 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.
- */
-import './common-test-setup.js';
-import '@polymer/test-fixture/test-fixture.js';
-import 'chai/chai.js';
-self.assert = window.chai.assert;
-self.expect = window.chai.expect;
-
-window.addEventListener('error', e => {
- // For uncaught error mochajs doesn't print the full stack trace.
- // We should print it ourselves.
- console.error(e.error.stack.toString());
-});
-
-let originalOnBeforeUnload;
-
-suiteSetup(() => {
- // This suiteSetup() method is called only once before all tests
-
- // Can't use window.addEventListener("beforeunload",...) here,
- // the handler is raised too late.
- originalOnBeforeUnload = window.onbeforeunload;
- window.onbeforeunload = e => {
- // If a test reloads a page, we can't prevent it.
- // However we can print earror and the stack trace with assert.fail
- try {
- throw new Error();
- } catch (e) {
- console.error('Page reloading attempt detected.');
- console.error(e.stack.toString());
- }
- originalOnBeforeUnload(e);
- };
-});
-
-suiteTeardown(() => {
- // This suiteTeardown() method is called only once after all tests
- window.onbeforeunload = originalOnBeforeUnload;
-});
-
-// Tests can use fake timers (sandbox.useFakeTimers)
-// Keep the original one for use in test utils methods.
-const nativeSetTimeout = window.setTimeout;
-
-/**
- * Triggers a flush of any pending events, observations, etc and calls you back
- * after they have been processed if callback is passed; otherwise returns
- * promise.
- *
- * @param {function()} callback
- */
-function flush(callback) {
- // Ideally, this function would be a call to Polymer.dom.flush, but that
- // doesn't support a callback yet
- // (https://github.com/Polymer/polymer-dev/issues/851)
- window.Polymer.dom.flush();
- if (callback) {
- nativeSetTimeout(callback, 0);
- } else {
- return new Promise(resolve => {
- nativeSetTimeout(resolve, 0);
- });
- }
-}
-
-self.flush = flush;
-
-class TestFixtureIdProvider {
- static get instance() {
- if (!TestFixtureIdProvider._instance) {
- TestFixtureIdProvider._instance = new TestFixtureIdProvider();
- }
- return TestFixtureIdProvider._instance;
- }
-
- constructor() {
- this.fixturesCount = 1;
- }
-
- generateNewFixtureId() {
- this.fixturesCount++;
- return `fixture-${this.fixturesCount}`;
- }
-}
-
-class TestFixture {
- constructor(fixtureId) {
- this.fixtureId = fixtureId;
- }
-
- /**
- * Create an instance of a fixture's template.
- *
- * @param {Object} model - see Data-bound sections at
- * https://www.webcomponents.org/element/@polymer/test-fixture
- * @return {HTMLElement | HTMLElement[]} - if the fixture's template contains
- * a single element, returns the appropriated instantiated element.
- * Otherwise, it return an array of all instantiated elements from the
- * template.
- */
- instantiate(model) {
- // The window.fixture method is defined in common-test-setup.js
- return window.fixture(this.fixtureId, model);
- }
-}
-
-/**
- * Wraps provided template to a test-fixture tag and adds test-fixture to
- * the document. You can use the html function to create a template.
- *
- * Example:
- * import {html} from '@polymer/polymer/lib/utils/html-tag.js';
- *
- * // Create fixture at the root level of a test file
- * const basicTestFixture = fixtureFromTemplate(html`
- * <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
- * <ul>
- * <li>A</li>
- * <li>B</li>
- * <li>C</li>
- * <li>D</li>
- * </ul>
- * `);
- * ...
- * // Instantiate fixture when needed:
- *
- * suite('example') {
- * let elements;
- * setup(() => {
- * elements = basicTestFixture.instantiate();
- * });
- * }
- *
- * @param {HTMLTemplateElement} template - a template for a fixture
- * @return {TestFixture} - the instance of TestFixture class
- */
-function fixtureFromTemplate(template) {
- const fixtureId = TestFixtureIdProvider.instance.generateNewFixtureId();
- const testFixture = document.createElement('test-fixture');
- testFixture.setAttribute('id', fixtureId);
- testFixture.appendChild(template);
- document.body.appendChild(testFixture);
- return new TestFixture(fixtureId);
-}
-
-/**
- * Wraps provided tag to a test-fixture/template tags and adds test-fixture
- * to the document.
- *
- * Example:
- *
- * // Create fixture at the root level of a test file
- * const basicTestFixture = fixtureFromElement('gr-diff-view');
- * ...
- * // Instantiate fixture when needed:
- *
- * suite('example') {
- * let element;
- * setup(() => {
- * element = basicTestFixture.instantiate();
- * });
- * }
- *
- * @param {HTMLTemplateElement} template - a template for a fixture
- * @return {TestFixture} - the instance of TestFixture class
- */
-function fixtureFromElement(tagName) {
- const template = document.createElement('template');
- template.innerHTML = `<${tagName}></${tagName}>`;
- return fixtureFromTemplate(template);
-}
-
-window.fixtureFromTemplate = fixtureFromTemplate;
-window.fixtureFromElement = fixtureFromElement;
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.ts b/polygerrit-ui/app/test/common-test-setup-karma.ts
new file mode 100644
index 0000000..3d07d8a
--- /dev/null
+++ b/polygerrit-ui/app/test/common-test-setup-karma.ts
@@ -0,0 +1,207 @@
+/**
+ * @license
+ * 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.
+ */
+import './common-test-setup';
+import '@polymer/test-fixture/test-fixture';
+import 'chai/chai';
+
+declare global {
+ interface Window {
+ flush: typeof flushImpl;
+ fixtureFromTemplate: typeof fixtureFromTemplateImpl;
+ fixtureFromElement: typeof fixtureFromElementImpl;
+ }
+ let flush: typeof flushImpl;
+ let fixtureFromTemplate: typeof fixtureFromTemplateImpl;
+ let fixtureFromElement: typeof fixtureFromElementImpl;
+}
+
+// Workaround for https://github.com/karma-runner/karma-mocha/issues/227
+let unhandledError: ErrorEvent;
+
+window.addEventListener('error', e => {
+ // For uncaught error mochajs doesn't print the full stack trace.
+ // We should print it ourselves.
+ console.error('Uncaught error:');
+ console.error(e.error.stack.toString());
+ unhandledError = e;
+});
+
+let originalOnBeforeUnload: typeof window.onbeforeunload;
+
+suiteSetup(() => {
+ // This suiteSetup() method is called only once before all tests
+
+ // Can't use window.addEventListener("beforeunload",...) here,
+ // the handler is raised too late.
+ originalOnBeforeUnload = window.onbeforeunload;
+ window.onbeforeunload = function (e: BeforeUnloadEvent) {
+ // If a test reloads a page, we can't prevent it.
+ // However we can print earror and the stack trace with assert.fail
+ try {
+ throw new Error();
+ } catch (e) {
+ console.error('Page reloading attempt detected.');
+ console.error(e.stack.toString());
+ }
+ if (originalOnBeforeUnload) {
+ originalOnBeforeUnload.call(this, e);
+ }
+ };
+});
+
+suiteTeardown(() => {
+ // This suiteTeardown() method is called only once after all tests
+ window.onbeforeunload = originalOnBeforeUnload;
+ if (unhandledError) {
+ throw unhandledError;
+ }
+});
+
+// Tests can use fake timers (sandbox.useFakeTimers)
+// Keep the original one for use in test utils methods.
+const nativeSetTimeout = window.setTimeout;
+
+function flushImpl(): Promise<void>;
+function flushImpl(callback: () => void): void;
+/**
+ * Triggers a flush of any pending events, observations, etc and calls you back
+ * after they have been processed if callback is passed; otherwise returns
+ * promise.
+ */
+function flushImpl(callback?: () => void): Promise<void> | void {
+ // Ideally, this function would be a call to Polymer.dom.flush, but that
+ // doesn't support a callback yet
+ // (https://github.com/Polymer/polymer-dev/issues/851)
+ // The type is used only in one place, disable eslint warning instead of
+ // creating an interface
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any).Polymer.dom.flush();
+ if (callback) {
+ nativeSetTimeout(callback, 0);
+ } else {
+ return new Promise(resolve => {
+ nativeSetTimeout(resolve, 0);
+ });
+ }
+}
+
+self.flush = flushImpl;
+
+class TestFixtureIdProvider {
+ public static readonly instance: TestFixtureIdProvider = new TestFixtureIdProvider();
+
+ private fixturesCount = 1;
+
+ generateNewFixtureId() {
+ this.fixturesCount++;
+ return `fixture-${this.fixturesCount}`;
+ }
+}
+
+interface TagTestFixture<T extends Element> {
+ instantiate(model?: unknown): T;
+}
+
+class TestFixture {
+ constructor(private readonly fixtureId: string) {}
+
+ /**
+ * Create an instance of a fixture's template.
+ *
+ * @param model - see Data-bound sections at
+ * https://www.webcomponents.org/element/@polymer/test-fixture
+ * @return - if the fixture's template contains
+ * a single element, returns the appropriated instantiated element.
+ * Otherwise, it return an array of all instantiated elements from the
+ * template.
+ */
+ instantiate(model?: unknown): HTMLElement | HTMLElement[] {
+ // The window.fixture method is defined in common-test-setup.js
+ return window.fixture(this.fixtureId, model);
+ }
+}
+
+/**
+ * Wraps provided template to a test-fixture tag and adds test-fixture to
+ * the document. You can use the html function to create a template.
+ *
+ * Example:
+ * import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+ *
+ * // Create fixture at the root level of a test file
+ * const basicTestFixture = fixtureFromTemplate(html`
+ * <gr-cursor-manager cursor-target-class="targeted"></gr-cursor-manager>
+ * <ul>
+ * <li>A</li>
+ * <li>B</li>
+ * <li>C</li>
+ * <li>D</li>
+ * </ul>
+ * `);
+ * ...
+ * // Instantiate fixture when needed:
+ *
+ * suite('example') {
+ * let elements;
+ * setup(() => {
+ * elements = basicTestFixture.instantiate();
+ * });
+ * }
+ *
+ * @param template - a template for a fixture
+ */
+function fixtureFromTemplateImpl(template: HTMLTemplateElement): TestFixture {
+ const fixtureId = TestFixtureIdProvider.instance.generateNewFixtureId();
+ const testFixture = document.createElement('test-fixture');
+ testFixture.setAttribute('id', fixtureId);
+ testFixture.appendChild(template);
+ document.body.appendChild(testFixture);
+ return new TestFixture(fixtureId);
+}
+
+/**
+ * Wraps provided tag to a test-fixture/template tags and adds test-fixture
+ * to the document.
+ *
+ * Example:
+ *
+ * // Create fixture at the root level of a test file
+ * const basicTestFixture = fixtureFromElement('gr-diff-view');
+ * ...
+ * // Instantiate fixture when needed:
+ *
+ * suite('example') {
+ * let element;
+ * setup(() => {
+ * element = basicTestFixture.instantiate();
+ * });
+ * }
+ *
+ * @param tagName - a template for a fixture is <tagName></tagName>
+ */
+function fixtureFromElementImpl<T extends keyof HTMLElementTagNameMap>(
+ tagName: T
+): TagTestFixture<HTMLElementTagNameMap[T]> {
+ const template = document.createElement('template');
+ template.innerHTML = `<${tagName}></${tagName}>`;
+ return (fixtureFromTemplate(template) as unknown) as TagTestFixture<
+ HTMLElementTagNameMap[T]
+ >;
+}
+
+window.fixtureFromTemplate = fixtureFromTemplateImpl;
+window.fixtureFromElement = fixtureFromElementImpl;
diff --git a/polygerrit-ui/app/test/common-test-setup.js b/polygerrit-ui/app/test/common-test-setup.js
deleted file mode 100644
index 19465a3..0000000
--- a/polygerrit-ui/app/test/common-test-setup.js
+++ /dev/null
@@ -1,146 +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.
- */
-
-// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
-// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
-import '../scripts/bundled-polymer.js';
-import './test-app-context-init.js';
-import 'polymer-resin/standalone/polymer-resin.js';
-import '@polymer/iron-test-helpers/iron-test-helpers.js';
-import './test-router.js';
-import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
-import {_testOnlyResetRestApi} from '../elements/shared/gr-js-api-interface/gr-plugin-rest-api.js';
-import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface.js';
-import {cleanupTestUtils, TestKeyboardShortcutBinder} from './test-utils.js';
-import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
-import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import sinon from 'sinon/pkg/sinon-esm.js';
-import {safeTypesBridge} from '../utils/safe-types-util.js';
-window.sinon = sinon;
-
-security.polymer_resin.install({
- allowedIdentifierPrefixes: [''],
- reportHandler(isViolation, fmt, ...args) {
- const log = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
- log(isViolation, fmt, ...args);
- if (isViolation) {
- // This will cause the test to fail if there is a data binding
- // violation.
- throw new Error(
- 'polymer-resin violation: ' + fmt +
- JSON.stringify(args));
- }
- },
- safeTypesBridge,
-});
-
-const cleanups = [];
-
-// For karma always set our implementation
-// (karma doesn't provide the fixture method)
-window.fixture = function(fixtureId, model) {
- // This method is inspired by web-component-tester method
- cleanups.push(() => document.getElementById(fixtureId).restore());
- return document.getElementById(fixtureId).create(model);
-};
-
-setup(() => {
- // If the following asserts fails - then window.stub is
- // overwritten by some other code.
- assert.equal(cleanups.length, 0);
- // The following calls is nessecary to avoid influence of previously executed
- // tests.
- TestKeyboardShortcutBinder.push();
- const mgr = _testOnly_getShortcutManagerInstance();
- assert.equal(mgr.activeHosts.size, 0);
- assert.equal(mgr.listeners.size, 0);
- document.getSelection().removeAllRanges();
- const pl = _testOnly_resetPluginLoader();
- // For testing, always init with empty plugin list
- // Since when serve in gr-app, we always retrieve the list
- // from project config and init loading after that, all
- // `awaitPluginsLoaded` will rely on that to kick off,
- // in testing, we want to kick start this earlier.
- // You still can manually call _testOnly_resetPluginLoader
- // to reset this behavior if you need to test something specific.
- pl.loadPlugins([]);
- _testOnlyResetGrRestApiSharedObjects();
- _testOnlyResetRestApi();
-});
-
-// For karma always set our implementation
-// (karma doesn't provide the stub method)
-window.stub = function(tagName, implementation) {
- // This method is inspired by web-component-tester method
- const proto = document.createElement(tagName).constructor.prototype;
- const stubs = Object.keys(implementation)
- .map(key => sinon.stub(proto, key).callsFake(implementation[key]));
- cleanups.push(() => {
- stubs.forEach(stub => {
- stub.restore();
- });
- });
-};
-
-// Very simple function to catch unexpected elements in documents body.
-// It can't catch everything, but in most cases it is enough.
-function checkChildAllowed(element) {
- const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER'];
- if (allowedTags.includes(element.tagName)) {
- return;
- }
- if (element.tagName === 'TEST-FIXTURE') {
- if (element.children.length == 0 ||
- (element.children.length == 1 &&
- element.children[0].tagName === 'TEMPLATE')) {
- return;
- }
- assert.fail(`Test fixture
- ${element.outerHTML}` +
- `isn't resotred after the test is finished. Please ensure that ` +
- `restore() method is called for this test-fixture. Usually the call` +
- `happens automatically.`);
- return;
- }
- if (element.tagName === 'DIV' && element.id === 'gr-hovercard-container' &&
- element.childNodes.length === 0) {
- return;
- }
- assert.fail(
- `The following node remains in document after the test:
- ${element.tagName}
- Outer HTML:
- ${element.outerHTML},
- Stack trace:
- ${element.stackTrace}`);
-}
-function checkGlobalSpace() {
- for (const child of document.body.children) {
- checkChildAllowed(child);
- }
-}
-
-teardown(() => {
- sinon.restore();
- cleanupTestUtils();
- cleanups.forEach(cleanup => cleanup());
- cleanups.splice(0);
- TestKeyboardShortcutBinder.pop();
- checkGlobalSpace();
- // Clean Polymer debouncer queue, so next tests will not be affected.
- flushDebouncers();
-});
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
new file mode 100644
index 0000000..71c45f7
--- /dev/null
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -0,0 +1,201 @@
+/**
+ * @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.
+ */
+// This should be the first import to install handler before any other code
+import './source-map-support-install';
+// TODO(dmfilippov): remove bundled-polymer.js imports when the following issue
+// https://github.com/Polymer/polymer-resin/issues/9 is resolved.
+import '../scripts/bundled-polymer';
+import '@polymer/iron-test-helpers/iron-test-helpers';
+import './test-router';
+import {_testOnlyInitAppContext} from './test-app-context-init';
+import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {_testOnlyResetRestApi} from '../elements/shared/gr-js-api-interface/gr-plugin-rest-api';
+import {_testOnlyResetGrRestApiSharedObjects} from '../elements/shared/gr-rest-api-interface/gr-rest-api-interface';
+import {
+ cleanupTestUtils,
+ getCleanupsCount,
+ registerTestCleanup,
+ TestKeyboardShortcutBinder,
+} from './test-utils';
+import {flushDebouncers} from '@polymer/polymer/lib/utils/debounce';
+import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import sinon, {SinonSpy} from 'sinon/pkg/sinon-esm';
+import {safeTypesBridge} from '../utils/safe-types-util';
+import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
+import {initGlobalVariables} from '../elements/gr-app-global-var-init';
+import 'chai/chai';
+import {
+ _testOnly_defaultResinReportHandler,
+ installPolymerResin,
+} from '../scripts/polymer-resin-install';
+import {hasOwnProperty} from '../utils/common-util';
+
+declare global {
+ interface Window {
+ assert: typeof chai.assert;
+ expect: typeof chai.expect;
+ fixture: typeof fixtureImpl;
+ stub: typeof stubImpl;
+ sinon: typeof sinon;
+ }
+ let assert: typeof chai.assert;
+ let expect: typeof chai.expect;
+ let stub: typeof stubImpl;
+ let sinon: typeof sinon;
+}
+window.assert = chai.assert;
+window.expect = chai.expect;
+
+window.sinon = sinon;
+
+installPolymerResin(safeTypesBridge, (isViolation, fmt, ...args) => {
+ const log = _testOnly_defaultResinReportHandler;
+ log(isViolation, fmt, ...args);
+ if (isViolation) {
+ // This will cause the test to fail if there is a data binding
+ // violation.
+ throw new Error('polymer-resin violation: ' + fmt + JSON.stringify(args));
+ }
+});
+
+interface TestFixtureElement extends HTMLElement {
+ restore(): void;
+ create(model?: unknown): HTMLElement | HTMLElement[];
+}
+
+function getFixtureElementById(fixtureId: string) {
+ return document.getElementById(fixtureId) as TestFixtureElement;
+}
+
+// For karma always set our implementation
+// (karma doesn't provide the fixture method)
+function fixtureImpl(fixtureId: string, model: unknown) {
+ // This method is inspired by web-component-tester method
+ registerTestCleanup(() => getFixtureElementById(fixtureId).restore());
+ return getFixtureElementById(fixtureId).create(model);
+}
+
+window.fixture = fixtureImpl;
+
+setup(() => {
+ window.Gerrit = {};
+ initGlobalVariables();
+
+ // If the following asserts fails - then window.stub is
+ // overwritten by some other code.
+ assert.equal(getCleanupsCount(), 0);
+ // The following calls is nessecary to avoid influence of previously executed
+ // tests.
+ TestKeyboardShortcutBinder.push();
+ _testOnlyInitAppContext();
+ _testOnly_initGerritPluginApi();
+ const mgr = _testOnly_getShortcutManagerInstance();
+ assert.isTrue(mgr._testOnly_isEmpty());
+ const selection = document.getSelection();
+ if (selection) {
+ selection.removeAllRanges();
+ }
+ const pl = _testOnly_resetPluginLoader();
+ // For testing, always init with empty plugin list
+ // Since when serve in gr-app, we always retrieve the list
+ // from project config and init loading after that, all
+ // `awaitPluginsLoaded` will rely on that to kick off,
+ // in testing, we want to kick start this earlier.
+ // You still can manually call _testOnly_resetPluginLoader
+ // to reset this behavior if you need to test something specific.
+ pl.loadPlugins([]);
+ _testOnlyResetGrRestApiSharedObjects();
+ _testOnlyResetRestApi();
+});
+
+// For karma always set our implementation
+// (karma doesn't provide the stub method)
+function stubImpl<T extends keyof HTMLElementTagNameMap>(
+ tagName: T,
+ implementation: Partial<HTMLElementTagNameMap[T]>
+) {
+ // This method is inspired by web-component-tester method
+ const proto = document.createElement(tagName).constructor
+ .prototype as HTMLElementTagNameMap[T];
+ let key: keyof HTMLElementTagNameMap[T];
+ const stubs: SinonSpy[] = [];
+ for (key in implementation) {
+ if (hasOwnProperty(implementation, key)) {
+ stubs.push(sinon.stub(proto, key).callsFake(implementation[key]));
+ }
+ }
+ registerTestCleanup(() => {
+ stubs.forEach(stub => {
+ stub.restore();
+ });
+ });
+}
+
+window.stub = stubImpl;
+
+// Very simple function to catch unexpected elements in documents body.
+// It can't catch everything, but in most cases it is enough.
+function checkChildAllowed(element: Element) {
+ const allowedTags = ['SCRIPT', 'IRON-A11Y-ANNOUNCER'];
+ if (allowedTags.includes(element.tagName)) {
+ return;
+ }
+ if (element.tagName === 'TEST-FIXTURE') {
+ if (
+ element.children.length === 0 ||
+ (element.children.length === 1 &&
+ element.children[0].tagName === 'TEMPLATE')
+ ) {
+ return;
+ }
+ assert.fail(
+ `Test fixture
+ ${element.outerHTML}` +
+ "isn't resotred after the test is finished. Please ensure that " +
+ 'restore() method is called for this test-fixture. Usually the call' +
+ 'happens automatically.'
+ );
+ return;
+ }
+ if (
+ element.tagName === 'DIV' &&
+ element.id === 'gr-hovercard-container' &&
+ element.childNodes.length === 0
+ ) {
+ return;
+ }
+ assert.fail(
+ `The following node remains in document after the test:
+ ${element.tagName}
+ Outer HTML:
+ ${element.outerHTML}`
+ );
+}
+function checkGlobalSpace() {
+ for (const child of document.body.children) {
+ checkChildAllowed(child);
+ }
+}
+
+teardown(() => {
+ sinon.restore();
+ cleanupTestUtils();
+ TestKeyboardShortcutBinder.pop();
+ checkGlobalSpace();
+ // Clean Polymer debouncer queue, so next tests will not be affected.
+ flushDebouncers();
+});
diff --git a/polygerrit-ui/app/test/source-map-support-install.ts b/polygerrit-ui/app/test/source-map-support-install.ts
new file mode 100644
index 0000000..b8798e2
--- /dev/null
+++ b/polygerrit-ui/app/test/source-map-support-install.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * 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.
+ */
+
+// Mark the file as a module. Otherwise typescript assumes this is a script
+// and doesn't allow "declare global".
+// See: https://www.typescriptlang.org/docs/handbook/modules.html
+export {};
+
+declare global {
+ interface Window {
+ sourceMapSupport: {
+ install(): void;
+ };
+ }
+}
+
+// The karma.conf.js file loads required module before any other modules
+// The source-map-support.js can't be imported with import ... statement
+window.sourceMapSupport.install();
diff --git a/polygerrit-ui/app/test/test-app-context-init.js b/polygerrit-ui/app/test/test-app-context-init.js
deleted file mode 100644
index 6fffdba..0000000
--- a/polygerrit-ui/app/test/test-app-context-init.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-// Init app context before any other imports
-import {initAppContext} from '../services/app-context-init.js';
-import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
-import {appContext} from '../services/app-context.js';
-
-initAppContext();
-
-function setMock(serviceName, setupMock) {
- Object.defineProperty(appContext, serviceName, {
- get() {
- return setupMock;
- },
- });
-}
-setMock('reportingService', grReportingMock);
diff --git a/polygerrit-ui/app/test/test-app-context-init.ts b/polygerrit-ui/app/test/test-app-context-init.ts
new file mode 100644
index 0000000..7f19903
--- /dev/null
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -0,0 +1,37 @@
+/**
+ * @license
+ * 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.
+ */
+
+// Init app context before any other imports
+import {initAppContext} from '../services/app-context-init';
+import {grReportingMock} from '../services/gr-reporting/gr-reporting_mock';
+import {AppContext, appContext} from '../services/app-context';
+
+export function _testOnlyInitAppContext() {
+ initAppContext();
+
+ function setMock<T extends keyof AppContext>(
+ serviceName: T,
+ setupMock: AppContext[T]
+ ) {
+ Object.defineProperty(appContext, serviceName, {
+ get() {
+ return setupMock;
+ },
+ });
+ }
+ setMock('reportingService', grReportingMock);
+}
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
new file mode 100644
index 0000000..3b464a5
--- /dev/null
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -0,0 +1,415 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {
+ AccountId,
+ AccountInfo,
+ AccountsConfigInfo,
+ ApprovalInfo,
+ AuthInfo,
+ BranchName,
+ ChangeConfigInfo,
+ ChangeId,
+ ChangeInfo,
+ ChangeInfoId,
+ ChangeMessageId,
+ ChangeMessageInfo,
+ ChangeViewChangeInfo,
+ CommentLinkInfo,
+ CommentLinks,
+ CommitId,
+ CommitInfo,
+ ConfigInfo,
+ DownloadInfo,
+ EditPatchSetNum,
+ GerritInfo,
+ EmailAddress,
+ GitPersonInfo,
+ GitRef,
+ InheritedBooleanInfo,
+ MaxObjectSizeLimitInfo,
+ MergeableInfo,
+ NumericChangeId,
+ PatchSetNum,
+ PluginConfigInfo,
+ PreferencesInfo,
+ RepoName,
+ Reviewers,
+ RevisionInfo,
+ SchemesInfoMap,
+ ServerInfo,
+ SubmitTypeInfo,
+ SuggestInfo,
+ Timestamp,
+ TimezoneOffset,
+ UserConfigInfo,
+ AccountDetailInfo,
+} from '../types/common';
+import {
+ AccountsVisibility,
+ AppTheme,
+ AuthType,
+ ChangeStatus,
+ DateFormat,
+ DefaultBase,
+ DefaultDisplayNameConfig,
+ DiffViewMode,
+ EmailStrategy,
+ InheritedBooleanInfoConfiguredValue,
+ MergeabilityComputationBehavior,
+ RevisionKind,
+ SubmitType,
+ TimeFormat,
+} from '../constants/constants';
+import {formatDate} from '../utils/date-util';
+import {GetDiffCommentsOutput} from '../services/services/gr-rest-api/gr-rest-api';
+import {AppElementChangeViewParams} from '../elements/gr-app-types';
+import {GerritView} from '../elements/core/gr-navigation/gr-navigation';
+import {
+ EditRevisionInfo,
+ ParsedChangeInfo,
+} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+export function dateToTimestamp(date: Date): Timestamp {
+ const nanosecondSuffix = '.000000000';
+ return (formatDate(date, 'YYYY-MM-DD HH:mm:ss') +
+ nanosecondSuffix) as Timestamp;
+}
+
+export function createCommentLink(match = 'test'): CommentLinkInfo {
+ return {
+ match,
+ };
+}
+
+export function createInheritedBoolean(value = false): InheritedBooleanInfo {
+ return {
+ value,
+ configured_value: value
+ ? InheritedBooleanInfoConfiguredValue.TRUE
+ : InheritedBooleanInfoConfiguredValue.FALSE,
+ };
+}
+
+export function createMaxObjectSizeLimit(): MaxObjectSizeLimitInfo {
+ return {};
+}
+
+export function createSubmitType(
+ value: Exclude<SubmitType, SubmitType.INHERIT> = SubmitType.MERGE_IF_NECESSARY
+): SubmitTypeInfo {
+ return {
+ value,
+ configured_value: SubmitType.INHERIT,
+ inherited_value: value,
+ };
+}
+
+export function createCommentLinks(): CommentLinks {
+ return {};
+}
+
+export function createConfig(): ConfigInfo {
+ return {
+ private_by_default: createInheritedBoolean(),
+ work_in_progress_by_default: createInheritedBoolean(),
+ max_object_size_limit: createMaxObjectSizeLimit(),
+ default_submit_type: createSubmitType(),
+ submit_type: SubmitType.INHERIT,
+ commentlinks: createCommentLinks(),
+ };
+}
+
+export function createAccountWithId(id = 5): AccountInfo {
+ return {
+ _account_id: id as AccountId,
+ };
+}
+
+export function createAccountDetailWithId(id = 5): AccountDetailInfo {
+ return {
+ _account_id: id as AccountId,
+ registered_on: dateToTimestamp(new Date(2020, 10, 15, 14, 5, 8)),
+ };
+}
+
+export function createAccountWithEmail(email = 'test@'): AccountInfo {
+ return {
+ email: email as EmailAddress,
+ };
+}
+
+export function createAccountWithIdNameAndEmail(id = 5): AccountInfo {
+ return {
+ _account_id: id as AccountId,
+ email: `user-${id}@` as EmailAddress,
+ name: `User-${id}`,
+ };
+}
+
+export function createReviewers(): Reviewers {
+ return {};
+}
+
+export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
+export const TEST_BRANCH_ID: BranchName = 'test-branch' as BranchName;
+export const TEST_CHANGE_ID: ChangeId = 'TestChangeId' as ChangeId;
+export const TEST_CHANGE_INFO_ID: ChangeInfoId = `${TEST_PROJECT_NAME}~${TEST_BRANCH_ID}~${TEST_CHANGE_ID}` as ChangeInfoId;
+export const TEST_SUBJECT = 'Test subject';
+export const TEST_NUMERIC_CHANGE_ID = 42 as NumericChangeId;
+
+export const TEST_CHANGE_CREATED = new Date(2020, 1, 1, 1, 2, 3);
+export const TEST_CHANGE_UPDATED = new Date(2020, 10, 6, 5, 12, 34);
+
+export function createGitPerson(name = 'Test name'): GitPersonInfo {
+ return {
+ name,
+ email: `${name}@`,
+ date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
+ tz: 0 as TimezoneOffset,
+ };
+}
+
+export function createCommit(): CommitInfo {
+ return {
+ parents: [],
+ author: createGitPerson(),
+ committer: createGitPerson(),
+ subject: 'Test commit subject',
+ message: 'Test commit message',
+ };
+}
+
+export function createRevision(patchSetNum = 1): RevisionInfo {
+ return {
+ _number: patchSetNum as PatchSetNum,
+ commit: createCommit(),
+ created: dateToTimestamp(TEST_CHANGE_CREATED),
+ kind: RevisionKind.REWORK,
+ ref: 'refs/changes/5/6/1' as GitRef,
+ uploader: createAccountWithId(),
+ };
+}
+
+export function createEditRevision(): EditRevisionInfo {
+ return {
+ _number: EditPatchSetNum,
+ basePatchNum: 1 as PatchSetNum,
+ commit: createCommit(),
+ };
+}
+
+export function createChangeMessage(id = 'cm_id_1'): ChangeMessageInfo {
+ return {
+ id: id as ChangeMessageId,
+ date: dateToTimestamp(TEST_CHANGE_CREATED),
+ message: `This is a message with id ${id}`,
+ };
+}
+
+export function createRevisions(
+ count: number
+): {[revisionId: string]: RevisionInfo} {
+ const revisions: {[revisionId: string]: RevisionInfo} = {};
+ const revisionDate = TEST_CHANGE_CREATED;
+ const revisionIdStart = 1; // The same as getCurrentRevision
+ for (let i = 0; i < count; i++) {
+ const revisionId = (i + revisionIdStart).toString(16);
+ const revision: RevisionInfo = {
+ ...createRevision(i + 1),
+ created: dateToTimestamp(revisionDate),
+ ref: `refs/changes/5/6/${i + 1}` as GitRef,
+ };
+ revisions[revisionId] = revision;
+ // advance 1 day
+ revisionDate.setDate(revisionDate.getDate() + 1);
+ }
+ return revisions;
+}
+
+export function getCurrentRevision(count: number): CommitId {
+ const revisionIdStart = 1; // The same as createRevisions
+ return (count + revisionIdStart).toString(16) as CommitId;
+}
+
+export function createChangeMessages(count: number): ChangeMessageInfo[] {
+ const messageIdStart = 1000;
+ const messages: ChangeMessageInfo[] = [];
+ const messageDate = TEST_CHANGE_CREATED;
+ for (let i = 0; i < count; i++) {
+ messages.push({
+ ...createChangeMessage((i + messageIdStart).toString(16)),
+ date: dateToTimestamp(messageDate),
+ });
+ messageDate.setDate(messageDate.getDate() + 1);
+ }
+ return messages;
+}
+
+export function createChange(): ChangeInfo {
+ return {
+ id: TEST_CHANGE_INFO_ID,
+ project: TEST_PROJECT_NAME,
+ branch: TEST_BRANCH_ID,
+ change_id: TEST_CHANGE_ID,
+ subject: TEST_SUBJECT,
+ status: ChangeStatus.NEW,
+ created: dateToTimestamp(TEST_CHANGE_CREATED),
+ updated: dateToTimestamp(TEST_CHANGE_UPDATED),
+ insertions: 0,
+ deletions: 0,
+ _number: TEST_NUMERIC_CHANGE_ID,
+ owner: createAccountWithId(),
+ // This is documented as optional, but actually always set.
+ reviewers: createReviewers(),
+ };
+}
+
+export function createChangeViewChange(): ChangeViewChangeInfo {
+ return {
+ ...createChange(),
+ revisions: {
+ abc: createRevision(),
+ },
+ current_revision: 'abc' as CommitId,
+ };
+}
+
+export function createParsedChange(): ParsedChangeInfo {
+ return createChangeViewChange();
+}
+
+export function createAccountsConfig(): AccountsConfigInfo {
+ return {
+ visibility: AccountsVisibility.ALL,
+ default_display_name: DefaultDisplayNameConfig.FULL_NAME,
+ };
+}
+
+export function createAuth(): AuthInfo {
+ return {
+ auth_type: AuthType.OPENID,
+ editable_account_fields: [],
+ };
+}
+
+export function createChangeConfig(): ChangeConfigInfo {
+ return {
+ large_change: 500,
+ reply_label: 'Reply',
+ reply_tooltip: 'Reply and score',
+ // The default update_delay is 5 minutes, but we don't want to accidentally
+ // start polling in tests
+ update_delay: 0,
+ mergeability_computation_behavior:
+ MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX,
+ enable_attention_set: false,
+ enable_assignee: false,
+ };
+}
+
+export function createDownloadSchemes(): SchemesInfoMap {
+ return {};
+}
+
+export function createDownloadInfo(): DownloadInfo {
+ return {
+ schemes: createDownloadSchemes(),
+ archives: ['tgz', 'tar'],
+ };
+}
+
+export function createGerritInfo(): GerritInfo {
+ return {
+ all_projects: 'All-Projects',
+ all_users: 'All-Users',
+ doc_search: false,
+ };
+}
+
+export function createPluginConfig(): PluginConfigInfo {
+ return {
+ has_avatars: false,
+ js_resource_paths: [],
+ html_resource_paths: [],
+ };
+}
+
+export function createSuggestInfo(): SuggestInfo {
+ return {
+ from: 0,
+ };
+}
+
+export function createUserConfig(): UserConfigInfo {
+ return {
+ anonymous_coward_name: 'Name of user not set',
+ };
+}
+
+export function createServerInfo(): ServerInfo {
+ return {
+ accounts: createAccountsConfig(),
+ auth: createAuth(),
+ change: createChangeConfig(),
+ download: createDownloadInfo(),
+ gerrit: createGerritInfo(),
+ plugin: createPluginConfig(),
+ suggest: createSuggestInfo(),
+ user: createUserConfig(),
+ };
+}
+
+export function createGetDiffCommentsOutput(): GetDiffCommentsOutput {
+ return {
+ baseComments: [],
+ comments: [],
+ };
+}
+
+export function createMergeable(): MergeableInfo {
+ return {
+ submit_type: SubmitType.MERGE_IF_NECESSARY,
+ mergeable: false,
+ };
+}
+
+export function createPreferences(): PreferencesInfo {
+ return {
+ changes_per_page: 10,
+ theme: AppTheme.LIGHT,
+ date_format: DateFormat.ISO,
+ time_format: TimeFormat.HHMM_24,
+ diff_view: DiffViewMode.SIDE_BY_SIDE,
+ my: [],
+ change_table: [],
+ email_strategy: EmailStrategy.ENABLED,
+ default_base_for_merges: DefaultBase.AUTO_MERGE,
+ };
+}
+
+export function createApproval(): ApprovalInfo {
+ return createAccountWithId();
+}
+
+export function createAppElementChangeViewParams(): AppElementChangeViewParams {
+ return {
+ view: GerritView.CHANGE,
+ changeNum: TEST_NUMERIC_CHANGE_ID,
+ project: TEST_PROJECT_NAME,
+ };
+}
diff --git a/polygerrit-ui/app/test/test-router.js b/polygerrit-ui/app/test/test-router.js
deleted file mode 100644
index 9b89744..0000000
--- a/polygerrit-ui/app/test/test-router.js
+++ /dev/null
@@ -1,19 +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 {GerritNav} from '../elements/core/gr-navigation/gr-navigation.js';
-
-GerritNav.setup(url => { /* noop */ }, params => '', () => []);
diff --git a/polygerrit-ui/app/test/test-router.ts b/polygerrit-ui/app/test/test-router.ts
new file mode 100644
index 0000000..a378e2d
--- /dev/null
+++ b/polygerrit-ui/app/test/test-router.ts
@@ -0,0 +1,28 @@
+/**
+ * @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 {GerritNav} from '../elements/core/gr-navigation/gr-navigation';
+
+GerritNav.setup(
+ () => {
+ /* noop */
+ },
+ () => '',
+ () => [],
+ () => {
+ return {};
+ }
+);
diff --git a/polygerrit-ui/app/test/test-utils.js b/polygerrit-ui/app/test/test-utils.js
deleted file mode 100644
index e1eadef..0000000
--- a/polygerrit-ui/app/test/test-utils.js
+++ /dev/null
@@ -1,125 +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 {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader.js';
-import {testOnly_resetInternalState} from '../elements/shared/gr-js-api-interface/gr-api-utils.js';
-import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints.js';
-import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-
-export const mockPromise = () => {
- let res;
- const promise = new Promise(resolve => {
- res = resolve;
- });
- promise.resolve = res;
- return promise;
-};
-export const isHidden = el => getComputedStyle(el).display === 'none';
-
-// Some tests/elements can define its own binding. We want to restore bindings
-// at the end of the test. The TestKeyboardShortcutBinder store bindings in
-// stack, so it is possible to override bindings in nested suites.
-export class TestKeyboardShortcutBinder {
- static push() {
- if (!this.stack) {
- this.stack = [];
- }
- const testBinder = new TestKeyboardShortcutBinder();
- this.stack.push(testBinder);
- return _testOnly_getShortcutManagerInstance();
- }
-
- static pop() {
- this.stack.pop()._restoreShortcuts();
- }
-
- constructor() {
- this._originalBinding = new Map(
- _testOnly_getShortcutManagerInstance().bindings);
- }
-
- _restoreShortcuts() {
- const bindings = _testOnly_getShortcutManagerInstance().bindings;
- bindings.clear();
- this._originalBinding.forEach((value, key) => {
- bindings.set(key, value);
- });
- }
-}
-
-// Provide reset plugins function to clear installed plugins between tests.
-// No gr-app found (running tests)
-export const resetPlugins = () => {
- testOnly_resetInternalState();
- _testOnly_resetEndpoints();
- const pl = _testOnly_resetPluginLoader();
- pl.loadPlugins([]);
-};
-
-const cleanups = [];
-
-function registerTestCleanup(cleanupCallback) {
- cleanups.push(cleanupCallback);
-}
-
-export function cleanupTestUtils() {
- cleanups.forEach(cleanup => cleanup());
- cleanups.splice(0);
-}
-
-export function stubBaseUrl(newUrl) {
- const originalCanonicalPath = window.CANONICAL_PATH;
- window.CANONICAL_PATH = newUrl;
- registerTestCleanup(() => window.CANONICAL_PATH = originalCanonicalPath);
-}
-
-export function generateChange(options) {
- const change = {
- _number: 42,
- };
- const revisionIdStart = 1;
- const messageIdStart = 1000;
- // We want to distinguish between empty arrays/objects and undefined
- // If an option is not set - the appropriate property is not set
- // If an options is set - the property always set
- if (typeof options.revisionsCount !== 'undefined') {
- const revisions = {};
- for (let i = 0; i < options.revisionsCount; i++) {
- const revisionId = (i + revisionIdStart).toString(16);
- revisions[revisionId] = {
- _number: i+1,
- commit: {parents: []},
- };
- }
- change.revisions = revisions;
- }
- if (typeof options.messagesCount !== 'undefined') {
- const messages = [];
- for (let i = 0; i < options.messagesCount; i++) {
- messages.push({
- id: (i + messageIdStart).toString(16),
- date: new Date(2020, 1, 1),
- message: `This is a message N${i + 1}`,
- });
- }
- change.messages = messages;
- }
- if (options.status) {
- change.status = options.status;
- }
- return change;
-}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
new file mode 100644
index 0000000..7ebc6e1
--- /dev/null
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -0,0 +1,154 @@
+/**
+ * @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 '../types/globals';
+import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
+import {testOnly_resetInternalState} from '../elements/shared/gr-js-api-interface/gr-api-utils';
+import {_testOnly_resetEndpoints} from '../elements/shared/gr-js-api-interface/gr-plugin-endpoints';
+import {
+ _testOnly_getShortcutManagerInstance,
+ Shortcut,
+} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+
+export interface MockPromise extends Promise<unknown> {
+ resolve: (value?: unknown) => void;
+}
+
+export const mockPromise = () => {
+ let res: (value?: unknown) => void;
+ const promise: MockPromise = new Promise(resolve => {
+ res = resolve;
+ }) as MockPromise;
+ promise.resolve = res!;
+ return promise;
+};
+
+export function isHidden(el: Element | undefined | null) {
+ if (!el) return true;
+ return getComputedStyle(el).display === 'none';
+}
+
+export function query(el: Element | undefined, selectors: string) {
+ if (!el) return null;
+ const root = el.shadowRoot || el;
+ return root.querySelector(selectors);
+}
+
+// Some tests/elements can define its own binding. We want to restore bindings
+// at the end of the test. The TestKeyboardShortcutBinder store bindings in
+// stack, so it is possible to override bindings in nested suites.
+export class TestKeyboardShortcutBinder {
+ private static stack: TestKeyboardShortcutBinder[] = [];
+
+ static push() {
+ const testBinder = new TestKeyboardShortcutBinder();
+ this.stack.push(testBinder);
+ return _testOnly_getShortcutManagerInstance();
+ }
+
+ static pop() {
+ const item = this.stack.pop();
+ if (!item) {
+ throw new Error('stack is empty');
+ }
+ item._restoreShortcuts();
+ }
+
+ private readonly originalBinding: Map<Shortcut, string[]>;
+
+ constructor() {
+ this.originalBinding = new Map(
+ _testOnly_getShortcutManagerInstance()._testOnly_getBindings()
+ );
+ }
+
+ _restoreShortcuts() {
+ const bindings = _testOnly_getShortcutManagerInstance()._testOnly_getBindings();
+ bindings.clear();
+ this.originalBinding.forEach((value, key) => {
+ bindings.set(key, value);
+ });
+ }
+}
+
+// Provide reset plugins function to clear installed plugins between tests.
+// No gr-app found (running tests)
+export const resetPlugins = () => {
+ testOnly_resetInternalState();
+ _testOnly_resetEndpoints();
+ const pl = _testOnly_resetPluginLoader();
+ pl.loadPlugins([]);
+};
+
+export type CleanupCallback = () => void;
+
+const cleanups: CleanupCallback[] = [];
+
+export function getCleanupsCount() {
+ return cleanups.length;
+}
+
+export function registerTestCleanup(cleanupCallback: CleanupCallback) {
+ cleanups.push(cleanupCallback);
+}
+
+export function cleanupTestUtils() {
+ cleanups.forEach(cleanup => cleanup());
+ cleanups.splice(0);
+}
+
+export function stubBaseUrl(newUrl: string) {
+ const originalCanonicalPath = window.CANONICAL_PATH;
+ window.CANONICAL_PATH = newUrl;
+ registerTestCleanup(() => (window.CANONICAL_PATH = originalCanonicalPath));
+}
+
+/**
+ * Forcing an opacity of 0 onto the ironOverlayBackdrop is required, because
+ * otherwise the backdrop stays around in the DOM for too long waiting for
+ * an animation to finish. This could be considered to be moved to a
+ * common-test-setup file.
+ */
+export function createIronOverlayBackdropStyleEl() {
+ const ironOverlayBackdropStyleEl = document.createElement('style');
+ document.head.appendChild(ironOverlayBackdropStyleEl);
+ ironOverlayBackdropStyleEl.sheet!.insertRule(
+ 'body { --iron-overlay-backdrop-opacity: 0; }'
+ );
+ return ironOverlayBackdropStyleEl;
+}
+
+/**
+ * Promisify an event callback to simplify async...await tests.
+ *
+ * Use like this:
+ * await listenOnce(el, 'render');
+ * ...
+ */
+export function listenOnce(el: EventTarget, eventType: string) {
+ return new Promise(resolve => {
+ const listener = () => {
+ removeEventListener();
+ resolve();
+ };
+ el.addEventListener(eventType, listener);
+ let removeEventListener = () => {
+ el.removeEventListener(eventType, listener);
+ removeEventListener = () => {};
+ };
+ registerTestCleanup(removeEventListener);
+ });
+}
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index efe575a..15294f4 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -36,14 +36,19 @@
/* Advanced Options */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
- "incremental": true
+ "incremental": true,
+ "experimentalDecorators": true,
+
+ "allowUmdGlobalAccess": true
},
// With the * pattern (without an extension), only supported files
// are included. The supported files are .ts, .tsx, .d.ts.
// If allowJs is set to true, .js and .jsx files are included as well.
// Note: gerrit doesn't have .tsx and .jsx files
"include": [
- // This items below must be in sync with the src_dirs list in the BUILD file
+ // Items below must be in sync with the src_dirs list in the BUILD file
+ // Also items must be in sync with tsconfig_bazel.json, tsconfig_bazel_test.json
+ // (include and exclude arrays are overriden when extends)
"constants/**/*",
"elements/**/*",
"embed/**/*",
@@ -55,7 +60,6 @@
"styles/**/*",
"types/**/*",
"utils/**/*",
- // Directory for test utils (not included in src_dirs in the BUILD file)
"test/**/*"
]
}
diff --git a/polygerrit-ui/app/tsconfig_bazel.json b/polygerrit-ui/app/tsconfig_bazel.json
new file mode 100644
index 0000000..6365bf0
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_bazel.json
@@ -0,0 +1,29 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "typeRoots": [
+ "../../external/ui_npm/node_modules/@types",
+ "../../external/ui_dev_npm/node_modules/@types"
+ ]
+ },
+ "include": [
+ // Items below must be in sync with the src_dirs list in the BUILD file
+ // Also items must be in sync with tsconfig.json, tsconfig_bazel_test.json
+ // (include and exclude arrays are overriden when extends)
+ "constants/**/*",
+ "elements/**/*",
+ "embed/**/*",
+ "gr-diff/**/*",
+ "mixins/**/*",
+ "samples/**/*",
+ "scripts/**/*",
+ "services/**/*",
+ "styles/**/*",
+ "types/**/*",
+ "utils/**/*"
+ ],
+ "exclude": [
+ "**/*_test.ts",
+ "**/*_test.js"
+ ]
+}
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
new file mode 100644
index 0000000..efd2978
--- /dev/null
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -0,0 +1,32 @@
+{
+ "extends": "./tsconfig_bazel.json",
+ "compilerOptions": {
+ "typeRoots": [
+ "./test/@types",
+ "../../external/ui_dev_npm/node_modules/@polymer/iron-test-helpers",
+ "../../external/ui_npm/node_modules/@types",
+ "../../external/ui_dev_npm/node_modules/@types"
+ ],
+ "paths": {
+ "@polymer/iron-test-helpers/*": ["../../ui_dev_npm/node_modules/@polymer/iron-test-helpers/*"]
+ }
+ },
+ "include": [
+ // Items below must be in sync with the src_dirs list in the BUILD file
+ // Also items must be in sync with tsconfig.json, tsconfig_test.json
+ // (include and exclude arrays are overriden when extends)
+ "constants/**/*",
+ "elements/**/*",
+ "embed/**/*",
+ "gr-diff/**/*",
+ "mixins/**/*",
+ "samples/**/*",
+ "scripts/**/*",
+ "services/**/*",
+ "styles/**/*",
+ "types/**/*",
+ "utils/**/*",
+ "test/**/*"
+ ],
+ "exclude": []
+}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
new file mode 100644
index 0000000..96cb02a
--- /dev/null
+++ b/polygerrit-ui/app/types/common.ts
@@ -0,0 +1,2261 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {
+ ChangeStatus,
+ DefaultDisplayNameConfig,
+ FileInfoStatus,
+ GpgKeyInfoStatus,
+ ProblemInfoStatus,
+ ProjectState,
+ RequirementStatus,
+ ReviewerState,
+ RevisionKind,
+ SubmitType,
+ InheritedBooleanInfoConfiguredValue,
+ ConfigParameterInfoType,
+ AccountTag,
+ PermissionAction,
+ HttpMethod,
+ CommentSide,
+ AppTheme,
+ DateFormat,
+ TimeFormat,
+ EmailStrategy,
+ DefaultBase,
+ IgnoreWhitespaceType,
+ UserPriority,
+ DiffViewMode,
+ DraftsAction,
+ NotifyType,
+ EmailFormat,
+ AuthType,
+ MergeStrategy,
+ EditableAccountField,
+ MergeabilityComputationBehavior,
+} from '../constants/constants';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+
+export type BrandType<T, BrandName extends string> = T &
+ {[__brand in BrandName]: never};
+
+/*
+ * In T, make a set of properties whose keys are in the union K required
+ */
+export type RequireProperties<T, K extends keyof T> = Omit<T, K> &
+ Required<Pick<T, K>>;
+
+export type PropertyType<T, K extends keyof T> = ReturnType<() => T[K]>;
+
+export type ElementPropertyDeepChange<
+ T,
+ K extends keyof T
+> = PolymerDeepPropertyChange<PropertyType<T, K>, PropertyType<T, K>>;
+
+/**
+ * Type alias for parsed json object to make code cleaner
+ */
+export type ParsedJSON = BrandType<unknown, '_parsedJSON'>;
+
+export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
+
+export const EditPatchSetNum = 'edit' as PatchSetNum;
+// TODO(TS): This is not correct, it is better to have a separate ApiPatchSetNum
+// without 'parent'.
+export const ParentPatchSetNum = 'PARENT' as PatchSetNum;
+
+export type ChangeId = BrandType<string, '_changeId'>;
+export type ChangeMessageId = BrandType<string, '_changeMessageId'>;
+export type NumericChangeId = BrandType<number, '_numericChangeId'>;
+export type RepoName = BrandType<string, '_repoName'>;
+export type UrlEncodedRepoName = BrandType<string, '_urlEncodedRepoName'>;
+export type TopicName = BrandType<string, '_topicName'>;
+// TODO(TS): Probably, we should separate AccountId and EncodedAccountId
+export type AccountId = BrandType<number, '_accountId'>;
+export type GitRef = BrandType<string, '_gitRef'>;
+export type RequirementType = BrandType<string, '_requirementType'>;
+export type TrackingId = BrandType<string, '_trackingId'>;
+export type ReviewInputTag = BrandType<string, '_reviewInputTag'>;
+export type RobotId = BrandType<string, '_robotId'>;
+export type RobotRunId = BrandType<string, '_robotRunId'>;
+
+// RevisionId '0' is the same as 'current'. However, we want to avoid '0'
+// in our code, so it is not added here as a possible value.
+export type RevisionId = 'current' | CommitId | PatchSetNum;
+
+// The UUID of the suggested fix.
+export type FixId = BrandType<string, '_fixId'>;
+export type EmailAddress = BrandType<string, '_emailAddress'>;
+
+// The URL encoded UUID of the comment
+export type UrlEncodedCommentId = BrandType<string, '_urlEncodedCommentId'>;
+
+// The ID of the dashboard, in the form of '<ref>:<path>'
+export type DashboardId = BrandType<string, '_dahsboardId'>;
+
+// The 8-char hex GPG key ID.
+export type GpgKeyId = BrandType<string, '_gpgKeyId'>;
+
+// The 40-char (plus spaces) hex GPG key fingerprint
+export type GpgKeyFingerprint = BrandType<string, '_gpgKeyFingerprint'>;
+
+// OpenPGP User IDs (https://tools.ietf.org/html/rfc4880#section-5.11).
+export type OpenPgpUserIds = BrandType<string, '_openPgpUserIds'>;
+
+// This ID is equal to the numeric ID of the change that triggered the
+// submission. If the change that triggered the submission also has a topic, it
+// will be "<id>-<topic>" of the change that triggered the submission
+// The callers must not rely on the format of the submission ID.
+export type ChangeSubmissionId = BrandType<
+ string | number,
+ '_changeSubmissionId'
+>;
+
+// The refs/heads/ prefix is omitted in Branch name
+export type BranchName = BrandType<string, '_branchName'>;
+
+// The refs/tags/ prefix is omitted in Tag name
+export type TagName = BrandType<string, '_tagName'>;
+
+// The ID of the change in the format "'<project>~<branch>~<Change-Id>'"
+export type ChangeInfoId = BrandType<string, '_changeInfoId'>;
+export type Hashtag = BrandType<string, '_hashtag'>;
+export type StarLabel = BrandType<string, '_startLabel'>;
+export type CommitId = BrandType<string, '_commitId'>;
+export type LabelName = BrandType<string, '_labelName'>;
+export type GroupName = BrandType<string, '_groupName'>;
+
+// The UUID of the group
+export type GroupId = BrandType<string, '_groupId'>;
+
+// The Encoded UUID of the group
+export type EncodedGroupId = BrandType<string, '_encodedGroupId'>;
+
+// The timezone offset from UTC in minutes
+export type TimezoneOffset = BrandType<number, '_timezoneOffset'>;
+
+// Timestamps are given in UTC and have the format
+// "'yyyy-mm-dd hh:mm:ss.fffffffff'"
+// where "'ffffffffff'" represents nanoseconds.
+export type Timestamp = BrandType<string, '_timestamp'>;
+
+export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
+export type LabelNameToInfoMap = {[labelName: string]: LabelInfo};
+
+// {Verified: ["-1", " 0", "+1"]}
+export type LabelNameToValueMap = {[labelName: string]: string[]};
+
+// The map maps the values (“-2”, “-1”, " `0`", “+1”, “+2”) to the value descriptions.
+export type LabelValueToDescriptionMap = {[labelValue: string]: string};
+
+/**
+ * The LabelInfo entity contains information about a label on a change, always
+ * corresponding to the current patch set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
+ */
+export type LabelInfo =
+ | QuickLabelInfo
+ | DetailedLabelInfo
+ | (QuickLabelInfo & DetailedLabelInfo);
+
+interface LabelCommonInfo {
+ optional?: boolean; // not set if false
+}
+
+export interface QuickLabelInfo extends LabelCommonInfo {
+ approved?: AccountInfo;
+ rejected?: AccountInfo;
+ recommended?: AccountInfo;
+ disliked?: AccountInfo;
+ blocking?: boolean; // not set if false
+ value?: number; // The voting value of the user who recommended/disliked this label on the change if it is not “+1”/“-1”.
+ default_value?: number;
+}
+
+/**
+ * LabelInfo when DETAILED_LABELS are requested.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code
+ */
+export interface DetailedLabelInfo extends LabelCommonInfo {
+ // This is not set when the change has no reviewers.
+ all?: ApprovalInfo[];
+ // Docs claim that 'values' is optional, but it is actually always set.
+ values: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
+ default_value?: number;
+}
+
+export function isQuickLabelInfo(
+ l: LabelInfo
+): l is QuickLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+ const quickLabelInfo = l as QuickLabelInfo;
+ return (
+ quickLabelInfo.approved !== undefined ||
+ quickLabelInfo.rejected !== undefined ||
+ quickLabelInfo.recommended !== undefined ||
+ quickLabelInfo.disliked !== undefined ||
+ quickLabelInfo.blocking !== undefined ||
+ quickLabelInfo.blocking !== undefined ||
+ quickLabelInfo.value !== undefined
+ );
+}
+
+export function isDetailedLabelInfo(
+ label: LabelInfo
+): label is DetailedLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+ return !!(label as DetailedLabelInfo).values;
+}
+
+// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
+export interface ContributorAgreementInput {
+ name?: string;
+}
+
+// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-info
+export interface ContributorAgreementInfo {
+ name: string;
+ description: string;
+ url: string;
+ auto_verify_group?: GroupInfo;
+}
+
+/**
+ * The ChangeInfo entity contains information about a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+ */
+export interface ChangeInfo {
+ id: ChangeInfoId;
+ project: RepoName;
+ branch: BranchName;
+ topic?: TopicName;
+ attention_set?: IdToAttentionSetMap;
+ assignee?: AccountInfo;
+ hashtags?: Hashtag[];
+ change_id: ChangeId;
+ subject: string;
+ status: ChangeStatus;
+ created: Timestamp;
+ updated: Timestamp;
+ submitted?: Timestamp;
+ submitter?: AccountInfo;
+ starred?: boolean; // not set if false
+ stars?: StarLabel[];
+ reviewed?: boolean; // not set if false
+ submit_type?: SubmitType;
+ mergeable?: boolean;
+ submittable?: boolean;
+ insertions: number; // Number of inserted lines
+ deletions: number; // Number of deleted lines
+ total_comment_count?: number;
+ unresolved_comment_count?: number;
+ // TODO(TS): Use changed_id everywhere in code instead of (legacy) _number
+ _number: NumericChangeId;
+ owner: AccountInfo;
+ actions?: ActionNameToActionInfoMap;
+ requirements?: Requirement[];
+ labels?: LabelNameToInfoMap;
+ permitted_labels?: LabelNameToValueMap;
+ removable_reviewers?: AccountInfo[];
+ // This is documented as optional, but actually always set.
+ reviewers: Reviewers;
+ pending_reviewers?: AccountInfo[];
+ reviewer_updates?: ReviewerUpdateInfo[];
+ messages?: ChangeMessageInfo[];
+ current_revision?: CommitId;
+ revisions?: {[revisionId: string]: RevisionInfo};
+ tracking_ids?: TrackingIdInfo[];
+ _more_changes?: boolean; // not set if false
+ problems?: ProblemInfo[];
+ is_private?: boolean; // not set if false
+ work_in_progress?: boolean; // not set if false
+ has_review_started?: boolean; // not set if false
+ revert_of?: NumericChangeId;
+ submission_id?: ChangeSubmissionId;
+ cherry_pick_of_change?: NumericChangeId;
+ cherry_pick_of_patch_set?: PatchSetNum;
+ contains_git_conflicts?: boolean;
+ internalHost?: string; // TODO(TS): provide an explanation what is its
+}
+
+/**
+ * The reviewers as a map that maps a reviewer state to a list of AccountInfo
+ * entities. Possible reviewer states are REVIEWER, CC and REMOVED.
+ * REVIEWER: Users with at least one non-zero vote on the change.
+ * CC: Users that were added to the change, but have not voted.
+ * REMOVED: Users that were previously reviewers on the change, but have been removed.
+ */
+export type Reviewers = Partial<Record<ReviewerState, AccountInfo[]>>;
+
+/**
+ * ChangeView request change detail with ALL_REVISIONS option set.
+ * The response always contains current_revision and revisions.
+ */
+export type ChangeViewChangeInfo = RequireProperties<
+ ChangeInfo,
+ 'current_revision' | 'revisions'
+>;
+/**
+ * The AccountInfo entity contains information about an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-info
+ */
+export interface AccountInfo {
+ // Normally _account_id is defined (for known Gerrit users), but users can
+ // also be CCed just with their email address. So you have to be prepared that
+ // _account_id is undefined, but then email must be set.
+ _account_id?: AccountId;
+ name?: string;
+ display_name?: string;
+ // Must be set, if _account_id is undefined.
+ email?: EmailAddress;
+ secondary_emails?: string[];
+ username?: string;
+ avatars?: AvatarInfo[];
+ _more_accounts?: boolean; // not set if false
+ status?: string; // status message of the account
+ inactive?: boolean; // not set if false
+ tags?: AccountTag[];
+}
+
+export function isAccount(x: AccountInfo | GroupInfo): x is AccountInfo {
+ const account = x as AccountInfo;
+ return account._account_id !== undefined || account.email !== undefined;
+}
+
+export function isGroup(x: AccountInfo | GroupInfo): x is GroupInfo {
+ return (x as GroupInfo).id !== undefined;
+}
+
+/**
+ * The AccountDetailInfo entity contains detailed information about an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-detail-info
+ */
+export interface AccountDetailInfo extends AccountInfo {
+ registered_on: Timestamp;
+}
+
+/**
+ * The AccountExternalIdInfo entity contains information for an external id of
+ * an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-external-id-info
+ */
+export interface AccountExternalIdInfo {
+ identity: string;
+ email?: string;
+ trusted?: boolean;
+ can_delete?: boolean;
+}
+
+/**
+ * The GroupAuditEventInfo entity contains information about an auditevent of a group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupAuditEventInfo {
+ member: string;
+ type: string;
+ user: string;
+ date: string;
+}
+
+/**
+ * The GroupBaseInfo entity contains base information about the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#group-base-info
+ */
+export interface GroupBaseInfo {
+ id: GroupId;
+ name: GroupName;
+}
+
+/**
+ * The GroupInfo entity contains information about a group. This can be a
+ * Gerrit internal group, or an external group that is known to Gerrit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-info
+ */
+export interface GroupInfo {
+ id: GroupId;
+ name?: GroupName;
+ url?: string;
+ options?: GroupOptionsInfo;
+ description?: string;
+ group_id?: string;
+ owner?: string;
+ owner_id?: string;
+ created_on?: string;
+ _more_groups?: boolean;
+ members?: AccountInfo[];
+ includes?: GroupInfo[];
+}
+
+export type GroupNameToGroupInfoMap = {[groupName: string]: GroupInfo};
+
+/**
+ * The 'GroupInput' entity contains information for the creation of a new
+ * internal group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-input
+ */
+export interface GroupInput {
+ name?: GroupName;
+ uuid?: string;
+ description?: string;
+ visible_to_all?: string;
+ owner_id?: string;
+ members?: string[];
+}
+
+/**
+ * Options of the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupOptionsInfo {
+ visible_to_all: boolean;
+}
+
+/**
+ * New options for a group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupOptionsInput {
+ visible_to_all: boolean;
+}
+
+/**
+ * The GroupsInput entity contains information about groups that should be
+ * included into a group or that should be deleted from a group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface GroupsInput {
+ _one_group?: string;
+ groups?: string[];
+}
+
+/**
+ * The MembersInput entity contains information about accounts that should be
+ * added as members to a group or that should be deleted from the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export interface MembersInput {
+ _one_member?: string;
+ members?: string[];
+}
+
+/**
+ * The ActionInfo entity describes a REST API call the client canmake to
+ * manipulate a resource. These are frequently implemented by plugins and may
+ * be discovered at runtime.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info
+ */
+export interface ActionInfo {
+ method?: HttpMethod; // Most actions use POST, PUT or DELETE to cause state changes.
+ label?: string; // Short title to display to a user describing the action
+ title?: string; // Longer text to display describing the action
+ enabled?: boolean; // not set if false
+}
+
+export interface ActionNameToActionInfoMap {
+ [actionType: string]: ActionInfo | undefined;
+ // List of actions explicitly used in code:
+ wip?: ActionInfo;
+ publishEdit?: ActionInfo;
+ rebaseEdit?: ActionInfo;
+ deleteEdit?: ActionInfo;
+ edit?: ActionInfo;
+ stopEdit?: ActionInfo;
+ download?: ActionInfo;
+ rebase?: ActionInfo;
+ cherrypick?: ActionInfo;
+ move?: ActionInfo;
+ revert?: ActionInfo;
+ revert_submission?: ActionInfo;
+ abandon?: ActionInfo;
+ submit?: ActionInfo;
+ topic?: ActionInfo;
+ hashtags?: ActionInfo;
+ assignee?: ActionInfo;
+ ready?: ActionInfo;
+}
+
+/**
+ * The Requirement entity contains information about a requirement relative to
+ * a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#requirement
+ */
+export interface Requirement {
+ status: RequirementStatus;
+ fallbackText: string; // A human readable reason
+ type: RequirementType;
+}
+
+/**
+ * The ReviewerUpdateInfo entity contains information about updates to change’s
+ * reviewers set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-update-info
+ */
+export interface ReviewerUpdateInfo {
+ updated: Timestamp;
+ updated_by: AccountInfo;
+ reviewer: AccountInfo;
+ state: ReviewerState;
+}
+
+/**
+ * The ChangeMessageInfo entity contains information about a messageattached
+ * to a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-message-info
+ */
+export interface ChangeMessageInfo {
+ id: ChangeMessageId;
+ author?: AccountInfo;
+ reviewer?: AccountInfo;
+ updated_by?: AccountInfo;
+ real_author?: AccountInfo;
+ date: Timestamp;
+ message: string;
+ tag?: ReviewInputTag;
+ _revision_number?: PatchSetNum;
+}
+
+/**
+ * The RevisionInfo entity contains information about a patch set.Not all
+ * fields are returned by default. Additional fields can be obtained by
+ * adding o parameters as described in Query Changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revision-info
+ * basePatchNum is present in case RevisionInfo is of type 'edit'
+ */
+export interface RevisionInfo {
+ kind: RevisionKind;
+ _number: PatchSetNum;
+ created: Timestamp;
+ uploader: AccountInfo;
+ ref: GitRef;
+ fetch?: {[protocol: string]: FetchInfo};
+ commit?: CommitInfo;
+ files?: {[filename: string]: FileInfo};
+ actions?: ActionNameToActionInfoMap;
+ reviewed?: boolean;
+ commit_with_footers?: boolean;
+ push_certificate?: PushCertificateInfo;
+ description?: string;
+ basePatchNum?: PatchSetNum;
+}
+
+/**
+ * The TrackingIdInfo entity describes a reference to an external tracking
+ * system.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#tracking-id-info
+ */
+export interface TrackingIdInfo {
+ system: string;
+ id: TrackingId;
+}
+
+/**
+ * The ProblemInfo entity contains a description of a potential consistency
+ * problem with a change. These are not related to the code review process,
+ * but rather indicate some inconsistency in Gerrit’s database or repository
+ * metadata related to the enclosing change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#problem-info
+ */
+export interface ProblemInfo {
+ message: string;
+ status?: ProblemInfoStatus; // Only set if a fix was attempted
+ outcome?: string;
+}
+
+/**
+ * The AttentionSetInfo entity contains details of users that are in the attention set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-info
+ */
+export interface AttentionSetInfo {
+ account: AccountInfo;
+ last_update?: Timestamp;
+ reason?: string;
+}
+
+/**
+ * The ApprovalInfo entity contains information about an approval from auser
+ * for a label on a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#approval-info
+ */
+export interface ApprovalInfo extends AccountInfo {
+ value?: number;
+ permitted_voting_range?: VotingRangeInfo;
+ date?: Timestamp;
+ tag?: ReviewInputTag;
+ post_submit?: boolean; // not set if false
+}
+
+/**
+ * The AvartarInfo entity contains information about an avatar image ofan
+ * account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#avatar-info
+ */
+export interface AvatarInfo {
+ url: string;
+ height: number;
+ width: number;
+}
+
+/**
+ * The FetchInfo entity contains information about how to fetch a patchset via
+ * a certain protocol.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fetch-info
+ */
+export interface FetchInfo {
+ url: string;
+ ref: string;
+ commands?: {[commandName: string]: string};
+}
+
+/**
+ * The CommitInfo entity contains information about a commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
+ */
+export interface CommitInfo {
+ commit?: CommitId;
+ parents: ParentCommitInfo[];
+ author: GitPersonInfo;
+ committer: GitPersonInfo;
+ subject: string;
+ message: string;
+ web_links?: WebLinkInfo[];
+}
+
+export interface CommitInfoWithRequiredCommit extends CommitInfo {
+ commit: CommitId;
+}
+
+/**
+ * Standalone Commit Info.
+ * Same as CommitInfo, except `commit` is required
+ * as it is only optional when used inside of the RevisionInfo.
+ */
+export interface StandaloneCommitInfo extends CommitInfo {
+ commit: CommitId;
+}
+
+/**
+ * The parent commits of this commit as a list of CommitInfo entities.
+ * In each parent only the commit and subject fields are populated.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
+ */
+export interface ParentCommitInfo {
+ commit: CommitId;
+ subject: string;
+}
+
+/**
+ * The FileInfo entity contains information about a file in a patch set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-info
+ */
+export interface FileInfo {
+ status?: FileInfoStatus;
+ binary?: boolean; // not set if false
+ old_path?: string;
+ lines_inserted?: number;
+ lines_deleted?: number;
+ size_delta: number; // in bytes
+ size: number; // in bytes
+}
+
+/**
+ * The PushCertificateInfo entity contains information about a pushcertificate
+ * provided when the user pushed for review with git push
+ * --signed HEAD:refs/for/<branch>. Only used when signed push is
+ * enabled on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#push-certificate-info
+ */
+export interface PushCertificateInfo {
+ certificate: string;
+ key: GpgKeyInfo;
+}
+
+/**
+ * The GpgKeyInfo entity contains information about a GPG public key.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-key-info
+ */
+export interface GpgKeyInfo {
+ id?: GpgKeyId;
+ fingerprint?: GpgKeyFingerprint;
+ user_ids?: OpenPgpUserIds[];
+ key?: string; // ASCII armored public key material
+ status?: GpgKeyInfoStatus;
+ problems?: string[];
+}
+
+/**
+ * The GpgKeysInput entity contains information for adding/deleting GPG keys.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-keys-input
+ */
+export interface GpgKeysInput {
+ add?: string[];
+ delete?: string[];
+}
+
+/**
+ * The GitPersonInfo entity contains information about theauthor/committer of
+ * a commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#git-person-info
+ */
+export interface GitPersonInfo {
+ name: string;
+ email: string;
+ date: Timestamp;
+ tz: TimezoneOffset;
+}
+
+/**
+ * The WebLinkInfo entity describes a link to an external site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
+ */
+export interface WebLinkInfo {
+ name: string;
+ url: string;
+ image_url: string;
+}
+
+/**
+ * The VotingRangeInfo entity describes the continuous voting range from minto
+ * max values.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#voting-range-info
+ */
+export interface VotingRangeInfo {
+ min: number;
+ max: number;
+}
+
+/**
+ * The AccountsConfigInfo entity contains information about Gerrit configuration
+ * from the accounts section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#accounts-config-info
+ */
+export interface AccountsConfigInfo {
+ visibility: string;
+ default_display_name: DefaultDisplayNameConfig;
+}
+
+/**
+ * The AuthInfo entity contains information about the authentication
+ * configuration of the Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export interface AuthInfo {
+ auth_type: AuthType; // docs incorrectly names it 'type'
+ use_contributor_agreements?: boolean;
+ contributor_agreements?: ContributorAgreementInfo;
+ editable_account_fields: EditableAccountField[];
+ login_url?: string;
+ login_text?: string;
+ switch_account_url?: string;
+ register_url?: string;
+ register_text?: string;
+ edit_full_name_url?: string;
+ http_password_url?: string;
+ git_basic_auth_policy?: string;
+}
+
+/**
+ * The CacheInfo entity contains information about a cache.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CacheInfo {
+ name: string;
+ type: string;
+ entries: EntriesInfo;
+ average_get?: string;
+ hit_ratio: HitRatioInfo;
+}
+
+/**
+ * The CacheOperationInput entity contains information about an operation that
+ * should be executed on caches.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CacheOperationInput {
+ operation: string;
+ caches?: string[];
+}
+
+/**
+ * The CapabilityInfo entity contains information about a capability.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#capability-info
+ */
+export interface CapabilityInfo {
+ id: string;
+ name: string;
+}
+
+export type CapabilityInfoMap = {[id: string]: CapabilityInfo};
+
+/**
+ * The ChangeConfigInfo entity contains information about Gerrit configuration
+ * from the change section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-config-info
+ */
+export interface ChangeConfigInfo {
+ allow_blame?: boolean;
+ large_change: number;
+ reply_label: string;
+ reply_tooltip: string;
+ update_delay: number;
+ submit_whole_topic?: boolean;
+ disable_private_changes?: boolean;
+ mergeability_computation_behavior: MergeabilityComputationBehavior;
+ enable_attention_set: boolean;
+ enable_assignee: boolean;
+}
+
+/**
+ * The ChangeIndexConfigInfo entity contains information about Gerrit
+ * configuration from the index.change section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-index-config-info
+ */
+export interface ChangeIndexConfigInfo {
+ index_mergeable?: boolean;
+}
+
+/**
+ * The CheckAccountExternalIdsResultInfo entity contains the result of running
+ * the account external ID consistency check.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CheckAccountExternalIdsResultInfo {
+ problems: string;
+}
+
+/**
+ * The CheckAccountsResultInfo entity contains the result of running the account
+ * consistency check.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CheckAccountsResultInfo {
+ problems: string;
+}
+
+/**
+ * The CheckGroupsResultInfo entity contains the result of running the group
+ * consistency check.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface CheckGroupsResultInfo {
+ problems: string;
+}
+
+/**
+ * The ConsistencyCheckInfo entity contains the results of running consistency
+ * checks.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConsistencyCheckInfo {
+ check_accounts_result?: CheckAccountsResultInfo;
+ check_account_external_ids_result?: CheckAccountExternalIdsResultInfo;
+ check_groups_result?: CheckGroupsResultInfo;
+}
+
+/**
+ * The ConsistencyCheckInput entity contains information about which consistency
+ * checks should be run.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConsistencyCheckInput {
+ check_accounts?: string;
+ check_account_external_ids?: string;
+ check_groups?: string;
+}
+
+/**
+ * The ConsistencyProblemInfo entity contains information about a consistency
+ * problem.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConsistencyProblemInfo {
+ status: string;
+ message: string;
+}
+
+/**
+ * The entity describes the result of a reload of gerrit.config.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConfigUpdateInfo {
+ applied: string;
+ rejected: string;
+}
+
+/**
+ * The entity describes an updated config value.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ConfigUpdateEntryInfo {
+ config_key: string;
+ old_value: string;
+ new_value: string;
+}
+
+export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
+
+/**
+ * The DownloadInfo entity contains information about supported download
+ * options.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#download-info
+ */
+export interface DownloadInfo {
+ schemes: SchemesInfoMap;
+ archives: string[];
+}
+
+export type CloneCommandMap = {[name: string]: string};
+/**
+ * The DownloadSchemeInfo entity contains information about a supported download
+ * scheme and its commands.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface DownloadSchemeInfo {
+ url: string;
+ is_auth_required: boolean;
+ is_auth_supported: boolean;
+ commands: string;
+ clone_commands: CloneCommandMap;
+}
+
+/**
+ * The EmailConfirmationInput entity contains information for confirming an
+ * email address.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface EmailConfirmationInput {
+ token: string;
+}
+
+/**
+ * The EntriesInfo entity contains information about the entries in acache.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface EntriesInfo {
+ mem?: string;
+ disk?: string;
+ space?: string;
+}
+
+/**
+ * The GerritInfo entity contains information about Gerrit configuration from
+ * the gerrit section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#gerrit-info
+ */
+export interface GerritInfo {
+ all_projects: string; // Doc contains incorrect name
+ all_users: string; // Doc contains incorrect name
+ doc_search: boolean;
+ doc_url?: string;
+ edit_gpg_keys?: boolean;
+ report_bug_url?: string;
+ // The following property is missed in doc
+ primary_weblink_name?: string;
+}
+
+/**
+ * The IndexConfigInfo entity contains information about Gerrit configuration
+ * from the index section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#index-config-info
+ */
+export interface IndexConfigInfo {
+ change: ChangeIndexConfigInfo;
+}
+
+/**
+ * The HitRatioInfo entity contains information about the hit ratio of a cache.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface HitRatioInfo {
+ mem: string;
+ disk?: string;
+}
+
+/**
+ * The IndexChangesInput contains a list of numerical changes IDs to index.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface IndexChangesInput {
+ changes: string;
+}
+
+/**
+ * The JvmSummaryInfo entity contains information about the JVM.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface JvmSummaryInfo {
+ vm_vendor: string;
+ vm_name: string;
+ vm_version: string;
+ os_name: string;
+ os_version: string;
+ os_arch: string;
+ user: string;
+ host?: string;
+ current_working_directory: string;
+ site: string;
+}
+
+/**
+ * The MemSummaryInfo entity contains information about the current memory
+ * usage.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface MemSummaryInfo {
+ total: string;
+ used: string;
+ free: string;
+ buffers: string;
+ max: string;
+ open_files?: string;
+}
+
+/**
+ * The PluginConfigInfo entity contains information about Gerrit extensions by
+ * plugins.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#plugin-config-info
+ */
+export interface PluginConfigInfo {
+ has_avatars: boolean;
+ // The following 2 properies exists in Java class, but don't mention in docs
+ js_resource_paths: string[];
+ html_resource_paths: string[];
+}
+
+/**
+ * The ReceiveInfo entity contains information about the configuration of
+ * git-receive-pack behavior on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#receive-info
+ */
+export interface ReceiveInfo {
+ enable_signed_push?: string;
+}
+
+/**
+ * The ServerInfo entity contains information about the configuration of the
+ * Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
+ */
+export interface ServerInfo {
+ accounts: AccountsConfigInfo;
+ auth: AuthInfo;
+ change: ChangeConfigInfo;
+ download: DownloadInfo;
+ gerrit: GerritInfo;
+ // docs mentions index property, but it doesn't exists in Java class
+ // index: IndexConfigInfo;
+ note_db_enabled?: boolean;
+ plugin: PluginConfigInfo;
+ receive?: ReceiveInfo;
+ sshd?: SshdInfo;
+ suggest: SuggestInfo;
+ user: UserConfigInfo;
+ default_theme?: string;
+}
+
+/**
+ * The SshdInfo entity contains information about Gerrit configuration from the sshd section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#sshd-info
+ * This entity doesn’t contain any data, but the presence of this (empty) entity
+ * in the ServerInfo entity means that SSHD is enabled on the server.
+ */
+export type SshdInfo = {};
+
+/**
+ * The SuggestInfo entity contains information about Gerritconfiguration from
+ * the suggest section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
+ */
+export interface SuggestInfo {
+ from: number;
+}
+
+/**
+ * The SummaryInfo entity contains information about the current state of the
+ * server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface SummaryInfo {
+ task_summary: TaskSummaryInfo;
+ mem_summary: MemSummaryInfo;
+ thread_summary: ThreadSummaryInfo;
+ jvm_summary?: JvmSummaryInfo;
+}
+
+/**
+ * The TaskInfo entity contains information about a task in a background work
+ * queue.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface TaskInfo {
+ id: string;
+ state: string;
+ start_time: string;
+ delay: string;
+ command: string;
+ remote_name?: string;
+ project?: string;
+}
+
+/**
+ * The TaskSummaryInfo entity contains information about the current tasks.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface TaskSummaryInfo {
+ total?: string;
+ running?: string;
+ ready?: string;
+ sleeping?: string;
+}
+
+/**
+ * The ThreadSummaryInfo entity contains information about the current threads.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export interface ThreadSummaryInfo {
+ cpus: string;
+ threads: string;
+ counts: string;
+}
+
+/**
+ * The TopMenuEntryInfo entity contains information about a top menu entry.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-entry-info
+ */
+export interface TopMenuEntryInfo {
+ name: string;
+ items: TopMenuItemInfo[];
+}
+
+/**
+ * The TopMenuItemInfo entity contains information about a menu item ina top
+ * menu entry.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#top-menu-item-info
+ */
+export interface TopMenuItemInfo {
+ url: string;
+ name: string;
+ target: string;
+ id?: string;
+}
+
+/**
+ * The UserConfigInfo entity contains information about Gerrit configuration
+ * from the user section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#user-config-info
+ */
+export interface UserConfigInfo {
+ anonymous_coward_name: string;
+}
+
+/*
+ * The CommentInfo entity contains information about an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
+ */
+export interface CommentInfo {
+ // TODO(TS): Make this required.
+ patch_set?: PatchSetNum;
+ id: UrlEncodedCommentId;
+ path?: string;
+ side?: CommentSide;
+ parent?: number;
+ line?: number;
+ range?: CommentRange;
+ in_reply_to?: UrlEncodedCommentId;
+ message?: string;
+ updated: Timestamp;
+ author?: AccountInfo;
+ tag?: string;
+ unresolved?: boolean;
+ change_message_id?: string;
+ commit_id?: string;
+}
+
+export type PathToCommentsInfoMap = {[path: string]: CommentInfo[]};
+
+export type PortedCommentsAndDrafts = {
+ portedComments?: PathToCommentsInfoMap;
+ portedDrafts?: PathToCommentsInfoMap;
+};
+
+/**
+ * The CommentRange entity describes the range of an inline comment.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-range
+ */
+export interface CommentRange {
+ start_line: number;
+ start_character: number;
+ end_line: number;
+ end_character: number;
+}
+
+/**
+ * The ProjectInfo entity contains information about a project
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-info
+ */
+export interface ProjectInfo {
+ id: UrlEncodedRepoName;
+ // name is not set if returned in a map where the project name is used as
+ // map key
+ name?: RepoName;
+ // ?-<n> if the parent project is not visible (<n> is a number which
+ // is increased for each non-visible project).
+ parent?: RepoName;
+ description?: string;
+ state?: ProjectState;
+ branches?: {[branchName: string]: CommitId};
+ // labels is filled for Create Project and Get Project calls.
+ labels?: LabelNameToLabelTypeInfoMap;
+ // Links to the project in external sites
+ web_links?: WebLinkInfo[];
+}
+
+export interface ProjectInfoWithName extends ProjectInfo {
+ name: RepoName;
+}
+
+export type NameToProjectInfoMap = {[projectName: string]: ProjectInfo};
+export type LabelNameToLabelTypeInfoMap = {[labelName: string]: LabelTypeInfo};
+
+/**
+ * The LabelTypeInfo entity contains metadata about the labels that a project
+ * has.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#label-type-info
+ */
+export interface LabelTypeInfo {
+ values: LabelTypeInfoValues;
+ default_value: number;
+}
+
+export type LabelTypeInfoValues = {[value: string]: string};
+
+/**
+ * The DiffContent entity contains information about the content differences in a file.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
+ */
+export interface DiffContent {
+ a?: string[];
+ b?: string[];
+ ab?: string[];
+ // The inner array is always of length two. The first entry is the 'skip'
+ // length. The second entry is the 'edit' length.
+ edit_a?: number[][];
+ edit_b?: number[][];
+ due_to_rebase?: boolean;
+ due_to_move?: boolean;
+ skip?: number;
+ common?: string;
+ keyLocation?: boolean;
+}
+
+/**
+ * The DiffFileMetaInfo entity contains meta information about a file diff.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-file-meta-info
+ */
+export interface DiffFileMetaInfo {
+ name: string;
+ content_type: string;
+ lines: string;
+ web_links?: WebLinkInfo[];
+ language?: string;
+}
+
+/**
+ * The DiffInfo entity contains information about the diff of a file in a revision.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-info
+ */
+export interface DiffInfo {
+ meta_a: DiffFileMetaInfo;
+ meta_b: DiffFileMetaInfo;
+ change_type: string;
+ intraline_status: string;
+ diff_header: string[];
+ content: DiffContent[];
+ web_links?: DiffWebLinkInfo[];
+ binary: boolean;
+}
+
+export type FilePathToDiffInfoMap = {[path: string]: DiffInfo};
+
+/**
+ * The DiffWebLinkInfo entity describes a link on a diff screen to an external site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-web-link-info
+ */
+export interface DiffWebLinkInfo {
+ name: string;
+ url: string;
+ image_url: string;
+ show_on_side_by_side_diff_view: string;
+ show_on_unified_diff_view: string;
+}
+
+/**
+ * The DiffPreferencesInfo entity contains information about the diff preferences of a user.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-info
+ */
+export interface DiffPreferencesInfo {
+ context: number;
+ expand_all_comments?: boolean;
+ ignore_whitespace: IgnoreWhitespaceType;
+ intraline_difference?: boolean;
+ line_length: number;
+ cursor_blink_rate: number;
+ manual_review?: boolean;
+ retain_header?: boolean;
+ show_line_endings?: boolean;
+ show_tabs?: boolean;
+ show_whitespace_errors?: boolean;
+ skip_deleted?: boolean;
+ skip_uncommented?: boolean;
+ syntax_highlighting?: boolean;
+ hide_top_menu?: boolean;
+ auto_hide_diff_table_header?: boolean;
+ hide_line_numbers?: boolean;
+ tab_size: number;
+ font_size: number;
+ hide_empty_pane?: boolean;
+ match_brackets?: boolean;
+ line_wrapping?: boolean;
+ // TODO(TS): show_file_comment_button exists in JS code, but doesn't exist in the doc.
+ // Either remove or update doc
+ show_file_comment_button?: boolean;
+ // TODO(TS): theme exists in JS code, but doesn't exist in the doc.
+ // Either remove or update doc
+ theme?: string;
+}
+export type DiffPreferencesInfoKey = keyof DiffPreferencesInfo;
+
+/**
+ * The RangeInfo entity stores the coordinates of a range.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#range-info
+ */
+export interface RangeInfo {
+ start: number;
+ end: number;
+}
+
+/**
+ * The BlameInfo entity stores the commit metadata with the row coordinates where it applies.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#blame-info
+ */
+export interface BlameInfo {
+ author: string;
+ id: string;
+ time: number;
+ commit_msg: string;
+ ranges: RangeInfo[];
+}
+
+/**
+ * Images are retrieved by using the file content API and the body is just the
+ * HTML response.
+ * TODO(TS): where is the source of this type ? I don't find it in doc
+ */
+export interface ImageInfo {
+ body: string;
+ type: string;
+ _name?: string;
+ _expectedType?: string;
+ _width?: number;
+ _height?: number;
+}
+
+/**
+ * A boolean value that can also be inherited.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
+ */
+export interface InheritedBooleanInfo {
+ value: boolean;
+ configured_value: InheritedBooleanInfoConfiguredValue;
+ inherited_value?: boolean;
+}
+
+/**
+ * The MaxObjectSizeLimitInfo entity contains information about the max object
+ * size limit of a project.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#max-object-size-limit-info
+ */
+export interface MaxObjectSizeLimitInfo {
+ value?: string;
+ configured_value?: string;
+ summary?: string;
+}
+
+/**
+ * Information about the default submittype of a project, taking into account
+ * project inheritance.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
+ */
+export interface SubmitTypeInfo {
+ value: Exclude<SubmitType, SubmitType.INHERIT>;
+ configured_value: SubmitType;
+ inherited_value: Exclude<SubmitType, SubmitType.INHERIT>;
+}
+
+/**
+ * The CommentLinkInfo entity describes acommentlink.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#commentlink-info
+ */
+export interface CommentLinkInfo {
+ match: string;
+ link?: string;
+ enabled?: boolean;
+ html?: string;
+}
+
+/**
+ * The ConfigParameterInfo entity describes a project configurationparameter.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
+ */
+export interface ConfigParameterInfoBase {
+ display_name?: string;
+ description?: string;
+ warning?: string;
+ type: ConfigParameterInfoType;
+ value?: string;
+ values?: string[];
+ editable?: boolean;
+ permitted_values?: string[];
+ inheritable?: boolean;
+ configured_value?: string;
+ inherited_value?: string;
+}
+
+export interface ConfigArrayParameterInfo extends ConfigParameterInfoBase {
+ type: ConfigParameterInfoType.ARRAY;
+ values: string[];
+}
+
+export interface ConfigListParameterInfo extends ConfigParameterInfoBase {
+ type: ConfigParameterInfoType.LIST;
+ permitted_values?: string[];
+}
+
+export type ConfigParameterInfo =
+ | ConfigParameterInfoBase
+ | ConfigArrayParameterInfo
+ | ConfigListParameterInfo;
+
+export interface CommentLinks {
+ [name: string]: CommentLinkInfo;
+}
+
+/**
+ * The ConfigInfo entity contains information about the effective
+ * project configuration.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
+ */
+export interface ConfigInfo {
+ description?: string;
+ use_contributor_agreements?: InheritedBooleanInfo;
+ use_content_merge?: InheritedBooleanInfo;
+ use_signed_off_by?: InheritedBooleanInfo;
+ create_new_change_for_all_not_in_target?: InheritedBooleanInfo;
+ require_change_id?: InheritedBooleanInfo;
+ enable_signed_push?: InheritedBooleanInfo;
+ require_signed_push?: InheritedBooleanInfo;
+ reject_implicit_merges?: InheritedBooleanInfo;
+ private_by_default: InheritedBooleanInfo;
+ work_in_progress_by_default: InheritedBooleanInfo;
+ max_object_size_limit: MaxObjectSizeLimitInfo;
+ default_submit_type: SubmitTypeInfo;
+ submit_type: SubmitType;
+ match_author_to_committer_date?: InheritedBooleanInfo;
+ state?: ProjectState;
+ commentlinks: CommentLinks;
+ plugin_config?: PluginNameToPluginParametersMap;
+ actions?: {[viewName: string]: ActionInfo};
+ reject_empty_commit?: InheritedBooleanInfo;
+}
+
+/**
+ * The ProjectAccessInfo entity contains information about the access rights for
+ * a project.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
+ */
+export interface ProjectAccessInfo {
+ revision: string; // The revision of the refs/meta/config branch from which the access rights were loaded
+ inherits_from?: ProjectInfo; // not set for the All-Project project
+ local: LocalAccessSectionInfo;
+ is_owner?: boolean;
+ owner_of: GitRef[];
+ can_upload?: boolean;
+ can_add?: boolean;
+ can_add_tags?: boolean;
+ config_visible?: boolean;
+ groups: ProjectAccessGroups;
+ config_web_links: string[];
+}
+
+export type ProjectAccessInfoMap = {[projectName: string]: ProjectAccessInfo};
+export type LocalAccessSectionInfo = {[ref: string]: AccessSectionInfo};
+export type ProjectAccessGroups = {[uuid: string]: GroupInfo};
+
+/**
+ * The AccessSectionInfo describes the access rights that are assigned on a ref.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#access-section-info
+ */
+export interface AccessSectionInfo {
+ permissions: AccessPermissionsMap;
+}
+
+export type AccessPermissionsMap = {[permissionName: string]: PermissionInfo};
+
+/**
+ * The PermissionInfo entity contains information about an assigned permission
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#permission-info
+ */
+export interface PermissionInfo {
+ label?: string; // The name of the label. Not set if it’s not a label permission.
+ exclusive?: boolean;
+ rules: PermissionInfoRules;
+}
+
+export type PermissionInfoRules = {[groupUUID: string]: PermissionRuleInfo};
+
+/**
+ * The PermissionRuleInfo entity contains information about a permission rule that is assigned to group
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#permission-info
+ */
+export interface PermissionRuleInfo {
+ action: PermissionAction;
+ force?: boolean;
+ min?: number; // not set if range is empty (from 0 to 0) or not set
+ max?: number; // not set if range is empty (from 0 to 0) or not set
+}
+
+/**
+ * The DashboardInfo entity contains information about a project dashboard
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#dashboard-info
+ */
+export interface DashboardInfo {
+ id: DashboardId;
+ project: RepoName;
+ defining_project: RepoName;
+ ref: string; // The name of the ref in which the dashboard is defined, without the refs/meta/dashboards/ prefix
+ path: string;
+ description?: string;
+ foreach?: string;
+ url: string;
+ is_default?: boolean;
+ title?: string;
+ sections: DashboardSectionInfo[];
+}
+
+/**
+ * The DashboardSectionInfo entity contains information about a section in a dashboard.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#dashboard-section-info
+ */
+export interface DashboardSectionInfo {
+ name: string;
+ query: string;
+}
+
+/**
+ * The ConfigInput entity describes a new project configuration
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
+ */
+export interface ConfigInput {
+ description?: string;
+ use_contributor_agreements?: InheritedBooleanInfoConfiguredValue;
+ use_content_merge?: InheritedBooleanInfoConfiguredValue;
+ use_signed_off_by?: InheritedBooleanInfoConfiguredValue;
+ create_new_change_for_all_not_in_target?: InheritedBooleanInfoConfiguredValue;
+ require_change_id?: InheritedBooleanInfoConfiguredValue;
+ enable_signed_push?: InheritedBooleanInfoConfiguredValue;
+ require_signed_push?: InheritedBooleanInfoConfiguredValue;
+ private_by_default?: InheritedBooleanInfoConfiguredValue;
+ work_in_progress_by_default?: InheritedBooleanInfoConfiguredValue;
+ enable_reviewer_by_email?: InheritedBooleanInfoConfiguredValue;
+ match_author_to_committer_date?: InheritedBooleanInfoConfiguredValue;
+ reject_implicit_merges?: InheritedBooleanInfoConfiguredValue;
+ reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
+ max_object_size_limit?: MaxObjectSizeLimitInfo;
+ submit_type?: SubmitType;
+ state?: ProjectState;
+ plugin_config_values?: PluginNameToPluginParametersMap;
+ commentlinks?: ConfigInfoCommentLinks;
+}
+/**
+ * Plugin configuration values as map which maps the plugin name to a map of parameter names to values
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
+ */
+export type PluginNameToPluginParametersMap = {
+ [pluginName: string]: PluginParameterToConfigParameterInfoMap;
+};
+
+export type PluginParameterToConfigParameterInfoMap = {
+ [parameterName: string]: ConfigParameterInfo;
+};
+
+export type ConfigInfoCommentLinks = {
+ [commentLinkName: string]: CommentLinkInfo;
+};
+
+/**
+ * The ProjectInput entity contains information for the creation of a new project
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-input
+ */
+export interface ProjectInput {
+ name?: RepoName;
+ parent?: RepoName;
+ description?: string;
+ permissions_only?: boolean;
+ create_empty_commit?: boolean;
+ submit_type?: SubmitType;
+ branches?: BranchName[];
+ owners?: GroupId[];
+ use_contributor_agreements?: InheritedBooleanInfoConfiguredValue;
+ use_signed_off_by?: InheritedBooleanInfoConfiguredValue;
+ create_new_change_for_all_not_in_target?: InheritedBooleanInfoConfiguredValue;
+ use_content_merge?: InheritedBooleanInfoConfiguredValue;
+ require_change_id?: InheritedBooleanInfoConfiguredValue;
+ enable_signed_push?: InheritedBooleanInfoConfiguredValue;
+ require_signed_push?: InheritedBooleanInfoConfiguredValue;
+ max_object_size_limit?: string;
+ reject_empty_commit?: InheritedBooleanInfoConfiguredValue;
+}
+
+/**
+ * The BranchInfo entity contains information about a branch
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#branch-info
+ */
+export interface BranchInfo {
+ ref: GitRef;
+ revision: string;
+ can_delete?: boolean;
+ web_links?: WebLinkInfo[];
+}
+
+/**
+ * The ProjectAccessInput describes changes that should be applied to a project access config
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-access-input
+ */
+export interface ProjectAccessInput {
+ remove?: RefToProjectAccessInfoMap;
+ add?: RefToProjectAccessInfoMap;
+ message?: string;
+ parent?: string;
+}
+
+export type RefToProjectAccessInfoMap = {[refName: string]: ProjectAccessInfo};
+
+/**
+ * Represent a file in a base64 encoding
+ */
+export interface Base64File {
+ body: string;
+ type: string | null;
+}
+
+/**
+ * Represent a file in a base64 encoding; GrRestApiInterface returns it from some
+ * methods
+ */
+export interface Base64FileContent {
+ content: string | null;
+ type: string | null;
+ ok: true;
+}
+
+/**
+ * The WatchedProjectsInfo entity contains information about a project watch for a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#project-watch-info
+ */
+export interface ProjectWatchInfo {
+ project: RepoName;
+ filter?: string;
+ notify_new_changes?: boolean;
+ notify_new_patch_sets?: boolean;
+ notify_all_comments?: boolean;
+ notify_submitted_changes?: boolean;
+ notify_abandoned_changes?: boolean;
+}
+/**
+ * The DeleteDraftCommentsInput entity contains information specifying a set of draft comments that should be deleted
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#delete-draft-comments-input
+ */
+export interface DeleteDraftCommentsInput {
+ query: string;
+}
+
+/**
+ * The AssigneeInput entity contains the identity of the user to be set as assignee
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#assignee-input
+ */
+export interface AssigneeInput {
+ assignee: AccountId;
+}
+
+/**
+ * The SshKeyInfo entity contains information about an SSH key of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#ssh-key-info
+ */
+export interface SshKeyInfo {
+ seq: number;
+ ssh_public_key: string;
+ encoded_key: string;
+ algorithm: string;
+ comment?: string;
+ valid: boolean;
+}
+
+/**
+ * The HashtagsInput entity contains information about hashtags to add to, and/or remove from, a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#hashtags-input
+ */
+export interface HashtagsInput {
+ add?: Hashtag[];
+ remove?: Hashtag[];
+}
+
+/**
+ * Defines a patch ranges. Used as input for gr-rest-api-interface methods,
+ * doesn't exist in Rest API
+ */
+export interface PatchRange {
+ patchNum: PatchSetNum;
+ basePatchNum: PatchSetNum;
+}
+
+/**
+ * The CommentInput entity contains information for creating an inline comment
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-input
+ */
+export interface CommentInput {
+ id?: UrlEncodedCommentId;
+ path?: string;
+ side?: CommentSide;
+ line?: number;
+ range?: CommentRange;
+ in_reply_to?: UrlEncodedCommentId;
+ updated?: Timestamp;
+ message?: string;
+ tag?: string;
+ unresolved?: boolean;
+}
+
+/**
+ * The EditPreferencesInfo entity contains information about the edit preferences of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#edit-preferences-info
+ */
+export interface EditPreferencesInfo {
+ tab_size: number;
+ line_length: number;
+ indent_unit: number;
+ cursor_blink_rate: number;
+ hide_top_menu?: boolean;
+ show_tabs?: boolean;
+ show_whitespace_errors?: boolean;
+ syntax_highlighting?: boolean;
+ hide_line_numbers?: boolean;
+ match_brackets?: boolean;
+ line_wrapping?: boolean;
+ indent_with_tabs?: boolean;
+ auto_close_brackets?: boolean;
+ show_base?: boolean;
+ // TODO(TS): the following proeprties doesn't exist in RestAPI doc
+ key_map_type?: string;
+ theme?: string;
+}
+
+/**
+ * The PreferencesInput entity contains information for setting the user preferences. Fields which are not set will not be updated
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-input
+ *
+ * Note: the doc missed several properties. Java code uses the same class (GeneralPreferencesInfo)
+ * both for input data and for response data.
+ */
+export type PreferencesInput = Partial<PreferencesInfo>;
+
+/**
+ * The DiffPreferencesInput entity contains information for setting the diff preferences of a user. Fields which are not set will not be updated
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#diff-preferences-input
+ */
+export interface DiffPreferenceInput {
+ context?: number;
+ expand_all_comments?: boolean;
+ ignore_whitespace: IgnoreWhitespaceType;
+ intraline_difference?: boolean;
+ line_length?: number;
+ manual_review?: boolean;
+ retain_header?: boolean;
+ show_line_endings?: boolean;
+ show_tabs?: boolean;
+ show_whitespace_errors?: boolean;
+ skip_deleted?: boolean;
+ skip_uncommented?: boolean;
+ syntax_highlighting?: boolean;
+ hide_top_menu?: boolean;
+ auto_hide_diff_table_header?: boolean;
+ hide_line_numbers?: boolean;
+ tab_size?: number;
+ font_size?: number;
+ line_wrapping?: boolean;
+ indent_with_tabs?: boolean;
+}
+
+/**
+ * The EmailInfo entity contains information about an email address of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#email-info
+ */
+export interface EmailInfo {
+ email: string;
+ preferred?: boolean;
+ pending_confirmation?: boolean;
+}
+
+/**
+ * The CapabilityInfo entity contains information about the global capabilities of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#capability-info
+ */
+export interface AccountCapabilityInfo {
+ accessDatabase?: boolean;
+ administrateServer?: boolean;
+ createAccount?: boolean;
+ createGroup?: boolean;
+ createProject?: boolean;
+ emailReviewers?: boolean;
+ flushCaches?: boolean;
+ killTask?: boolean;
+ maintainServer?: boolean;
+ priority: UserPriority;
+ queryLimit: QueryLimitInfo;
+ runAs?: boolean;
+ runGC?: boolean;
+ streamEvents?: boolean;
+ viewAllAccounts?: boolean;
+ viewCaches?: boolean;
+ viewConnections?: boolean;
+ viewPlugins?: boolean;
+ viewQueue?: boolean;
+}
+
+/**
+ * The QueryLimitInfo entity contains information about the Query Limit of a user
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#query-limit-info
+ */
+export interface QueryLimitInfo {
+ min: number;
+ max: number;
+}
+
+/**
+ * The PreferencesInfo entity contains information about a user’s preferences
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#preferences-info
+ */
+export interface PreferencesInfo {
+ changes_per_page: 10 | 25 | 50 | 100;
+ theme: AppTheme;
+ expand_inline_diffs?: boolean;
+ download_scheme?: string;
+ date_format: DateFormat;
+ time_format: TimeFormat;
+ relative_date_in_change_table?: boolean;
+ diff_view: DiffViewMode;
+ size_bar_in_change_table?: boolean;
+ legacycid_in_change_table?: boolean;
+ mute_common_path_prefixes?: boolean;
+ signed_off_by?: boolean;
+ my: TopMenuItemInfo[];
+ change_table: string[];
+ email_strategy: EmailStrategy;
+ default_base_for_merges: DefaultBase;
+ publish_comments_on_push?: boolean;
+ work_in_progress_by_default?: boolean;
+ // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
+ email_format?: EmailFormat;
+ // The following property doesn't exist in RestAPI, it is added by GrRestApiInterface
+ default_diff_view?: DiffViewMode;
+}
+
+/**
+ * Contains information about diff images
+ * There is no RestAPI interface for it
+ */
+export interface ImagesForDiff {
+ baseImage: Base64ImageFile | null;
+ revisionImage: Base64ImageFile | null;
+}
+
+/**
+ * Contains information about diff image
+ * There is no RestAPI interface for it
+ */
+export interface Base64ImageFile extends Base64File {
+ _expectedType: string;
+ _name: string;
+}
+
+/**
+ * The ReviewInput entity contains information for adding a review to a revision
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-input
+ */
+export interface ReviewInput {
+ message?: string;
+ tag?: ReviewInputTag;
+ labels?: LabelNameToValuesMap;
+ comments?: PathToCommentsInputMap;
+ robot_comments?: PathToRobotCommentsMap;
+ drafts?: DraftsAction;
+ notify?: NotifyType;
+ notify_details?: RecipientTypeToNotifyInfoMap;
+ omit_duplicate_comments?: boolean;
+ on_behalf_of?: AccountId;
+ reviewers?: ReviewerInput[];
+ ready?: boolean;
+ work_in_progress?: boolean;
+ add_to_attention_set?: AttentionSetInput[];
+ remove_from_attention_set?: AttentionSetInput[];
+ ignore_automatic_attention_set_rules?: boolean;
+}
+
+/**
+ * The ReviewResult entity contains information regarding the updates that were
+ * made to a review.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-result
+ */
+export interface ReviewResult {
+ labels?: unknown;
+ // type of key is (AccountId | GroupId | EmailAddress)
+ reviewers?: {[key: string]: AddReviewerResult};
+ ready?: boolean;
+}
+
+/**
+ * The AddReviewerResult entity describes the result of adding a reviewer to a
+ * change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#add-reviewer-result
+ */
+export interface AddReviewerResult {
+ input: AccountId | GroupId | EmailAddress;
+ reviewers?: AccountInfo[];
+ ccs?: AccountInfo[];
+ error?: string;
+ confirm?: boolean;
+}
+
+export type LabelNameToValuesMap = {[labelName: string]: number};
+export type PathToCommentsInputMap = {[path: string]: CommentInput[]};
+export type PathToRobotCommentsMap = {[path: string]: RobotCommentInput[]};
+export type RecipientTypeToNotifyInfoMap = {
+ [recepientType: string]: NotifyInfo;
+};
+
+/**
+ * The RobotCommentInput entity contains information for creating an inline robot comment
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#robot-comment-input
+ */
+export type RobotCommentInput = RobotCommentInfo;
+
+/**
+ * This is what human, robot and draft comments can agree upon.
+ *
+ * Human, robot and saved draft comments all have a required id, but unsaved
+ * drafts do not. That is why the id is omitted from CommentInfo, such that it
+ * can be optional in Draft, but required in CommentInfo and RobotCommentInfo.
+ */
+export interface CommentBasics extends Omit<CommentInfo, 'id' | 'updated'> {
+ id?: UrlEncodedCommentId;
+ updated?: Timestamp;
+}
+
+/**
+ * The RobotCommentInfo entity contains information about a robot inline comment
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#robot-comment-info
+ */
+export interface RobotCommentInfo extends CommentInfo {
+ robot_id: RobotId;
+ robot_run_id: RobotRunId;
+ url?: string;
+ properties: {[propertyName: string]: string};
+ fix_suggestions: FixSuggestionInfo[];
+}
+export type PathToRobotCommentsInfoMap = {[path: string]: RobotCommentInfo[]};
+
+/**
+ * The FixSuggestionInfo entity represents a suggested fix
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-suggestion-info
+ */
+export interface FixSuggestionInfoInput {
+ description: string;
+ replacements: FixReplacementInfo[];
+}
+
+export interface FixSuggestionInfo extends FixSuggestionInfoInput {
+ fix_id: FixId;
+ description: string;
+ replacements: FixReplacementInfo[];
+}
+
+/**
+ * The FixReplacementInfo entity describes how the content of a file should be replaced by another content
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fix-replacement-info
+ */
+export interface FixReplacementInfo {
+ path: string;
+ range: CommentRange;
+ replacement: string;
+}
+
+/**
+ * The NotifyInfo entity contains detailed information about who should be notified about an update
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#notify-info
+ */
+export interface NotifyInfo {
+ accounts?: AccountId[];
+}
+
+/**
+ * The ReviewerInput entity contains information for adding a reviewer to a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#reviewer-input
+ */
+export interface ReviewerInput {
+ reviewer: AccountId | GroupId | EmailAddress;
+ state?: ReviewerState;
+ confirmed?: boolean;
+ notify?: NotifyType;
+ notify_details?: RecipientTypeToNotifyInfoMap;
+}
+
+/**
+ * The AttentionSetInput entity contains details for adding users to the attention set and removing them from it
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-input
+ */
+export interface AttentionSetInput {
+ user: AccountId;
+ reason: string;
+ notify?: NotifyType;
+ notify_details?: RecipientTypeToNotifyInfoMap;
+}
+
+/**
+ * The EditInfo entity contains information about a change edit
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#edit-info
+ */
+export interface EditInfo {
+ commit: CommitInfo;
+ base_patch_set_number: PatchSetNum;
+ base_revision: string;
+ ref: GitRef;
+ fetch?: ProtocolToFetchInfoMap;
+ files?: FileNameToFileInfoMap;
+}
+
+export type ProtocolToFetchInfoMap = {[protocol: string]: FetchInfo};
+export type FileNameToFileInfoMap = {[name: string]: FileInfo};
+
+/**
+ * Contains information about an account that can be added to a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#suggested-reviewer-info
+ */
+export interface SuggestedReviewerAccountInfo {
+ account: AccountInfo;
+ /**
+ * The total number of accounts in the suggestion - always 1
+ */
+ count: 1;
+}
+
+/**
+ * Contains information about a group that can be added to a change
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#suggested-reviewer-info
+ */
+export interface SuggestedReviewerGroupInfo {
+ group: GroupBaseInfo;
+ /**
+ * The total number of accounts that are members of the group is returned
+ * (this count includes members of nested groups)
+ */
+ count: number;
+ /**
+ * True if group is present and count is above the threshold where the
+ * confirmed flag must be passed to add the group as a reviewer
+ */
+ confirm?: boolean;
+}
+
+/**
+ * The SuggestedReviewerInfo entity contains information about a reviewer that can be added to a change (an account or a group)
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#suggested-reviewer-info
+ */
+export type SuggestedReviewerInfo =
+ | SuggestedReviewerAccountInfo
+ | SuggestedReviewerGroupInfo;
+
+export type Suggestion = SuggestedReviewerInfo | AccountInfo;
+
+export function isReviewerAccountSuggestion(
+ s: Suggestion
+): s is SuggestedReviewerAccountInfo {
+ return (s as SuggestedReviewerAccountInfo).account !== undefined;
+}
+
+export function isReviewerGroupSuggestion(
+ s: Suggestion
+): s is SuggestedReviewerGroupInfo {
+ return (s as SuggestedReviewerGroupInfo).group !== undefined;
+}
+
+export type RequestPayload = string | object;
+
+export type Password = string;
+
+/**
+ * The BranchInput entity contains information for the creation of a new branch
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#branch-input
+ */
+export interface BranchInput {
+ ref?: BranchName; // refs/heads prefix is allowed, but can be omitted
+ revision?: string;
+}
+
+/**
+ * The TagInput entity contains information for creating a tag
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#tag-input
+ */
+export interface TagInput {
+ // ref: string; mentoined as required in doc, but it doesn't used anywher
+ revision?: string;
+ message?: string;
+}
+
+/**
+ * The IncludedInInfo entity contains information about the branches a change was merged into and tags it was tagged with
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#included-in-info
+ */
+export interface IncludedInInfo {
+ branches: BranchName[];
+ tags: TagName[];
+ external?: NameToExternalSystemsMap;
+}
+
+// It is unclear what is name here
+export type NameToExternalSystemsMap = {[name: string]: string[]};
+
+/**
+ * The PluginInfo entity describes a plugin.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-plugins.html#plugin-info
+ */
+export interface PluginInfo {
+ id: string;
+ version: string;
+ api_version?: string;
+ index_url?: string;
+ filename?: string;
+ disabled: boolean;
+}
+/**
+ * The PluginInput entity describes a plugin that should be installed.
+ */
+export interface PluginInput {
+ url: string;
+}
+
+/**
+ * The DocResult entity contains information about a document.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-documentation.html#doc-result
+ */
+export interface DocResult {
+ title: string;
+ url: string;
+}
+
+/**
+ * The TagInfo entity contains information about a tag.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#tag-info
+ **/
+export interface TagInfo {
+ ref: GitRef;
+ revision: string;
+ object?: string;
+ message?: string;
+ tagger?: GitPersonInfo;
+ created?: string;
+ can_delete: boolean;
+ web_links?: WebLinkInfo[];
+}
+
+/**
+ * The RelatedChangesInfo entity contains information about related changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#related-changes-info
+ */
+export interface RelatedChangesInfo {
+ changes: RelatedChangeAndCommitInfo[];
+}
+
+/**
+ * The RelatedChangeAndCommitInfo entity contains information about a related change and commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#related-change-and-commit-info
+ */
+export interface RelatedChangeAndCommitInfo {
+ project: RepoName;
+ change_id?: ChangeId;
+ commit: CommitInfoWithRequiredCommit;
+ _change_number?: NumericChangeId;
+ _revision_number?: number;
+ _current_revision_number?: number;
+ status?: ChangeStatus;
+ // The submittable property doesn't exist in the Gerrit API, but in the future
+ // we can bring this feature back. There is a frontend code and CSS styles for
+ // it and this property is added here to keep related frontend code unchanged.
+ submittable?: boolean;
+}
+
+/**
+ * The SubmittedTogetherInfo entity contains information about a collection of changes that would be submitted together.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submitted-together-info
+ */
+export interface SubmittedTogetherInfo {
+ changes: ChangeInfo[];
+ non_visible_changes: number;
+}
+
+/**
+ * The RevertSubmissionInfo entity describes the revert changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revert-submission-info
+ */
+export interface RevertSubmissionInfo {
+ revert_changes: ChangeInfo[];
+}
+
+/**
+ * The CherryPickInput entity contains information for cherry-picking a change to a new branch.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#cherrypick-input
+ */
+export interface CherryPickInput {
+ message?: string;
+ destination: BranchName;
+ base?: CommitId;
+ parent?: number;
+ notify?: NotifyType;
+ notify_details: RecipientTypeToNotifyInfoMap;
+ keep_reviewers?: boolean;
+ allow_conflicts?: boolean;
+ topic?: TopicName;
+ allow_empty?: boolean;
+}
+
+/**
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
+ */
+export interface MergeableInfo {
+ submit_type: SubmitType;
+ strategy?: MergeStrategy;
+ mergeable: boolean;
+ commit_merged?: boolean;
+ content_merged?: boolean;
+ conflicts?: string[];
+ mergeable_into?: string[];
+}
diff --git a/polygerrit-ui/app/types/custom-externs.ts b/polygerrit-ui/app/types/custom-externs.ts
deleted file mode 100644
index 216900a..0000000
--- a/polygerrit-ui/app/types/custom-externs.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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.
- */
-
-/**
- * For the purposes of template type checking, externs should be added for
- * anything set on the window object. Note that sub-properties of these
- * declared properties are considered something separate.
- *
- * This file is only for template type checking, not used in Gerrit code.
- */
-
-/* eslint-disable no-var */
-/* eslint-disable no-unused-vars */
-/** @externs */
-// @unused
-
-var Gerrit: any;
-var GrAnnotation;
-var GrAttributeHelper;
-var GrChangeActionsInterface;
-var GrChangeReplyInterface;
-var GrDiffBuilder;
-var GrDiffBuilderImage;
-var GrDiffBuilderSideBySide;
-var GrDiffBuilderUnified;
-var GrDiffGroup;
-var GrDiffLine;
-var GrDomHooks;
-var GrEditConstants;
-var GrEtagDecorator;
-var GrFileListConstants;
-var GrGapiAuth;
-var GrGerritAuth;
-var GrLinkTextParser;
-var GrPluginEndpoints;
-var GrPopupInterface;
-var GrRangeNormalizer;
-var GrReporting;
-var GrReviewerUpdatesParser;
-var GrCountStringFormatter;
-var GrThemeApi;
-var SiteBasedCache;
-var FetchPromisesCache;
-var GrRestApiHelper;
-var GrDisplayNameUtils;
-var GrReviewerSuggestionsProvider;
-var page;
-var util;
diff --git a/polygerrit-ui/app/types/events.ts b/polygerrit-ui/app/types/events.ts
new file mode 100644
index 0000000..529904a
--- /dev/null
+++ b/polygerrit-ui/app/types/events.ts
@@ -0,0 +1,174 @@
+/**
+ * @license
+ * 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.
+ */
+import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {PatchSetNum} from './common';
+import {UIComment} from '../utils/comment-util';
+
+export interface TitleChangeEventDetail {
+ title: string;
+}
+
+export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
+
+declare global {
+ interface HTMLElementEventMap {
+ 'title-change': TitleChangeEvent;
+ }
+}
+
+export interface PageErrorEventDetail {
+ response: Response;
+}
+
+export type PageErrorEvent = CustomEvent<PageErrorEventDetail>;
+
+declare global {
+ interface HTMLElementEventMap {
+ 'page-error': PageErrorEvent;
+ }
+}
+
+export interface LocationChangeEventDetail {
+ hash: string;
+ pathname: string;
+}
+
+export type LocationChangeEvent = CustomEvent<LocationChangeEventDetail>;
+
+declare global {
+ interface HTMLElementEventMap {
+ 'location-change': LocationChangeEvent;
+ }
+}
+
+export interface RpcLogEventDetail {
+ status: number | null;
+ method: string;
+ elapsed: number;
+ anonymizedUrl: string;
+}
+
+export type RpcLogEvent = CustomEvent<RpcLogEventDetail>;
+
+declare global {
+ interface HTMLElementEventMap {
+ 'rpc-log': RpcLogEvent;
+ }
+}
+
+export interface ShortcutTriggeredEventDetail {
+ event: CustomKeyboardEvent;
+ goKey: boolean;
+ vKey: boolean;
+}
+
+export type ShortcutTriggeredEvent = CustomEvent<ShortcutTriggeredEventDetail>;
+
+declare global {
+ interface HTMLElementEventMap {
+ 'shortcut-triggered': ShortcutTriggeredEvent;
+ }
+}
+
+export interface EditableContentSaveEventDetail {
+ content: string;
+}
+
+export type EditableContentSaveEvent = CustomEvent<
+ EditableContentSaveEventDetail
+>;
+
+declare global {
+ interface HTMLElementEventMap {
+ 'editable-content-save': EditableContentSaveEvent;
+ }
+}
+
+export interface OpenFixPreviewEventDetail {
+ patchNum?: PatchSetNum;
+ comment?: UIComment;
+}
+
+export type OpenFixPreviewEvent = CustomEvent<OpenFixPreviewEventDetail>;
+
+declare global {
+ interface HTMLElementEventMap {
+ 'open-fix-preview': OpenFixPreviewEvent;
+ }
+}
+
+// Type for the custom event to switch tab.
+interface SwitchTabEventDetail {
+ // name of the tab to set as active, from custom event
+ tab?: string;
+ // index of tab to set as active, from paper-tabs event
+ value?: number;
+ // scroll into the tab afterwards, from custom event
+ scrollIntoView?: boolean;
+}
+
+export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
+
+declare global {
+ interface HTMLElementEventMap {
+ 'show-primary-tab': SwitchTabEvent;
+ 'show-secondary-tab': SwitchTabEvent;
+ }
+}
+
+export interface ReloadEventDetail {
+ clearPatchset: boolean;
+}
+
+export type ReloadEvent = CustomEvent<ReloadEventDetail>;
+
+declare global {
+ interface HTMLElementEventMap {
+ reload: ReloadEvent;
+ }
+}
+
+export interface ShowAlertEventDetail {
+ message: string;
+}
+
+export type ShowAlertEvent = CustomEvent<ShowAlertEventDetail>;
+
+declare global {
+ interface HTMLElementEventMap {
+ 'show-alert': ShowAlertEvent;
+ }
+}
+
+/**
+ * Keyboard events emitted from polymer elements.
+ */
+export interface CustomKeyboardEvent extends CustomEvent, EventApi {
+ event: CustomKeyboardEvent;
+ detail: {
+ keyboardEvent?: CustomKeyboardEvent;
+ // TODO(TS): maybe should mark as optional and check before accessing
+ key: string;
+ };
+ readonly altKey: boolean;
+ readonly changedTouches: TouchList;
+ readonly ctrlKey: boolean;
+ readonly metaKey: boolean;
+ readonly shiftKey: boolean;
+ readonly keyCode: number;
+ readonly repeat: boolean;
+}
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index ac2ae7c..28cac87 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -14,10 +14,127 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+import {ParsedJSON} from './common';
+import {HighlightJS} from './types';
+
export {};
declare global {
interface Window {
CANONICAL_PATH?: string;
+ INITIAL_DATA?: {[key: string]: ParsedJSON};
+ HTMLImports?: {whenReady: (cb: () => void) => void};
+ linkify(
+ text: string,
+ options: {callback: (text: string, href?: string) => void}
+ ): void;
+ ASSETS_PATH?: string;
+ // TODO(TS): define gerrit type
+ Gerrit?: {
+ Nav?: unknown;
+ getRootElement?: unknown;
+ Auth?: unknown;
+ _pluginLoader?: unknown;
+ _endpoints?: unknown;
+ slotToContent?: unknown;
+ rangesEqual?: unknown;
+ SUGGESTIONS_PROVIDERS_USERS_TYPES?: unknown;
+ RevisionInfo?: unknown;
+ CoverageType?: unknown;
+ hiddenscroll?: unknown;
+ flushPreinstalls?: () => void;
+ };
+ // TODO(TS): define polymer type
+ Polymer?: {importHref?: unknown};
+ // TODO(TS): remove page when better workaround is found
+ // page shouldn't be exposed in window and it shouldn't be used
+ // it's defined because of limitations from typescript, which don't import .mjs
+ page?: unknown;
+ hljs?: HighlightJS;
+
+ DEFAULT_DETAIL_HEXES?: {
+ diffPage?: string;
+ changePage?: string;
+ dashboardPage?: string;
+ };
+ STATIC_RESOURCE_PATH?: string;
+
+ PRELOADED_QUERIES?: {
+ dashboardQuery?: string[];
+ };
+
+ VERSION_INFO?: string;
+
+ /** Enhancements on Gr elements or utils */
+ // TODO(TS): should clean up those and removing them may break certain plugin behaviors
+ // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
+ // use any for them for now
+ GrDisplayNameUtils: unknown;
+ GrAnnotation: unknown;
+ GrAttributeHelper: unknown;
+ GrDiffLine: unknown;
+ GrDiffLineType: unknown;
+ GrDiffGroup: unknown;
+ GrDiffGroupType: unknown;
+ GrDiffBuilder: unknown;
+ GrDiffBuilderSideBySide: unknown;
+ GrDiffBuilderImage: unknown;
+ GrDiffBuilderUnified: unknown;
+ GrDiffBuilderBinary: unknown;
+ GrChangeActionsInterface: unknown;
+ GrChangeReplyInterface: unknown;
+ GrEditConstants: unknown;
+ GrDomHooksManager: unknown;
+ GrDomHook: unknown;
+ GrEtagDecorator: unknown;
+ GrThemeApi: unknown;
+ SiteBasedCache: unknown;
+ FetchPromisesCache: unknown;
+ GrRestApiHelper: unknown;
+ GrLinkTextParser: unknown;
+ GrPluginEndpoints: unknown;
+ GrReviewerUpdatesParser: unknown;
+ GrPopupInterface: unknown;
+ GrCountStringFormatter: unknown;
+ GrReviewerSuggestionsProvider: unknown;
+ util: unknown;
+ Auth: unknown;
+ EventEmitter: unknown;
+ GrAdminApi: unknown;
+ GrAnnotationActionsContext: unknown;
+ GrAnnotationActionsInterface: unknown;
+ GrChangeMetadataApi: unknown;
+ GrEmailSuggestionsProvider: unknown;
+ GrGroupSuggestionsProvider: unknown;
+ GrEventHelper: unknown;
+ GrPluginRestApi: unknown;
+ GrRepoApi: unknown;
+ GrSettingsApi: unknown;
+ GrStylesApi: unknown;
+ PluginLoader: unknown;
+ GrPluginActionContext: unknown;
+ _apiUtils: {};
+ }
+
+ interface Performance {
+ // typescript doesn't know about the memory property.
+ // Define it here, so it can be used everywhere
+ memory?: {
+ jsHeapSizeLimit: number;
+ totalJSHeapSize: number;
+ usedJSHeapSize: number;
+ };
+ }
+
+ interface Event {
+ // path is a non-standard property. Actually, this is optional property,
+ // but marking it as optional breaks CustomKeyboardEvent
+ // TODO(TS): replace with composedPath if possible
+ readonly path: EventTarget[];
+ }
+
+ interface Error {
+ lineNumber?: number; // non-standard property
+ columnNumber?: number; // non-standard property
}
}
diff --git a/polygerrit-ui/app/types/types.js b/polygerrit-ui/app/types/types.js
deleted file mode 100644
index 91909a6..0000000
--- a/polygerrit-ui/app/types/types.js
+++ /dev/null
@@ -1,324 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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.
- */
-
-// Type definitions used across multiple files in Gerrit
-
-/** @enum {string} */
-export const CoverageType = {
- /**
- * start_character and end_character of the range will be ignored for this
- * type.
- */
- COVERED: 'COVERED',
- /**
- * start_character and end_character of the range will be ignored for this
- * type.
- */
- NOT_COVERED: 'NOT_COVERED',
- PARTIALLY_COVERED: 'PARTIALLY_COVERED',
- /**
- * You don't have to use this. If there is no coverage information for a
- * range, then it implicitly means NOT_INSTRUMENTED. start_character and
- * end_character of the range will be ignored for this type.
- */
- NOT_INSTRUMENTED: 'NOT_INSTRUMENTED',
-};
-
-const Gerrit = window.Gerrit || {};
-
-/**
- * @typedef {{
- * start_line: number,
- * start_character: number,
- * end_line: number,
- * end_character: number,
- * }}
- */
-Gerrit.Range;
-
-/**
- * @typedef {{side: string, range: Gerrit.Range, hovering: boolean}}
- */
-Gerrit.HoveredRange;
-
-/**
- * @typedef {{
- * side: string,
- * type: Gerrit.CoverageType,
- * code_range: Gerrit.Range,
- * }}
- */
-Gerrit.CoverageRange;
-
-/**
- * @typedef {{
- * basePatchNum: (string|number),
- * patchNum: (number),
- * }}
- */
-Gerrit.PatchRange;
-
-/**
- * @typedef {{
- * changeNum: (string|number),
- * endpoint: string,
- * patchNum: (string|number|null|undefined),
- * errFn: (function(?Response, string=)|null|undefined),
- * params: (Object|null|undefined),
- * fetchOptions: (Object|null|undefined),
- * anonymizedEndpoint: (string|undefined),
- * reportEndpointAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.ChangeFetchRequest;
-
-/**
- * @typedef {{
- * is_private: boolean,
- * subject: string,
- * unresolved_comment_count: number,
- * }}
- */
-Gerrit.Change;
-
-/**
- * Object to describe a request for passing into _send.
- * - method is the HTTP method to use in the request.
- * - url is the URL for the request
- * - body is a request payload.
- * TODO (beckysiegel) remove need for number at least.
- * - errFn is a function to invoke when the request fails.
- * - cancelCondition is a function that, if provided and returns true, will
- * cancel the response after it resolves.
- * - contentType is the content type of the body.
- * - headers is a key-value hash to describe HTTP headers for the request.
- * - parseResponse states whether the result should be parsed as a JSON
- * object using getResponseObject.
- *
- * @typedef {{
- * method: string,
- * url: string,
- * body: (string|number|Object|null|undefined),
- * errFn: (function(?Response, string=)|null|undefined),
- * contentType: (string|null|undefined),
- * headers: (Object|undefined),
- * parseResponse: (boolean|undefined),
- * anonymizedUrl: (string|undefined),
- * reportUrlAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.SendRequest;
-
-/**
- * @typedef {{
- * changeNum: (string|number),
- * method: string,
- * patchNum: (string|number|undefined),
- * endpoint: string,
- * body: (string|number|Object|null|undefined),
- * errFn: (function(?Response, string=)|null|undefined),
- * contentType: (string|null|undefined),
- * headers: (Object|undefined),
- * parseResponse: (boolean|undefined),
- * anonymizedEndpoint: (string|undefined),
- * reportEndpointAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.ChangeSendRequest;
-
-/**
- * @typedef {{
- * url: string,
- * fetchOptions: (Object|null|undefined),
- * anonymizedUrl: (string|undefined),
- * }}
- */
-Gerrit.FetchRequest;
-
-/**
- * Object to describe a request for passing into fetchJSON or fetchRawJSON.
- * - url is the URL for the request (excluding get params)
- * - errFn is a function to invoke when the request fails.
- * - cancelCondition is a function that, if provided and returns true, will
- * cancel the response after it resolves.
- * - params is a key-value hash to specify get params for the request URL.
- *
- * @typedef {{
- * url: string,
- * errFn: (function(?Response, string=)|null|undefined),
- * cancelCondition: (function()|null|undefined),
- * params: (Object|null|undefined),
- * fetchOptions: (Object|null|undefined),
- * anonymizedUrl: (string|undefined),
- * reportUrlAsIs: (boolean|undefined),
- * }}
- */
-Gerrit.FetchJSONRequest;
-
-/**
- * @typedef {{
- * message: string,
- * icon: string,
- * class: string,
- * }}
- */
-Gerrit.PushCertificateValidation;
-
-/**
- * Object containing layout values to be used in rendering size-bars.
- * `max{Inserted,Deleted}` represent the largest values of the
- * `lines_inserted` and `lines_deleted` fields of the files respectively. The
- * `max{Addition,Deletion}Width` represent the width of the graphic allocated
- * to the insertion or deletion side respectively. Finally, the
- * `deletionOffset` value represents the x-position for the deletion bar.
- *
- * @typedef {{
- * maxInserted: number,
- * maxDeleted: number,
- * maxAdditionWidth: number,
- * maxDeletionWidth: number,
- * deletionOffset: number,
- * }}
- */
-Gerrit.LayoutStats;
-
-/**
- * @typedef {{
- * changeNum: number,
- * path: string,
- * patchRange: !Gerrit.PatchRange,
- * projectConfig: (Object|undefined),
- * }}
- */
-Gerrit.CommentMeta;
-
-/**
- * @typedef {{
- * meta: !Gerrit.CommentMeta,
- * left: !Array,
- * right: !Array,
- * }}
- */
-Gerrit.CommentsBySide;
-
-/**
- * The DiffIntralineInfo entity contains information about intraline edits in a
- * file.
- *
- * The information consists of a list of <skip length, mark length> pairs, where
- * the skip length is the number of characters between the end of the previous
- * edit and the start of this edit, and the mark length is the number of edited
- * characters following the skip. The start of the edits is from the beginning
- * of the related diff content lines.
- *
- * Note that the implied newline character at the end of each line is included
- * in the length calculation, and thus it is possible for the edits to span
- * newlines.
- *
- * @typedef {!Array<number>}
- */
-Gerrit.IntralineInfo;
-
-/**
- * A portion of the diff that is treated the same.
- *
- * Called `DiffContent` in the API, see
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-content
- *
- * @typedef {{
- * ab: ?Array<!string>,
- * a: ?Array<!string>,
- * b: ?Array<!string>,
- * skip: ?number,
- * edit_a: ?Array<!Gerrit.IntralineInfo>,
- * edit_b: ?Array<!Gerrit.IntralineInfo>,
- * due_to_rebase: ?boolean,
- * common: ?boolean
- * }}
- */
-Gerrit.DiffChunk;
-
-/**
- * Special line number which should not be collapsed into a shared region.
- *
- * @typedef {{
- * number: number,
- * leftSide: boolean
- * }}
- */
-Gerrit.LineOfInterest;
-
-/**
- * @typedef {{
- * html: Node,
- * position: number,
- * length: number,
- * }}
- */
-Gerrit.CommentLinkItem;
-
-/**
- * @typedef {{
- * name: string,
- * value: Object,
- * }}
- */
-Gerrit.GrSuggestionItem;
-
-/**
- * @typedef {{
- * getSuggestions: function(string): Promise<Array<Object>>,
- * makeSuggestionItem: function(Object): Gerrit.GrSuggestionItem,
- * }}
- */
-Gerrit.GrSuggestionsProvider;
-
-/**
- * @typedef {{
- * patch_set: ?number,
- * id: ?string,
- * path: ?Object,
- * side: ?string,
- * parent: ?number,
- * line: ?Object,
- * in_reply_to: ?string,
- * message: ?Object,
- * updated: ?string,
- * author: ?Object,
- * tag: ?Object,
- * unresolved: ?boolean,
- * robot_id: ?string,
- * robot_run_id: ?string,
- * url: ?string,
- * properties: ?Object,
- * fix_suggestions: ?Object,
- * }}
- */
-Gerrit.Comment;
-
-/**
- * This contains path info used in diff, basePath
- * is used on the left while path is used on the right.
- *
- * TODO(taoalpha): unify all *Range into one.
- *
- * @typedef {{
- * basePath: ?string,
- * path: string,
- * }}
- */
-Gerrit.FileRange;
\ No newline at end of file
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
new file mode 100644
index 0000000..b40d618
--- /dev/null
+++ b/polygerrit-ui/app/types/types.ts
@@ -0,0 +1,239 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {DiffViewMode, Side} from '../constants/constants';
+import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
+import {GrDiffLine} from '../elements/diff/gr-diff/gr-diff-line';
+import {FlattenedNodesObserver} from '@polymer/polymer/lib/utils/flattened-nodes-observer';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+import {
+ ChangeId,
+ CommitId,
+ NumericChangeId,
+ PatchRange,
+ PatchSetNum,
+} from './common';
+import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
+
+export function notUndefined<T>(x: T | undefined): x is T {
+ return x !== undefined;
+}
+
+export interface FixIronA11yAnnouncer extends IronA11yAnnouncer {
+ requestAvailability(): void;
+}
+
+export interface CommitRange {
+ baseCommit: CommitId;
+ commit: CommitId;
+}
+
+export interface CoverageRange {
+ type: CoverageType;
+ side: Side;
+ code_range: {end_line: number; start_line: number};
+}
+
+export enum CoverageType {
+ /**
+ * start_character and end_character of the range will be ignored for this
+ * type.
+ */
+ COVERED = 'COVERED',
+ /**
+ * start_character and end_character of the range will be ignored for this
+ * type.
+ */
+ NOT_COVERED = 'NOT_COVERED',
+ PARTIALLY_COVERED = 'PARTIALLY_COVERED',
+ /**
+ * You don't have to use this. If there is no coverage information for a
+ * range, then it implicitly means NOT_INSTRUMENTED. start_character and
+ * end_character of the range will be ignored for this type.
+ */
+ NOT_INSTRUMENTED = 'NOT_INSTRUMENTED',
+}
+
+export enum ErrorType {
+ AUTH = 'AUTH',
+ NETWORK = 'NETWORK',
+ GENERIC = 'GENERIC',
+}
+
+/**
+ * We would like to access the the typed `nativeInput` of PaperInputElement, so
+ * we are creating this wrapper.
+ */
+export type PaperInputElementExt = PaperInputElement & {
+ $: {nativeInput?: Element};
+};
+
+/**
+ * If Polymer would have exported DomApiNative from its dom.js utility, then we
+ * would probably not need this type. We just use it for casting the return
+ * value of dom(element).
+ */
+export interface PolymerDomWrapper {
+ getOwnerRoot(): Node & OwnerRoot;
+ getEffectiveChildNodes(): Node[];
+ observeNodes(
+ callback: (p0: {
+ target: HTMLElement;
+ addedNodes: Element[];
+ removedNodes: Element[];
+ }) => void
+ ): FlattenedNodesObserver;
+ unobserveNodes(observerHandle: FlattenedNodesObserver): void;
+}
+
+export interface OwnerRoot {
+ host?: HTMLElement;
+}
+
+/**
+ * Event type for an event fired by Polymer for an element generated from a
+ * dom-repeat template.
+ */
+export interface PolymerDomRepeatEvent<TModel = unknown> extends Event {
+ model: PolymerDomRepeatEventModel<TModel>;
+}
+
+/**
+ * Event type for an event fired by Polymer for an element generated from a
+ * dom-repeat template.
+ */
+export interface PolymerDomRepeatCustomEvent<
+ TModel = unknown,
+ TDetail = unknown
+> extends CustomEvent<TDetail> {
+ model: PolymerDomRepeatEventModel<TModel>;
+}
+
+/**
+ * Model containing additional information about the dom-repeat element
+ * that fired an event.
+ *
+ * Note: This interface is valid only if both dom-repeat properties 'as' and
+ * 'indexAs' have default values ('item' and 'index' correspondingly)
+ */
+export interface PolymerDomRepeatEventModel<T> {
+ /**
+ * The item corresponding to the element in the dom-repeat.
+ */
+ item: T;
+
+ /**
+ * 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 */
+export interface HighlightJSResult {
+ value: string;
+ top: unknown;
+}
+
+/** https://highlightjs.readthedocs.io/en/latest/api.html */
+export interface HighlightJS {
+ configure(options: {classPrefix: string}): void;
+ getLanguage(languageName: string): unknown | undefined;
+ highlight(
+ languageName: string,
+ code: string,
+ ignore_illegals: boolean,
+ continuation: unknown
+ ): HighlightJSResult;
+}
+
+export type DiffLayerListener = (
+ start: number,
+ end: number,
+ side: Side
+) => void;
+
+export interface DiffLayer {
+ annotate(el: HTMLElement, lineEl: HTMLElement, line: GrDiffLine): void;
+ addListener?(listener: DiffLayerListener): void;
+ removeListener?(listener: DiffLayerListener): void;
+}
+
+export interface ChangeViewState {
+ changeNum: NumericChangeId | null;
+ patchRange: PatchRange | null;
+ selectedFileIndex: number;
+ showReplyDialog: boolean;
+ showDownloadDialog: boolean;
+ diffMode: DiffViewMode | null;
+ numFilesShown: number | null;
+ scrollTop?: number;
+ diffViewMode?: boolean;
+}
+
+export interface ChangeListViewState {
+ changeNum?: ChangeId;
+ patchRange?: PatchRange;
+ // TODO(TS): seems only one of 2 selected... is required
+ selectedFileIndex?: number;
+ selectedChangeIndex?: number;
+ showReplyDialog?: boolean;
+ showDownloadDialog?: boolean;
+ diffMode?: DiffViewMode;
+ numFilesShown?: number;
+ scrollTop?: number;
+ query?: string | null;
+ offset?: number;
+}
+
+export interface DashboardViewState {
+ selectedChangeIndex: number;
+}
+
+export interface ViewState {
+ changeView: ChangeViewState;
+ changeListView: ChangeListViewState;
+ dashboardView: DashboardViewState;
+}
+
+export interface PatchSetFile {
+ path: string;
+ basePath?: string;
+ patchNum?: PatchSetNum;
+}
+
+export interface PatchNumOnly {
+ patchNum: PatchSetNum;
+}
+
+export function isPatchSetFile(
+ x: PatchSetFile | PatchNumOnly
+): x is PatchSetFile {
+ return !!(x as PatchSetFile).path;
+}
+
+export interface FileRange {
+ basePath?: string;
+ path: string;
+}
+
+export function isPolymerSpliceChange<
+ T,
+ U extends Array<{} | null | undefined>
+>(x: T | PolymerSpliceChange<U>): x is PolymerSpliceChange<U> {
+ return (x as PolymerSpliceChange<U>).indexSplices !== undefined;
+}
diff --git a/polygerrit-ui/app/utils/access-util.js b/polygerrit-ui/app/utils/access-util.js
deleted file mode 100644
index 2578cfa..0000000
--- a/polygerrit-ui/app/utils/access-util.js
+++ /dev/null
@@ -1,143 +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.
- */
-
-export const AccessPermissions = {
- abandon: {
- id: 'abandon',
- name: 'Abandon',
- },
- addPatchSet: {
- id: 'addPatchSet',
- name: 'Add Patch Set',
- },
- create: {
- id: 'create',
- name: 'Create Reference',
- },
- createTag: {
- id: 'createTag',
- name: 'Create Annotated Tag',
- },
- createSignedTag: {
- id: 'createSignedTag',
- name: 'Create Signed Tag',
- },
- delete: {
- id: 'delete',
- name: 'Delete Reference',
- },
- deleteChanges: {
- id: 'deleteChanges',
- name: 'Delete Changes',
- },
- deleteOwnChanges: {
- id: 'deleteOwnChanges',
- name: 'Delete Own Changes',
- },
- editAssignee: {
- id: 'editAssignee',
- name: 'Edit Assignee',
- },
- editHashtags: {
- id: 'editHashtags',
- name: 'Edit Hashtags',
- },
- editTopicName: {
- id: 'editTopicName',
- name: 'Edit Topic Name',
- },
- forgeAuthor: {
- id: 'forgeAuthor',
- name: 'Forge Author Identity',
- },
- forgeCommitter: {
- id: 'forgeCommitter',
- name: 'Forge Committer Identity',
- },
- forgeServerAsCommitter: {
- id: 'forgeServerAsCommitter',
- name: 'Forge Server Identity',
- },
- owner: {
- id: 'owner',
- name: 'Owner',
- },
- publishDrafts: {
- id: 'publishDrafts',
- name: 'Publish Drafts',
- },
- push: {
- id: 'push',
- name: 'Push',
- },
- pushMerge: {
- id: 'pushMerge',
- name: 'Push Merge Commit',
- },
- read: {
- id: 'read',
- name: 'Read',
- },
- rebase: {
- id: 'rebase',
- name: 'Rebase',
- },
- revert: {
- id: 'revert',
- name: 'Revert',
- },
- removeReviewer: {
- id: 'removeReviewer',
- name: 'Remove Reviewer',
- },
- submit: {
- id: 'submit',
- name: 'Submit',
- },
- submitAs: {
- id: 'submitAs',
- name: 'Submit (On Behalf Of)',
- },
- toggleWipState: {
- id: 'toggleWipState',
- name: 'Toggle Work In Progress State',
- },
- viewPrivateChanges: {
- id: 'viewPrivateChanges',
- name: 'View Private Changes',
- },
-};
-
-/**
- * @param {!Object} obj
- * @return {!Array} returns a sorted array sorted by the id of the original
- * object.
- */
-export function toSortedPermissionsArray(obj) {
- if (!obj) { return []; }
- return Object.keys(obj)
- .map(key => {
- return {
- id: key,
- value: obj[key],
- };
- })
- .sort((a, b) =>
- // Since IDs are strings, use localeCompare.
- a.id.localeCompare(b.id)
- );
-}
diff --git a/polygerrit-ui/app/utils/access-util.ts b/polygerrit-ui/app/utils/access-util.ts
new file mode 100644
index 0000000..4af5533
--- /dev/null
+++ b/polygerrit-ui/app/utils/access-util.ts
@@ -0,0 +1,191 @@
+/**
+ * @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 {LabelName} from '../types/common';
+
+export enum AccessPermissionId {
+ ABANDON = 'abandon',
+ ADD_PATCH_SET = 'addPatchSet',
+ CREATE = 'create',
+ CREATE_TAG = 'createTag',
+ CREATE_SIGNED_TAG = 'createSignedTag',
+ DELETE = 'delete',
+ DELETE_CHANGES = 'deleteChanges',
+ DELETE_OWN_CHANGES = 'deleteOwnChanges',
+ EDIT_ASSIGNEE = 'editAssignee',
+ EDIT_HASHTAGS = 'editHashtags',
+ EDIT_TOPIC_NAME = 'editTopicName',
+ FORGE_AUTHOR = 'forgeAuthor',
+ FORGE_COMMITTER = 'forgeCommitter',
+ FORGE_SERVER_AS_COMMITTER = 'forgeServerAsCommitter',
+ OWNER = 'owner',
+ PUBLISH_DRAFTS = 'publishDrafts',
+ PUSH = 'push',
+ PUSH_MERGE = 'pushMerge',
+ READ = 'read',
+ REBASE = 'rebase',
+ REVERT = 'revert',
+ REMOVE_REVIEWER = 'removeReviewer',
+ SUBMIT = 'submit',
+ SUBMIT_AS = 'submitAs',
+ TOGGLE_WIP_STATE = 'toggleWipState',
+ VIEW_PRIVATE_CHANGES = 'viewPrivateChanges',
+
+ PRIORITY = 'priority',
+}
+
+export const AccessPermissions: {[id: string]: AccessPermission} = {
+ [AccessPermissionId.ABANDON]: {
+ id: AccessPermissionId.ABANDON,
+ name: 'Abandon',
+ },
+ [AccessPermissionId.ADD_PATCH_SET]: {
+ id: AccessPermissionId.ADD_PATCH_SET,
+ name: 'Add Patch Set',
+ },
+ [AccessPermissionId.CREATE]: {
+ id: AccessPermissionId.CREATE,
+ name: 'Create Reference',
+ },
+ [AccessPermissionId.CREATE_TAG]: {
+ id: AccessPermissionId.CREATE_TAG,
+ name: 'Create Annotated Tag',
+ },
+ [AccessPermissionId.CREATE_SIGNED_TAG]: {
+ id: AccessPermissionId.CREATE_SIGNED_TAG,
+ name: 'Create Signed Tag',
+ },
+ [AccessPermissionId.DELETE]: {
+ id: AccessPermissionId.DELETE,
+ name: 'Delete Reference',
+ },
+ [AccessPermissionId.DELETE_CHANGES]: {
+ id: AccessPermissionId.DELETE_CHANGES,
+ name: 'Delete Changes',
+ },
+ [AccessPermissionId.DELETE_OWN_CHANGES]: {
+ id: AccessPermissionId.DELETE_OWN_CHANGES,
+ name: 'Delete Own Changes',
+ },
+ [AccessPermissionId.EDIT_ASSIGNEE]: {
+ id: AccessPermissionId.EDIT_ASSIGNEE,
+ name: 'Edit Assignee',
+ },
+ [AccessPermissionId.EDIT_HASHTAGS]: {
+ id: AccessPermissionId.EDIT_HASHTAGS,
+ name: 'Edit Hashtags',
+ },
+ [AccessPermissionId.EDIT_TOPIC_NAME]: {
+ id: AccessPermissionId.EDIT_TOPIC_NAME,
+ name: 'Edit Topic Name',
+ },
+ [AccessPermissionId.FORGE_AUTHOR]: {
+ id: AccessPermissionId.FORGE_AUTHOR,
+ name: 'Forge Author Identity',
+ },
+ [AccessPermissionId.FORGE_COMMITTER]: {
+ id: AccessPermissionId.FORGE_COMMITTER,
+ name: 'Forge Committer Identity',
+ },
+ [AccessPermissionId.FORGE_SERVER_AS_COMMITTER]: {
+ id: AccessPermissionId.FORGE_SERVER_AS_COMMITTER,
+ name: 'Forge Server Identity',
+ },
+ [AccessPermissionId.OWNER]: {
+ id: AccessPermissionId.OWNER,
+ name: 'Owner',
+ },
+ [AccessPermissionId.PUBLISH_DRAFTS]: {
+ id: AccessPermissionId.PUBLISH_DRAFTS,
+ name: 'Publish Drafts',
+ },
+ [AccessPermissionId.PUSH]: {
+ id: AccessPermissionId.PUSH,
+ name: 'Push',
+ },
+ [AccessPermissionId.PUSH_MERGE]: {
+ id: AccessPermissionId.PUSH_MERGE,
+ name: 'Push Merge Commit',
+ },
+ [AccessPermissionId.READ]: {
+ id: AccessPermissionId.READ,
+ name: 'Read',
+ },
+ [AccessPermissionId.REBASE]: {
+ id: AccessPermissionId.REBASE,
+ name: 'Rebase',
+ },
+ [AccessPermissionId.REVERT]: {
+ id: AccessPermissionId.REVERT,
+ name: 'Revert',
+ },
+ [AccessPermissionId.REMOVE_REVIEWER]: {
+ id: AccessPermissionId.REMOVE_REVIEWER,
+ name: 'Remove Reviewer',
+ },
+ [AccessPermissionId.SUBMIT]: {
+ id: AccessPermissionId.SUBMIT,
+ name: 'Submit',
+ },
+ [AccessPermissionId.SUBMIT_AS]: {
+ id: AccessPermissionId.SUBMIT_AS,
+ name: 'Submit (On Behalf Of)',
+ },
+ [AccessPermissionId.TOGGLE_WIP_STATE]: {
+ id: AccessPermissionId.TOGGLE_WIP_STATE,
+ name: 'Toggle Work In Progress State',
+ },
+ [AccessPermissionId.VIEW_PRIVATE_CHANGES]: {
+ id: AccessPermissionId.VIEW_PRIVATE_CHANGES,
+ name: 'View Private Changes',
+ },
+};
+
+export interface AccessPermission {
+ id: AccessPermissionId;
+ name: string;
+ label?: LabelName;
+}
+
+export interface PermissionArrayItem<T> {
+ id: string;
+ value: T;
+}
+
+export type PermissionArray<T> = Array<PermissionArrayItem<T>>;
+
+/**
+ * @return a sorted array sorted by the id of the original
+ * object.
+ */
+export function toSortedPermissionsArray<T>(obj?: {
+ [permissionId: string]: T;
+}): PermissionArray<T> {
+ if (!obj) {
+ return [];
+ }
+ return Object.keys(obj)
+ .map(key => {
+ return {
+ id: key,
+ value: obj[key],
+ };
+ })
+ .sort((a, b) =>
+ // Since IDs are strings, use localeCompare.
+ a.id.localeCompare(b.id)
+ );
+}
diff --git a/polygerrit-ui/app/utils/access-util_test.js b/polygerrit-ui/app/utils/access-util_test.js
deleted file mode 100644
index d4f5669..0000000
--- a/polygerrit-ui/app/utils/access-util_test.js
+++ /dev/null
@@ -1,42 +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 '../test/common-test-setup-karma.js';
-import {toSortedPermissionsArray} from './access-util.js';
-
-suite('gr-access-behavior tests', () => {
- test('toSortedPermissionsArray', () => {
- const rules = {
- 'global:Project-Owners': {
- action: 'ALLOW', force: false,
- },
- '4c97682e6ce6b7247f3381b6f1789356666de7f': {
- action: 'ALLOW', force: false,
- },
- };
- const expectedResult = [
- {id: '4c97682e6ce6b7247f3381b6f1789356666de7f', value: {
- action: 'ALLOW', force: false,
- }},
- {id: 'global:Project-Owners', value: {
- action: 'ALLOW', force: false,
- }},
- ];
- assert.deepEqual(toSortedPermissionsArray(rules), expectedResult);
- });
-});
-
diff --git a/polygerrit-ui/app/utils/access-util_test.ts b/polygerrit-ui/app/utils/access-util_test.ts
new file mode 100644
index 0000000..f098d89
--- /dev/null
+++ b/polygerrit-ui/app/utils/access-util_test.ts
@@ -0,0 +1,51 @@
+/**
+ * @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 '../test/common-test-setup-karma';
+import {toSortedPermissionsArray} from './access-util';
+
+suite('access-util tests', () => {
+ test('toSortedPermissionsArray', () => {
+ const rules = {
+ 'global:Project-Owners': {
+ action: 'ALLOW',
+ force: false,
+ },
+ '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+ action: 'ALLOW',
+ force: false,
+ },
+ };
+ const expectedResult = [
+ {
+ id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+ value: {
+ action: 'ALLOW',
+ force: false,
+ },
+ },
+ {
+ id: 'global:Project-Owners',
+ value: {
+ action: 'ALLOW',
+ force: false,
+ },
+ },
+ ];
+ assert.deepEqual(toSortedPermissionsArray(rules), expectedResult);
+ });
+});
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
new file mode 100644
index 0000000..7e425f8
--- /dev/null
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -0,0 +1,33 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {AccountId, AccountInfo, EmailAddress} from '../types/common';
+import {AccountTag} from '../constants/constants';
+
+export function accountKey(account: AccountInfo): AccountId | EmailAddress {
+ if (account._account_id) return account._account_id;
+ if (account.email) return account.email;
+ throw new Error('Account has neither _account_id nor email.');
+}
+
+export function isServiceUser(account?: AccountInfo): boolean {
+ return !!account?.tags?.includes(AccountTag.SERVICE_USER);
+}
+
+export function removeServiceUsers(accounts?: AccountInfo[]): AccountInfo[] {
+ return accounts?.filter(a => !isServiceUser(a)) || [];
+}
diff --git a/polygerrit-ui/app/utils/account-util_test.js b/polygerrit-ui/app/utils/account-util_test.js
new file mode 100644
index 0000000..0628f2d
--- /dev/null
+++ b/polygerrit-ui/app/utils/account-util_test.js
@@ -0,0 +1,46 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {isServiceUser, removeServiceUsers} from './account-util.js';
+import {AccountTag} from '../constants/constants.js';
+
+const EMPTY = {};
+const ERNIE = {name: 'Ernie'};
+const KERMIT = {name: 'Kermit', tags: ['FROG']};
+const SERVY = {name: 'Servy', tags: [AccountTag.SERVICE_USER]};
+const BOTTY = {name: 'Botty', tags: [AccountTag.SERVICE_USER]};
+
+suite('account-util tests 3', () => {
+ test('isServiceUser', () => {
+ assert.isFalse(isServiceUser());
+ assert.isFalse(isServiceUser(EMPTY));
+ assert.isFalse(isServiceUser(ERNIE));
+ assert.isFalse(isServiceUser(KERMIT));
+ assert.isTrue(isServiceUser(SERVY));
+ assert.isTrue(isServiceUser(BOTTY));
+ });
+
+ test('removeServiceUsers', () => {
+ assert.sameMembers(removeServiceUsers([]), []);
+ assert.sameMembers(removeServiceUsers([EMPTY, ERNIE, KERMIT]),
+ [EMPTY, ERNIE, KERMIT]);
+ assert.sameMembers(removeServiceUsers([SERVY, BOTTY]), []);
+ assert.sameMembers(removeServiceUsers([EMPTY, SERVY, ERNIE, BOTTY, KERMIT]),
+ [EMPTY, ERNIE, KERMIT]);
+ });
+});
diff --git a/polygerrit-ui/app/utils/admin-nav-util.js b/polygerrit-ui/app/utils/admin-nav-util.js
deleted file mode 100644
index c1f3465..0000000
--- a/polygerrit-ui/app/utils/admin-nav-util.js
+++ /dev/null
@@ -1,197 +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 {GerritNav} from '../elements/core/gr-navigation/gr-navigation.js';
-
-const ADMIN_LINKS = [{
- name: 'Repositories',
- noBaseUrl: true,
- url: '/admin/repos',
- view: 'gr-repo-list',
- viewableToAll: true,
-}, {
- name: 'Groups',
- section: 'Groups',
- noBaseUrl: true,
- url: '/admin/groups',
- view: 'gr-admin-group-list',
-}, {
- name: 'Plugins',
- capability: 'viewPlugins',
- section: 'Plugins',
- noBaseUrl: true,
- url: '/admin/plugins',
- view: 'gr-plugin-list',
-}];
-
-/**
- * @param {!Object} account
- * @param {!Function} getAccountCapabilities
- * @param {!Function} getAdminMenuLinks
- * Possible aguments in options:
- * repoName?: string
- * groupId?: string,
- * groupName?: string,
- * groupIsInternal?: boolean,
- * isAdmin?: boolean,
- * groupOwner?: boolean,
- * @param {!Object=} opt_options
- * @return {Promise<!Object>}
- */
-export function getAdminLinks(account, getAccountCapabilities,
- getAdminMenuLinks, opt_options) {
- if (!account) {
- return Promise.resolve(_filterLinks(link => link.viewableToAll,
- getAdminMenuLinks, opt_options));
- }
- return getAccountCapabilities()
- .then(capabilities => _filterLinks(
- link => !link.capability
- || capabilities.hasOwnProperty(link.capability),
- getAdminMenuLinks,
- opt_options));
-}
-
-/**
- * @param {!Function} filterFn
- * @param {!Function} getAdminMenuLinks
- * Possible aguments in options:
- * repoName?: string
- * groupId?: string,
- * groupName?: string,
- * groupIsInternal?: boolean,
- * isAdmin?: boolean,
- * groupOwner?: boolean,
- * @param {!Object|undefined} opt_options
- * @return {Promise<!Object>}
- */
-function _filterLinks(filterFn, getAdminMenuLinks, opt_options) {
- let links = ADMIN_LINKS.slice(0);
- let expandedSection;
-
- const isExternalLink = link => link.url[0] !== '/';
-
- // Append top-level links that are defined by plugins.
- links.push(...getAdminMenuLinks().map(link => {
- return {
- url: link.url,
- name: link.text,
- capability: link.capability || null,
- noBaseUrl: !isExternalLink(link),
- view: null,
- viewableToAll: !link.capability,
- target: isExternalLink(link) ? '_blank' : null,
- };
- }));
-
- links = links.filter(filterFn);
-
- const filteredLinks = [];
- const repoName = opt_options && opt_options.repoName;
- const groupId = opt_options && opt_options.groupId;
- const groupName = opt_options && opt_options.groupName;
- const groupIsInternal = opt_options && opt_options.groupIsInternal;
- const isAdmin = opt_options && opt_options.isAdmin;
- const groupOwner = opt_options && opt_options.groupOwner;
-
- // Don't bother to get sub-navigation items if only the top level links
- // are needed. This is used by the main header dropdown.
- if (!repoName && !groupId) { return {links, expandedSection}; }
-
- // Otherwise determine the full set of links and return both the full
- // set in addition to the subsection that should be displayed if it
- // exists.
- for (const link of links) {
- const linkCopy = Object.assign({}, link);
- if (linkCopy.name === 'Repositories' && repoName) {
- linkCopy.subsection = getRepoSubsections(repoName);
- expandedSection = linkCopy.subsection;
- } else if (linkCopy.name === 'Groups' && groupId && groupName) {
- linkCopy.subsection = getGroupSubsections(groupId, groupName,
- groupIsInternal, isAdmin, groupOwner);
- expandedSection = linkCopy.subsection;
- }
- filteredLinks.push(linkCopy);
- }
- return {links: filteredLinks, expandedSection};
-}
-
-export function getGroupSubsections(groupId, groupName, groupIsInternal,
- isAdmin, groupOwner) {
- const subsection = {
- name: groupName,
- view: GerritNav.View.GROUP,
- url: GerritNav.getUrlForGroup(groupId),
- children: [],
- };
- if (groupIsInternal) {
- subsection.children.push({
- name: 'Members',
- detailType: GerritNav.GroupDetailView.MEMBERS,
- view: GerritNav.View.GROUP,
- url: GerritNav.getUrlForGroupMembers(groupId),
- });
- }
- if (groupIsInternal && (isAdmin || groupOwner)) {
- subsection.children.push(
- {
- name: 'Audit Log',
- detailType: GerritNav.GroupDetailView.LOG,
- view: GerritNav.View.GROUP,
- url: GerritNav.getUrlForGroupLog(groupId),
- }
- );
- }
- return subsection;
-}
-
-export function getRepoSubsections(repoName) {
- return {
- name: repoName,
- view: GerritNav.View.REPO,
- url: GerritNav.getUrlForRepo(repoName),
- children: [{
- name: 'Access',
- view: GerritNav.View.REPO,
- detailType: GerritNav.RepoDetailView.ACCESS,
- url: GerritNav.getUrlForRepoAccess(repoName),
- },
- {
- name: 'Commands',
- view: GerritNav.View.REPO,
- detailType: GerritNav.RepoDetailView.COMMANDS,
- url: GerritNav.getUrlForRepoCommands(repoName),
- },
- {
- name: 'Branches',
- view: GerritNav.View.REPO,
- detailType: GerritNav.RepoDetailView.BRANCHES,
- url: GerritNav.getUrlForRepoBranches(repoName),
- },
- {
- name: 'Tags',
- view: GerritNav.View.REPO,
- detailType: GerritNav.RepoDetailView.TAGS,
- url: GerritNav.getUrlForRepoTags(repoName),
- },
- {
- name: 'Dashboards',
- view: GerritNav.View.REPO,
- detailType: GerritNav.RepoDetailView.DASHBOARDS,
- url: GerritNav.getUrlForRepoDashboards(repoName),
- }],
- };
-}
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
new file mode 100644
index 0000000..06f4e3a
--- /dev/null
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -0,0 +1,256 @@
+/**
+ * @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 {
+ GerritNav,
+ GerritView,
+ RepoDetailView,
+ GroupDetailView,
+} from '../elements/core/gr-navigation/gr-navigation';
+import {
+ RepoName,
+ GroupId,
+ AccountDetailInfo,
+ AccountCapabilityInfo,
+} from '../types/common';
+import {MenuLink} from '../elements/plugins/gr-admin-api/gr-admin-api';
+import {hasOwnProperty} from './common-util';
+
+const ADMIN_LINKS: NavLink[] = [
+ {
+ name: 'Repositories',
+ noBaseUrl: true,
+ url: '/admin/repos',
+ view: 'gr-repo-list',
+ viewableToAll: true,
+ },
+ {
+ name: 'Groups',
+ section: 'Groups',
+ noBaseUrl: true,
+ url: '/admin/groups',
+ view: 'gr-admin-group-list',
+ },
+ {
+ name: 'Plugins',
+ capability: 'viewPlugins',
+ section: 'Plugins',
+ noBaseUrl: true,
+ url: '/admin/plugins',
+ view: 'gr-plugin-list',
+ },
+];
+
+export interface AdminLink {
+ url: string;
+ text: string;
+ capability: string | null;
+ noBaseUrl: boolean;
+ view: null;
+ viewableToAll: boolean;
+ target: '_blank' | null;
+}
+
+export interface AdminLinks {
+ links: NavLink[];
+ expandedSection?: SubsectionInterface;
+}
+
+export function getAdminLinks(
+ account: AccountDetailInfo | undefined,
+ getAccountCapabilities: () => Promise<AccountCapabilityInfo>,
+ getAdminMenuLinks: () => MenuLink[],
+ options?: AdminNavLinksOption
+): Promise<AdminLinks> {
+ if (!account) {
+ return Promise.resolve(
+ _filterLinks(link => !!link.viewableToAll, getAdminMenuLinks, options)
+ );
+ }
+ return getAccountCapabilities().then(capabilities =>
+ _filterLinks(
+ link => !link.capability || hasOwnProperty(capabilities, link.capability),
+ getAdminMenuLinks,
+ options
+ )
+ );
+}
+
+function _filterLinks(
+ filterFn: (link: NavLink) => boolean,
+ getAdminMenuLinks: () => MenuLink[],
+ options?: AdminNavLinksOption
+): AdminLinks {
+ let links: NavLink[] = ADMIN_LINKS.slice(0);
+ let expandedSection: SubsectionInterface | undefined = undefined;
+
+ const isExternalLink = (link: MenuLink) => link.url[0] !== '/';
+
+ // Append top-level links that are defined by plugins.
+ links.push(
+ ...getAdminMenuLinks().map((link: MenuLink) => {
+ return {
+ url: link.url,
+ name: link.text,
+ capability: link.capability || undefined,
+ noBaseUrl: !isExternalLink(link),
+ view: null,
+ viewableToAll: !link.capability,
+ target: isExternalLink(link) ? '_blank' : null,
+ };
+ })
+ );
+
+ links = links.filter(filterFn);
+
+ const filteredLinks: NavLink[] = [];
+ const repoName = options && options.repoName;
+ const groupId = options && options.groupId;
+ const groupName = options && options.groupName;
+ const groupIsInternal = options && options.groupIsInternal;
+ const isAdmin = options && options.isAdmin;
+ const groupOwner = options && options.groupOwner;
+
+ // Don't bother to get sub-navigation items if only the top level links
+ // are needed. This is used by the main header dropdown.
+ if (!repoName && !groupId) {
+ return {links, expandedSection};
+ }
+
+ // Otherwise determine the full set of links and return both the full
+ // set in addition to the subsection that should be displayed if it
+ // exists.
+ for (const link of links) {
+ const linkCopy = {...link};
+ if (linkCopy.name === 'Repositories' && repoName) {
+ linkCopy.subsection = getRepoSubsections(repoName);
+ expandedSection = linkCopy.subsection;
+ } else if (linkCopy.name === 'Groups' && groupId && groupName) {
+ linkCopy.subsection = getGroupSubsections(
+ groupId,
+ groupName,
+ groupIsInternal,
+ isAdmin,
+ groupOwner
+ );
+ expandedSection = linkCopy.subsection;
+ }
+ filteredLinks.push(linkCopy);
+ }
+ return {links: filteredLinks, expandedSection};
+}
+
+export function getGroupSubsections(
+ groupId: GroupId,
+ groupName: string,
+ groupIsInternal?: boolean,
+ isAdmin?: boolean,
+ groupOwner?: boolean
+) {
+ const children: SubsectionInterface[] = [];
+ const subsection: SubsectionInterface = {
+ name: groupName,
+ view: GerritNav.View.GROUP,
+ url: GerritNav.getUrlForGroup(groupId),
+ children,
+ };
+ if (groupIsInternal) {
+ children.push({
+ name: 'Members',
+ detailType: GerritNav.GroupDetailView.MEMBERS,
+ view: GerritNav.View.GROUP,
+ url: GerritNav.getUrlForGroupMembers(groupId),
+ });
+ }
+ if (groupIsInternal && (isAdmin || groupOwner)) {
+ children.push({
+ name: 'Audit Log',
+ detailType: GerritNav.GroupDetailView.LOG,
+ view: GerritNav.View.GROUP,
+ url: GerritNav.getUrlForGroupLog(groupId),
+ });
+ }
+ return subsection;
+}
+
+export function getRepoSubsections(repoName: RepoName) {
+ return {
+ name: repoName,
+ view: GerritNav.View.REPO,
+ url: GerritNav.getUrlForRepo(repoName),
+ children: [
+ {
+ name: 'Access',
+ view: GerritNav.View.REPO,
+ detailType: GerritNav.RepoDetailView.ACCESS,
+ url: GerritNav.getUrlForRepoAccess(repoName),
+ },
+ {
+ name: 'Commands',
+ view: GerritNav.View.REPO,
+ detailType: GerritNav.RepoDetailView.COMMANDS,
+ url: GerritNav.getUrlForRepoCommands(repoName),
+ },
+ {
+ name: 'Branches',
+ view: GerritNav.View.REPO,
+ detailType: GerritNav.RepoDetailView.BRANCHES,
+ url: GerritNav.getUrlForRepoBranches(repoName),
+ },
+ {
+ name: 'Tags',
+ view: GerritNav.View.REPO,
+ detailType: GerritNav.RepoDetailView.TAGS,
+ url: GerritNav.getUrlForRepoTags(repoName),
+ },
+ {
+ name: 'Dashboards',
+ view: GerritNav.View.REPO,
+ detailType: GerritNav.RepoDetailView.DASHBOARDS,
+ url: GerritNav.getUrlForRepoDashboards(repoName),
+ },
+ ],
+ };
+}
+
+export interface SubsectionInterface {
+ name: string;
+ view: GerritView;
+ detailType?: RepoDetailView | GroupDetailView;
+ url: string;
+ children?: SubsectionInterface[];
+}
+
+export interface AdminNavLinksOption {
+ repoName?: RepoName;
+ groupId?: GroupId;
+ groupName?: string;
+ groupIsInternal?: boolean;
+ isAdmin?: boolean;
+ groupOwner?: boolean;
+}
+
+export interface NavLink {
+ name: string;
+ noBaseUrl: boolean;
+ url: string;
+ view: string | null;
+ viewableToAll?: boolean;
+ section?: string;
+ capability?: string;
+ target?: string | null;
+ subsection?: SubsectionInterface;
+}
diff --git a/polygerrit-ui/app/utils/async-util.js b/polygerrit-ui/app/utils/async-util.js
deleted file mode 100644
index 14d0288..0000000
--- a/polygerrit-ui/app/utils/async-util.js
+++ /dev/null
@@ -1,38 +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.
- */
-
-/**
- * @template T
- * @param {!Array<T>} array
- * @param {!Function} fn An iteratee function to be passed each element of
- * the array in order. Must return a promise, and the following
- * iteration will not begin until resolution of the promise returned by
- * the previous iteration.
- *
- * An optional second argument to fn is a callback that will halt the
- * loop if called.
- * @return {!Promise<undefined>}
- */
-export function asyncForeach(array, fn) {
- if (!array.length) { return Promise.resolve(); }
- let stop = false;
- const stopCallback = () => { stop = true; };
- return fn(array[0], stopCallback).then(exit => {
- if (stop) { return Promise.resolve(); }
- return asyncForeach(array.slice(1), fn);
- });
-}
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
new file mode 100644
index 0000000..119b09b
--- /dev/null
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -0,0 +1,44 @@
+/**
+ * @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.
+ */
+
+/**
+ * @param fn An iteratee function to be passed each element of
+ * the array in order. Must return a promise, and the following
+ * iteration will not begin until resolution of the promise returned by
+ * the previous iteration.
+ *
+ * An optional second argument to fn is a callback that will halt the
+ * loop if called.
+ */
+export function asyncForeach<T>(
+ array: T[],
+ fn: (item: T, stopCallback: () => void) => Promise<unknown>
+): Promise<T | void> {
+ if (!array.length) {
+ return Promise.resolve();
+ }
+ let stop = false;
+ const stopCallback = () => {
+ stop = true;
+ };
+ return fn(array[0], stopCallback).then(() => {
+ if (stop) {
+ return Promise.resolve();
+ }
+ return asyncForeach(array.slice(1), fn);
+ });
+}
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
new file mode 100644
index 0000000..b0aefcb
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {AccountInfo, ChangeInfo} from '../types/common';
+import {isServiceUser} from './account-util';
+
+// You would typically use a ServerInfo here, but this utility does not care
+// about all the other parameters in that object.
+interface SimpleServerInfo {
+ change?: {
+ enable_attention_set?: boolean;
+ };
+}
+
+const CONFIG_ENABLED: SimpleServerInfo = {
+ change: {enable_attention_set: true},
+};
+
+export function isAttentionSetEnabled(config?: SimpleServerInfo): boolean {
+ return !!config?.change?.enable_attention_set;
+}
+
+export function canHaveAttention(account?: AccountInfo): boolean {
+ return !!account?._account_id && !isServiceUser(account);
+}
+
+export function hasAttention(
+ config?: SimpleServerInfo,
+ account?: AccountInfo,
+ change?: ChangeInfo
+): boolean {
+ return (
+ isAttentionSetEnabled(config) &&
+ canHaveAttention(account) &&
+ !!change?.attention_set?.hasOwnProperty(account!._account_id!)
+ );
+}
+
+export function getReason(account?: AccountInfo, change?: ChangeInfo) {
+ if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+ const entry = change!.attention_set![account!._account_id!];
+ return entry?.reason ? entry.reason : '';
+}
+
+export function getLastUpdate(account?: AccountInfo, change?: ChangeInfo) {
+ if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+ const entry = change!.attention_set![account!._account_id!];
+ return entry?.last_update ? entry.last_update : '';
+}
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.js b/polygerrit-ui/app/utils/attention-set-util_test.js
new file mode 100644
index 0000000..71735d5
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util_test.js
@@ -0,0 +1,57 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+ hasAttention, getReason,
+} from './attention-set-util.js';
+
+const KERMIT = {
+ email: 'kermit@gmail.com',
+ username: 'kermit',
+ name: 'Kermit The Frog',
+ _account_id: '31415926535',
+};
+
+suite('attention-set-util', () => {
+ test('hasAttention', () => {
+ const config = {
+ change: {enable_attention_set: true},
+ };
+ const change = {
+ attention_set: {
+ 31415926535: {
+ reason: 'a good reason',
+ },
+ },
+ };
+
+ assert.isTrue(hasAttention(config, KERMIT, change));
+ });
+
+ test('getReason', () => {
+ const change = {
+ attention_set: {
+ 31415926535: {
+ reason: 'a good reason',
+ },
+ },
+ };
+
+ assert.equal(getReason(KERMIT, change), 'a good reason');
+ });
+});
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 5a39b23..47924e6 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -16,22 +16,13 @@
*/
import {getBaseUrl} from './url-util';
import {ChangeStatus} from '../constants/constants';
-
-// WARNING: The types below can be completely wrong!
-// The types was added to avoid eslinter and typescript errors.
-// Correct typing requires more analysis and (probably) code changes.
-// This will be done later.
-type ChangeNum = string; // This can be wrong! See WARNING above
-type PatchNum = string; // This can be wrong! See WARNING above
-
-// 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 {
+ NumericChangeId,
+ PatchSetNum,
+ ChangeInfo,
+ AccountInfo,
+} from '../types/common';
+import {ParsedChangeInfo} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
// This can be wrong! See WARNING above
interface ChangeStatusesOptions {
@@ -122,38 +113,28 @@
return v.toString(16);
}
-/**
- * @return {string}
- */
export function changeBaseURL(
project: string,
- changeNum: ChangeNum,
- patchNum: PatchNum
+ changeNum: NumericChangeId,
+ patchNum: PatchSetNum
): string {
- let v =
- getBaseUrl() + '/changes/' + encodeURIComponent(project) + '~' + changeNum;
+ let v = `${getBaseUrl()}/changes/${encodeURIComponent(project)}~${changeNum}`;
if (patchNum) {
- v += '/revisions/' + patchNum;
+ v += `/revisions/${patchNum}`;
}
return v;
}
-export function changePath(changeNum: ChangeNum) {
- return getBaseUrl() + '/c/' + changeNum;
+export function changePath(changeNum: NumericChangeId) {
+ return `${getBaseUrl()}/c/${changeNum}`;
}
-export function changeIsOpen(change?: Change) {
- return change && change.status === ChangeStatus.NEW;
+export function changeIsOpen(change?: ChangeInfo | ParsedChangeInfo) {
+ return change?.status === ChangeStatus.NEW;
}
-/**
- * @param {!Object} change
- * @param {!Object=} opt_options
- *
- * @return {!Array}
- */
export function changeStatuses(
- change: Change,
+ change: ChangeInfo,
opt_options?: ChangeStatusesOptions
) {
const states = [];
@@ -191,10 +172,23 @@
return states;
}
-/**
- * @param {!Object} change
- * @return {string}
- */
-export function changeStatusString(change: Change) {
+export function isOwner(change?: ChangeInfo, account?: AccountInfo) {
+ if (!change || !account) return false;
+ return change.owner?._account_id === account._account_id;
+}
+
+export function changeStatusString(change: ChangeInfo) {
return changeStatuses(change).join(', ');
}
+
+export function isRemovableReviewer(
+ change?: ChangeInfo,
+ reviewer?: AccountInfo
+): boolean {
+ if (!change?.removable_reviewers || !reviewer) return false;
+ return change.removable_reviewers.some(
+ account =>
+ account._account_id === reviewer._account_id ||
+ (!reviewer._account_id && account.email === reviewer.email)
+ );
+}
diff --git a/polygerrit-ui/app/utils/change-util_test.js b/polygerrit-ui/app/utils/change-util_test.js
index 20b9578..fd181fe 100644
--- a/polygerrit-ui/app/utils/change-util_test.js
+++ b/polygerrit-ui/app/utils/change-util_test.js
@@ -21,6 +21,7 @@
changePath,
changeStatuses,
changeStatusString,
+ isRemovableReviewer,
} from './change-util.js';
suite('change-util tests', () => {
@@ -198,5 +199,19 @@
assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
assert.equal(statusString, 'Merge Conflict, WIP, Private');
});
+
+ test('isRemovableReviewer', () => {
+ let change = {
+ removable_reviewers: [{_account_id: 1}],
+ };
+ const reviewer = {_account_id: 1};
+
+ assert.equal(isRemovableReviewer(change, reviewer), true);
+
+ change = {
+ removable_reviewers: [{_account_id: 2}],
+ };
+ assert.equal(isRemovableReviewer(change, reviewer), false);
+ });
});
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
new file mode 100644
index 0000000..5f8aa82
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -0,0 +1,124 @@
+/**
+ * @license
+ * 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.
+ */
+import {
+ CommentBasics,
+ CommentInfo,
+ PatchSetNum,
+ RobotCommentInfo,
+ Timestamp,
+ UrlEncodedCommentId,
+} from '../types/common';
+import {CommentSide, Side} from '../constants/constants';
+import {parseDate} from './date-util';
+
+export interface DraftCommentProps {
+ __draft?: boolean;
+ __draftID?: string;
+ __date?: Date;
+}
+
+export type DraftInfo = CommentBasics & DraftCommentProps;
+
+/**
+ * Each of the type implements or extends CommentBasics.
+ */
+export type Comment = DraftInfo | CommentInfo | RobotCommentInfo;
+
+export interface UIStateCommentProps {
+ // The `side` of the comment is PARENT or REVISION, but this is LEFT or RIGHT.
+ // TODO(TS): Remove the naming confusion of commentSide being of type of Side,
+ // but side being of type CommentSide. :-)
+ __commentSide?: Side;
+ // TODO(TS): Remove this. Seems to be exactly the same as `path`??
+ __path?: string;
+ collapsed?: boolean;
+ // TODO(TS): Consider allowing this only for drafts.
+ __editing?: boolean;
+ __otherEditing?: boolean;
+}
+
+export type UIDraft = DraftInfo & UIStateCommentProps;
+
+export type UIHuman = CommentInfo & UIStateCommentProps;
+
+export type UIRobot = RobotCommentInfo & UIStateCommentProps;
+
+export type UIComment = UIHuman | UIRobot | UIDraft;
+
+export type CommentMap = {[path: string]: boolean};
+
+export function isRobot<T extends CommentInfo>(
+ x: T | DraftInfo | RobotCommentInfo | undefined
+): x is RobotCommentInfo {
+ return !!x && !!(x as RobotCommentInfo).robot_id;
+}
+
+export function isDraft<T extends CommentInfo>(
+ x: T | UIDraft | undefined
+): x is UIDraft {
+ return !!x && !!(x as UIDraft).__draft;
+}
+
+interface SortableComment {
+ __draft?: boolean;
+ __date?: Date;
+ updated?: Timestamp;
+ id?: UrlEncodedCommentId;
+}
+
+export function sortComments<T extends SortableComment>(comments: T[]): T[] {
+ return comments.slice(0).sort((c1, c2) => {
+ const d1 = !!c1.__draft;
+ const d2 = !!c2.__draft;
+ if (d1 !== d2) return d1 ? 1 : -1;
+
+ const date1 = (c1.updated && parseDate(c1.updated)) || c1.__date;
+ const date2 = (c2.updated && parseDate(c2.updated)) || c2.__date;
+ const dateDiff = date1!.valueOf() - date2!.valueOf();
+ if (dateDiff !== 0) return dateDiff;
+
+ const id1 = c1.id ?? '';
+ const id2 = c2.id ?? '';
+ return id1.localeCompare(id2);
+ });
+}
+
+export interface CommentThread {
+ comments: UIComment[];
+ patchNum?: PatchSetNum;
+ path: string;
+ // TODO(TS): It would be nice to use LineNumber here, but the comment thread
+ // element actually relies on line to be undefined for file comments. Be
+ // aware of element attribute getters and setters, if you try to refactor
+ // this. :-) Still worthwhile to do ...
+ line?: number;
+ rootId: UrlEncodedCommentId;
+ commentSide?: CommentSide;
+}
+
+export function getLastComment(thread?: CommentThread): UIComment | undefined {
+ const len = thread?.comments.length;
+ return thread && len ? thread.comments[len - 1] : undefined;
+}
+
+export function isUnresolved(thread?: CommentThread): boolean {
+ return !!getLastComment(thread)?.unresolved;
+}
+
+export function isDraftThread(thread?: CommentThread): boolean {
+ return isDraft(getLastComment(thread));
+}
diff --git a/polygerrit-ui/app/utils/comment-util_test.js b/polygerrit-ui/app/utils/comment-util_test.js
new file mode 100644
index 0000000..ad19974
--- /dev/null
+++ b/polygerrit-ui/app/utils/comment-util_test.js
@@ -0,0 +1,34 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+ isUnresolved,
+} from './comment-util.js';
+
+suite('comment-util', () => {
+ test('isUnresolved', () => {
+ assert.isFalse(isUnresolved(undefined));
+ assert.isFalse(isUnresolved({comments: []}));
+ assert.isTrue(isUnresolved({comments: [{unresolved: true}]}));
+ assert.isFalse(isUnresolved({comments: [{unresolved: false}]}));
+ assert.isTrue(isUnresolved(
+ {comments: [{unresolved: false}, {unresolved: true}]}));
+ assert.isFalse(isUnresolved(
+ {comments: [{unresolved: true}, {unresolved: false}]}));
+ });
+});
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
new file mode 100644
index 0000000..5b332ea
--- /dev/null
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * 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.
+ */
+
+/**
+ * @fileoverview Functions in this file contains some widely used
+ * code patterns. If you noticed a repeated code and none of the existing util
+ * files are appropriate for it - you can wrap the code in a function and put it
+ * here. If you notice that several functions can be group together - create
+ * a separate util file for them.
+ */
+
+/**
+ * Wrapper for the Object.prototype.hasOwnProperty method
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function hasOwnProperty(obj: any, prop: PropertyKey) {
+ // Typescript rules don't allow to use obj.hasOwnProperty directly
+ return Object.prototype.hasOwnProperty.call(obj, prop);
+}
+
+// TODO(TS): move to common types once we have type utils
+// Required for constructor signature.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type Constructor<T> = new (...args: any[]) => T;
+
+/**
+ * Use the function for compile-time checking that all possible input
+ * values are processed
+ */
+export function assertNever(obj: never, msg: string): never {
+ console.error(msg, obj);
+ throw new Error(msg);
+}
+
+/**
+ * Returns true, if both sets contain the same members.
+ */
+export function areSetsEqual<T>(a: Set<T>, b: Set<T>): boolean {
+ if (a.size !== b.size) {
+ return false;
+ }
+ return containsAll(a, b);
+}
+
+/**
+ * Returns true, if 'set' contains 'subset'.
+ */
+export function containsAll<T>(set: Set<T>, subSet: Set<T>): boolean {
+ for (const value of subSet) {
+ if (!set.has(value)) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.js
new file mode 100644
index 0000000..917d652b
--- /dev/null
+++ b/polygerrit-ui/app/utils/common-util_test.js
@@ -0,0 +1,69 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {hasOwnProperty, areSetsEqual, containsAll} from './common-util.js';
+
+suite('common-util tests', () => {
+ suite('hasOwnProperty', () => {
+ test('object with the default prototype', () => {
+ const obj = {
+ 'abc': 3,
+ 'name with spaces': 5,
+ };
+ assert.isTrue(hasOwnProperty(obj, 'abc'));
+ assert.isTrue(hasOwnProperty(obj, 'name with spaces'));
+ assert.isFalse(hasOwnProperty(obj, 'def'));
+ });
+ test('object prototype has overriden hasOwnProperty', () => {
+ const F = function() {
+ this.abc = 23;
+ };
+ F.prototype.hasOwnProperty = function(key) {
+ return true;
+ };
+ const obj = new F();
+ assert.isTrue(hasOwnProperty(obj, 'abc'));
+ assert.isFalse(hasOwnProperty(obj, 'def'));
+ });
+ });
+
+ test('areSetsEqual', () => {
+ assert.isTrue(areSetsEqual(new Set(), new Set()));
+ assert.isTrue(areSetsEqual(new Set([1]), new Set([1])));
+ assert.isTrue(areSetsEqual(new Set([1, 1, 1, 1]), new Set([1])));
+ assert.isTrue(areSetsEqual(new Set([1, 1, 2, 2]), new Set([2, 1, 2, 1])));
+ assert.isTrue(areSetsEqual(new Set([1, 2, 3, 4]), new Set([4, 3, 2, 1])));
+ assert.isFalse(areSetsEqual(new Set(), new Set([1])));
+ assert.isFalse(areSetsEqual(new Set([1]), new Set([2])));
+ assert.isFalse(areSetsEqual(new Set([1, 2, 4]), new Set([1, 2, 3])));
+ });
+
+ test('containsAll', () => {
+ assert.isTrue(containsAll(new Set(), new Set()));
+ assert.isTrue(containsAll(new Set([1]), new Set()));
+ assert.isTrue(containsAll(new Set([1]), new Set([1])));
+ assert.isTrue(containsAll(new Set([1, 2]), new Set([1])));
+ assert.isTrue(containsAll(new Set([1, 2]), new Set([2])));
+ assert.isTrue(containsAll(new Set([1, 2, 3, 4]), new Set([1, 4])));
+ assert.isTrue(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 4])));
+ assert.isFalse(containsAll(new Set(), new Set([2])));
+ assert.isFalse(containsAll(new Set([1]), new Set([2])));
+ assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([5])));
+ assert.isFalse(containsAll(new Set([1, 2, 3, 4]), new Set([1, 2, 3, 5])));
+ });
+});
diff --git a/polygerrit-ui/app/utils/date-util.js b/polygerrit-ui/app/utils/date-util.js
deleted file mode 100644
index 475fdc6..0000000
--- a/polygerrit-ui/app/utils/date-util.js
+++ /dev/null
@@ -1,178 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-const Duration = {
- HOUR: 1000 * 60 * 60,
- DAY: 1000 * 60 * 60 * 24,
-};
-
-export function parseDate(dateStr) {
- // Timestamps are given in UTC and have the format
- // "'yyyy-mm-dd hh:mm:ss.fffffffff'" where "'ffffffffff'" represents
- // nanoseconds.
- // Munge the date into an ISO 8061 format and parse that.
- return new Date(dateStr.replace(' ', 'T') + 'Z');
-}
-
-export function isValidDate(date) {
- return date instanceof Date && !isNaN(date);
-}
-
-// similar to fromNow from moment.js
-export function fromNow(date) {
- const now = new Date();
- const secondsAgo = Math.round((now - date) / 1000);
- if (secondsAgo <= 44) return 'just now';
- if (secondsAgo <= 89) return 'a minute ago';
- const minutesAgo = Math.round(secondsAgo / 60);
- if (minutesAgo <= 44) return `${minutesAgo} minutes ago`;
- if (minutesAgo <= 89) return 'an hour ago';
- const hoursAgo = Math.round(minutesAgo / 60);
- if (hoursAgo <= 21) return `${hoursAgo} hours ago`;
- if (hoursAgo <= 35) return 'a day ago';
- const daysAgo = Math.round(hoursAgo / 24);
- if (daysAgo <= 25) return `${daysAgo} days ago`;
- if (daysAgo <= 45) return `a month ago`;
- const monthsAgo = Math.round(daysAgo / 30);
- if (daysAgo <= 319) return `${monthsAgo} months ago`;
- if (daysAgo <= 547) return `a year ago`;
- const yearsAgo = Math.round(daysAgo / 365);
- return `${yearsAgo} years ago`;
-}
-
-/**
- * Return true if date is within 24 hours and on the same day.
- */
-export function isWithinDay(now, date) {
- const diff = now - date;
- return diff < Duration.DAY && date.getDay() == now.getDay();
-}
-
-/**
- * Returns true if date is from one to six months.
- */
-export function isWithinHalfYear(now, date) {
- const diff = now - date;
- return diff < 180 * Duration.DAY;
-}
-
-export function formatDate(date, format) {
- const options = {};
- if (format.includes('MM')) {
- if (format.includes('MMM')) {
- options.month = 'short';
- } else {
- options.month = '2-digit';
- }
- }
- if (format.includes('YY')) {
- if (format.includes('YYYY')) {
- options.year = 'numeric';
- } else {
- options.year = '2-digit';
- }
- }
-
- if (format.includes('DD')) {
- options.day = '2-digit';
- }
-
- if (format.includes('HH')) {
- options.hour = '2-digit';
- options.hour12 = false;
- }
-
- if (format.includes('h')) {
- options.hour = 'numeric';
- options.hour12 = true;
- }
-
- if (format.includes('mm')) {
- options.minute = '2-digit';
- }
-
- if (format.includes('ss')) {
- options.second = '2-digit';
- }
- let locale = 'en-US';
- // Workaround for Chrome 80, en-US is using h24 (midnight is 24:00),
- // en-GB is using h23 (midnight is 00:00)
- if (format.includes('HH')) {
- locale = 'en-GB';
- }
-
- const dtf = new Intl.DateTimeFormat(locale, options);
- const parts = dtf.formatToParts(date).filter(o => o.type != 'literal')
- .reduce((acc, o) => {
- acc[o.type] = o.value;
- return acc;
- }, {});
- if (format.includes('YY')) {
- if (format.includes('YYYY')) {
- format = format.replace('YYYY', parts.year);
- } else {
- format = format.replace('YY', parts.year);
- }
- }
-
- if (format.includes('DD')) {
- format = format.replace('DD', parts.day);
- }
-
- if (format.includes('HH')) {
- format = format.replace('HH', parts.hour);
- }
-
- if (format.includes('h')) {
- format = format.replace('h', parts.hour);
- }
-
- if (format.includes('mm')) {
- format = format.replace('mm', parts.minute);
- }
-
- if (format.includes('ss')) {
- format = format.replace('ss', parts.second);
- }
-
- if (format.includes('A')) {
- if (parts.dayperiod) {
- // Workaround for chrome 70 and below
- format = format.replace('A', parts.dayperiod.toUpperCase());
- } else {
- format = format.replace('A', parts.dayPeriod.toUpperCase());
- }
- }
- if (format.includes('MM')) {
- if (format.includes('MMM')) {
- format = format.replace('MMM', parts.month);
- } else {
- format = format.replace('MM', parts.month);
- }
- }
- return format;
-}
-
-export function utcOffsetString() {
- const now = new Date();
- const tzo = -now.getTimezoneOffset();
- const pad = num => {
- const norm = Math.floor(Math.abs(num));
- return (norm < 10 ? '0' : '') + norm;
- };
- return ` UTC${tzo >= 0 ? '+' : '-'}${pad(tzo / 60)}:${pad(tzo%60)}`;
-}
\ No newline at end of file
diff --git a/polygerrit-ui/app/utils/date-util.ts b/polygerrit-ui/app/utils/date-util.ts
new file mode 100644
index 0000000..1dd2d2f
--- /dev/null
+++ b/polygerrit-ui/app/utils/date-util.ts
@@ -0,0 +1,214 @@
+import {Timestamp} from '../types/common';
+
+/**
+ * @license
+ * 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.
+ */
+
+const Duration = {
+ HOUR: 1000 * 60 * 60,
+ DAY: 1000 * 60 * 60 * 24,
+};
+
+export function parseDate(dateStr: Timestamp) {
+ // Timestamps are given in UTC and have the format
+ // "'yyyy-mm-dd hh:mm:ss.fffffffff'" where "'ffffffffff'" represents
+ // nanoseconds.
+ // Munge the date into an ISO 8061 format and parse that.
+ return new Date(dateStr.replace(' ', 'T') + 'Z');
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function isValidDate(date: any): date is Date {
+ return date instanceof Date && !isNaN(date.valueOf());
+}
+
+// similar to fromNow from moment.js
+export function fromNow(date: Date, noAgo = false) {
+ const now = new Date();
+ const ago = noAgo ? '' : ' ago';
+ const secondsAgo = Math.round((now.valueOf() - date.valueOf()) / 1000);
+ if (secondsAgo <= 59) return 'just now';
+ if (secondsAgo <= 119) return `1 minute${ago}`;
+ const minutesAgo = Math.round(secondsAgo / 60);
+ if (minutesAgo <= 59) return `${minutesAgo} minutes${ago}`;
+ if (minutesAgo === 60) return `1 hour${ago}`;
+ if (minutesAgo <= 119) return `1 hour ${minutesAgo - 60} min${ago}`;
+ const hoursAgo = Math.round(minutesAgo / 60);
+ if (hoursAgo <= 23) return `${hoursAgo} hours${ago}`;
+ if (hoursAgo === 24) return `1 day${ago}`;
+ if (hoursAgo <= 47) return `1 day ${hoursAgo - 24} hr${ago}`;
+ const daysAgo = Math.round(hoursAgo / 24);
+ if (daysAgo <= 30) return `${daysAgo} days${ago}`;
+ if (daysAgo <= 60) return `1 month${ago}`;
+ const monthsAgo = Math.round(daysAgo / 30);
+ if (monthsAgo <= 11) return `${monthsAgo} months${ago}`;
+ if (monthsAgo === 12) return `1 year${ago}`;
+ if (monthsAgo <= 24) return `1 year ${monthsAgo - 12} m${ago}`;
+ const yearsAgo = Math.round(daysAgo / 365);
+ return `${yearsAgo} years${ago}`;
+}
+
+/**
+ * Return true if date is within 24 hours and on the same day.
+ */
+export function isWithinDay(now: Date, date: Date) {
+ const diff = now.valueOf() - date.valueOf();
+ return diff < Duration.DAY && date.getDay() === now.getDay();
+}
+
+/**
+ * Returns true if date is from one to six months.
+ */
+export function isWithinHalfYear(now: Date, date: Date) {
+ const diff = now.valueOf() - date.valueOf();
+ return diff < 180 * Duration.DAY;
+}
+interface Options {
+ month?: string;
+ year?: string;
+ day?: string;
+ hour?: string;
+ hour12?: boolean;
+ minute?: string;
+ second?: string;
+}
+
+// TODO(dmfilippov): TS-Fix review this type. All fields here must be optional,
+// but this require some changes in the code. During JS->TS migration
+// we want to avoid code changes where possible, so for simplicity we
+// define it with almost all fields mandatory
+interface DateTimeFormatParts {
+ year: string;
+ month: string;
+ day: string;
+ hour: string;
+ minute: string;
+ second: string;
+ dayPeriod: string;
+ dayperiod?: string;
+ // Object can have other properties, but our code doesn't use it
+ [key: string]: string | undefined;
+}
+
+export function formatDate(date: Date, format: string) {
+ const options: Options = {};
+ if (format.includes('MM')) {
+ if (format.includes('MMM')) {
+ options.month = 'short';
+ } else {
+ options.month = '2-digit';
+ }
+ }
+ if (format.includes('YY')) {
+ if (format.includes('YYYY')) {
+ options.year = 'numeric';
+ } else {
+ options.year = '2-digit';
+ }
+ }
+
+ if (format.includes('DD')) {
+ options.day = '2-digit';
+ }
+
+ if (format.includes('HH')) {
+ options.hour = '2-digit';
+ options.hour12 = false;
+ }
+
+ if (format.includes('h')) {
+ options.hour = 'numeric';
+ options.hour12 = true;
+ }
+
+ if (format.includes('mm')) {
+ options.minute = '2-digit';
+ }
+
+ if (format.includes('ss')) {
+ options.second = '2-digit';
+ }
+
+ let locale = 'en-US';
+ // Workaround for Chrome 80, en-US is using h24 (midnight is 24:00),
+ // en-GB is using h23 (midnight is 00:00)
+ if (format.includes('HH')) {
+ locale = 'en-GB';
+ }
+
+ const dtf = new Intl.DateTimeFormat(locale, options);
+ const parts = dtf
+ .formatToParts(date)
+ .filter(o => o.type !== 'literal')
+ .reduce((acc, o: Intl.DateTimeFormatPart) => {
+ acc[o.type] = o.value;
+ return acc;
+ }, {} as DateTimeFormatParts);
+ if (format.includes('YY')) {
+ if (format.includes('YYYY')) {
+ format = format.replace('YYYY', parts.year);
+ } else {
+ format = format.replace('YY', parts.year);
+ }
+ }
+
+ if (format.includes('DD')) {
+ format = format.replace('DD', parts.day);
+ }
+
+ if (format.includes('HH')) {
+ format = format.replace('HH', parts.hour);
+ }
+
+ if (format.includes('h')) {
+ format = format.replace('h', parts.hour);
+ }
+
+ if (format.includes('mm')) {
+ format = format.replace('mm', parts.minute);
+ }
+
+ if (format.includes('ss')) {
+ format = format.replace('ss', parts.second);
+ }
+
+ if (format.includes('A')) {
+ if (parts.dayperiod) {
+ // Workaround for chrome 70 and below
+ format = format.replace('A', parts.dayperiod.toUpperCase());
+ } else {
+ format = format.replace('A', parts.dayPeriod.toUpperCase());
+ }
+ }
+ if (format.includes('MM')) {
+ if (format.includes('MMM')) {
+ format = format.replace('MMM', parts.month);
+ } else {
+ format = format.replace('MM', parts.month);
+ }
+ }
+ return format;
+}
+
+export function utcOffsetString() {
+ const now = new Date();
+ const tzo = -now.getTimezoneOffset();
+ const pad = (num: number) => {
+ const norm = Math.floor(Math.abs(num));
+ return (norm < 10 ? '0' : '') + norm.toString();
+ };
+ return ` UTC${tzo >= 0 ? '+' : '-'}${pad(tzo / 60)}:${pad(tzo % 60)}`;
+}
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
index 7b22cc6..a003c65 100644
--- a/polygerrit-ui/app/utils/date-util_test.js
+++ b/polygerrit-ui/app/utils/date-util_test.js
@@ -39,15 +39,18 @@
const fakeNow = new Date('May 08 2020 12:00:00');
sinon.useFakeTimers(fakeNow.getTime());
assert.equal('just now', fromNow(new Date('May 08 2020 11:59:30')));
- assert.equal('a minute ago', fromNow(new Date('May 08 2020 11:59:00')));
+ assert.equal('1 minute ago', fromNow(new Date('May 08 2020 11:59:00')));
assert.equal('5 minutes ago', fromNow(new Date('May 08 2020 11:55:00')));
- assert.equal('an hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+ assert.equal('1 hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+ assert.equal(
+ '1 hour 5 min ago', fromNow(new Date('May 08 2020 10:55:00')));
assert.equal('3 hours ago', fromNow(new Date('May 08 2020 9:00:00')));
- assert.equal('a day ago', fromNow(new Date('May 07 2020 12:00:00')));
+ assert.equal('1 day ago', fromNow(new Date('May 07 2020 12:00:00')));
+ assert.equal('1 day 2 hr ago', fromNow(new Date('May 07 2020 10:00:00')));
assert.equal('3 days ago', fromNow(new Date('May 05 2020 12:00:00')));
- assert.equal('a month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
+ assert.equal('1 month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
assert.equal('2 months ago', fromNow(new Date('Mar 05 2020 12:00:00')));
- assert.equal('a year ago', fromNow(new Date('May 05 2019 12:00:00')));
+ assert.equal('1 year ago', fromNow(new Date('May 05 2019 12:00:00')));
assert.equal('10 years ago', fromNow(new Date('May 05 2010 12:00:00')));
});
});
@@ -118,4 +121,4 @@
formatDate(new Date('Jul 03 2013 00:15:00'), timeFormat));
});
});
-});
\ No newline at end of file
+});
diff --git a/polygerrit-ui/app/utils/display-name-util.js b/polygerrit-ui/app/utils/display-name-util.js
deleted file mode 100644
index 9868932..0000000
--- a/polygerrit-ui/app/utils/display-name-util.js
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 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.
- */
-const ANONYMOUS_NAME = 'Anonymous';
-
-export function getUserName(config, account) {
- if (account && account.name) {
- return account.name;
- } else if (account && account.username) {
- return account.username;
- } else if (account && account.email) {
- return account.email;
- } else if (config && config.user &&
- config.user.anonymous_coward_name !== 'Anonymous Coward') {
- return config.user.anonymous_coward_name;
- }
-
- return ANONYMOUS_NAME;
-}
-
-export function getDisplayName(config, account) {
- if (account && account.display_name) {
- return account.display_name;
- }
- if (!account || !account.name || !config || !config.accounts) {
- return getUserName(config, account);
- }
- if (config.accounts.default_display_name === 'USERNAME'
- && account.username) {
- return account.username;
- }
- if (config.accounts.default_display_name === 'FIRST_NAME') {
- return account.name.trim().split(' ')[0];
- }
- // Treat every other value as FULL_NAME.
- return account.name;
-}
-
-export function getAccountDisplayName(config, account) {
- const reviewerName = getUserName(config, account);
- const reviewerEmail = _accountEmail(account.email);
- const reviewerStatus = account.status ? '(' + account.status + ')' : '';
- return [reviewerName, reviewerEmail, reviewerStatus]
- .filter(p => p.length > 0).join(' ');
-}
-
-function _accountEmail(email) {
- if (typeof email !== 'undefined') {
- return '<' + email + '>';
- }
- return '';
-}
-
-export const _testOnly_accountEmail = _accountEmail;
-
-export function getGroupDisplayName(group) {
- return group.name + ' (group)';
-}
diff --git a/polygerrit-ui/app/utils/display-name-util.ts b/polygerrit-ui/app/utils/display-name-util.ts
new file mode 100644
index 0000000..7114f98
--- /dev/null
+++ b/polygerrit-ui/app/utils/display-name-util.ts
@@ -0,0 +1,88 @@
+/**
+ * @license
+ * Copyright (C) 2019 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 {AccountInfo, GroupInfo, ServerInfo} from '../types/common';
+import {DefaultDisplayNameConfig} from '../constants/constants';
+
+const ANONYMOUS_NAME = 'Anonymous';
+
+export function getUserName(
+ config?: ServerInfo,
+ account?: AccountInfo
+): string {
+ if (account?.name) {
+ return account.name;
+ } else if (account?.username) {
+ return account.username;
+ } else if (account?.email) {
+ return account.email;
+ } else if (
+ config &&
+ config.user &&
+ config.user.anonymous_coward_name !== 'Anonymous Coward'
+ ) {
+ return config.user.anonymous_coward_name;
+ }
+
+ return ANONYMOUS_NAME;
+}
+
+export function getDisplayName(
+ config?: ServerInfo,
+ account?: AccountInfo,
+ firstNameOnly = false
+): string {
+ if (account?.display_name) {
+ return account.display_name;
+ }
+ if (!account || !account.name) {
+ return getUserName(config, account);
+ }
+ const configDefault = config?.accounts?.default_display_name;
+ if (firstNameOnly || configDefault === DefaultDisplayNameConfig.FIRST_NAME) {
+ return account.name.trim().split(' ')[0];
+ }
+ if (configDefault === DefaultDisplayNameConfig.USERNAME && account.username) {
+ return account.username;
+ }
+ // Treat every other value as FULL_NAME.
+ return account.name;
+}
+
+export function getAccountDisplayName(
+ config: ServerInfo | undefined,
+ account: AccountInfo
+) {
+ const reviewerName = getUserName(config, account);
+ const reviewerEmail = _accountEmail(account.email);
+ const reviewerStatus = account.status ? '(' + account.status + ')' : '';
+ return [reviewerName, reviewerEmail, reviewerStatus]
+ .filter(p => p.length > 0)
+ .join(' ');
+}
+
+function _accountEmail(email?: string) {
+ if (typeof email !== 'undefined') {
+ return '<' + email + '>';
+ }
+ return '';
+}
+
+export const _testOnly_accountEmail = _accountEmail;
+
+export function getGroupDisplayName(group: GroupInfo) {
+ return `${group.name || ''} (group)`;
+}
diff --git a/polygerrit-ui/app/utils/display-name-util_test.js b/polygerrit-ui/app/utils/display-name-util_test.js
index 68dc2e5..9bb68dc 100644
--- a/polygerrit-ui/app/utils/display-name-util_test.js
+++ b/polygerrit-ui/app/utils/display-name-util_test.js
@@ -57,6 +57,13 @@
'user-name');
});
+ test('getDisplayName firstNameOnly', () => {
+ const account = {
+ name: 'firstname lastname',
+ };
+ assert.equal(getDisplayName(config, account, true), 'firstname');
+ });
+
test('getDisplayName prefer first name default', () => {
const account = {
name: 'firstname lastname',
diff --git a/polygerrit-ui/app/utils/dom-util.js b/polygerrit-ui/app/utils/dom-util.js
deleted file mode 100644
index 16d9e00..0000000
--- a/polygerrit-ui/app/utils/dom-util.js
+++ /dev/null
@@ -1,202 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-function getPathFromNode(el) {
- if (!el.tagName || el.tagName === 'GR-APP'
- || el instanceof DocumentFragment
- || el instanceof HTMLSlotElement) {
- return '';
- }
- let path = el.tagName.toLowerCase();
- if (el.id) path += `#${el.id}`;
- if (el.className) path += `.${el.className.replace(/ /g, '.')}`;
- return path;
-}
-
-/**
- * Get computed style value.
- *
- * If ShadyCSS is provided, use ShadyCSS api.
- * If `getComputedStyleValue` is provided on the element, use it.
- * Otherwise fallback to native method (in polymer 2).
- *
- */
-export function getComputedStyleValue(name, el) {
- let style;
- if (window.ShadyCSS) {
- style = ShadyCSS.getComputedStyleValue(el, name);
- } else if (el.getComputedStyleValue) {
- style = el.getComputedStyleValue(name);
- } else {
- style = getComputedStyle(el).getPropertyValue(name);
- }
- return style;
-}
-
-/**
- * Query selector on a dom element.
- *
- * This is shadow DOM compatible, but only works when selector is within
- * one shadow host, won't work if your selector is crossing
- * multiple shadow hosts.
- *
- */
-export function querySelector(el, selector) {
- let nodes = [el];
- let result = null;
- while (nodes.length) {
- const node = nodes.pop();
-
- // Skip if it's an invalid node.
- if (!node || !node.querySelector) continue;
-
- // Try find it with native querySelector directly
- result = node.querySelector(selector);
-
- if (result) {
- break;
- }
-
- // Add all nodes with shadowRoot and loop through
- const allShadowNodes = [...node.querySelectorAll('*')]
- .filter(child => !!child.shadowRoot)
- .map(child => child.shadowRoot);
- nodes = nodes.concat(allShadowNodes);
-
- // Add shadowRoot of current node if has one
- // as its not included in node.querySelectorAll('*')
- if (node.shadowRoot) {
- nodes.push(node.shadowRoot);
- }
- }
- return result;
-}
-
-/**
- * Query selector all dom elements matching with certain selector.
- *
- * This is shadow DOM compatible, but only works when selector is within
- * one shadow host, won't work if your selector is crossing
- * multiple shadow hosts.
- *
- * Note: this can be very expensive, only use when have to.
- */
-export function querySelectorAll(el, selector) {
- let nodes = [el];
- const results = new Set();
- while (nodes.length) {
- const node = nodes.pop();
-
- if (!node || !node.querySelectorAll) continue;
-
- // Try find all from regular children
- [...node.querySelectorAll(selector)]
- .forEach(el => results.add(el));
-
- // Add all nodes with shadowRoot and loop through
- const allShadowNodes = [...node.querySelectorAll('*')]
- .filter(child => !!child.shadowRoot)
- .map(child => child.shadowRoot);
- nodes = nodes.concat(allShadowNodes);
-
- // Add shadowRoot of current node if has one
- // as its not included in node.querySelectorAll('*')
- if (node.shadowRoot) {
- nodes.push(node.shadowRoot);
- }
- }
- return [...results];
-}
-
-/**
- * Retrieves the dom path of the current event.
- *
- * If the event object contains a `path` property, then use it,
- * otherwise, construct the dom path based on the event target.
- *
- * @param {!Event} e
- * @return {string}
- * @example
- *
- * domNode.onclick = e => {
- * getEventPath(e); // eg: div.class1>p#pid.class2
- * }
- */
-export function getEventPath(e) {
- if (!e) return '';
-
- let path = e.path;
- if (!path || !path.length) {
- path = [];
- let el = e.target;
- while (el) {
- path.push(el);
- el = el.parentNode || el.host;
- }
- }
-
- return path.reduce((domPath, curEl) => {
- const pathForEl = getPathFromNode(curEl);
- if (!pathForEl) return domPath;
- return domPath ? `${pathForEl}>${domPath}` : pathForEl;
- }, '');
-}
-
-/**
- * Are any ancestors of the element (or the element itself) members of the
- * given class.
- *
- * @param {!Element} element
- * @param {string} className
- * @param {Element=} opt_stopElement If provided, stop traversing the
- * ancestry when the stop element is reached. The stop element's class
- * is not checked.
- * @return {boolean}
- */
-export function descendedFromClass(element, className, opt_stopElement) {
- let isDescendant = element.classList.contains(className);
- while (!isDescendant && element.parentElement &&
- (!opt_stopElement || element.parentElement !== opt_stopElement)) {
- isDescendant = element.classList.contains(className);
- element = element.parentElement;
- }
- return isDescendant;
-}
-
-/**
- * Convert any string into a valid class name.
- *
- * For class names, naming rules:
- * Must begin with a letter A-Z or a-z
- * Can be followed by: letters (A-Za-z), digits (0-9), hyphens ("-"), and underscores ("_")
- *
- * @param {string} str
- * @param {string} prefix
- */
-export function strToClassName(str = '', prefix = 'generated_') {
- return `${prefix}${str.replace(/[^a-zA-Z0-9-_]/g, '_')}`;
-}
-
-// shared API element
-let _sharedApiEl;
-
-export function getSharedApiEl() {
- if (!_sharedApiEl) {
- _sharedApiEl = document.createElement('gr-js-api-interface');
- }
- return _sharedApiEl;
-}
\ No newline at end of file
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
new file mode 100644
index 0000000..364112b
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -0,0 +1,236 @@
+/**
+ * @license
+ * 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.
+ */
+
+import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
+
+/**
+ * Event emitted from polymer elements.
+ */
+export interface PolymerEvent extends EventApi, Event {}
+
+interface ElementWithShadowRoot extends Element {
+ shadowRoot: ShadowRoot;
+}
+
+/**
+ * Type guard for element with a shadowRoot.
+ */
+function isElementWithShadowRoot(
+ el: Element | ShadowRoot
+): el is ElementWithShadowRoot {
+ return 'shadowRoot' in el;
+}
+
+// TODO: maybe should have a better name for this
+function getPathFromNode(el: EventTarget) {
+ let tagName = '';
+ let id = '';
+ let className = '';
+ if (el instanceof Element) {
+ tagName = el.tagName;
+ id = el.id;
+ className = el.className;
+ }
+ if (
+ !tagName ||
+ 'GR-APP' === tagName ||
+ el instanceof DocumentFragment ||
+ el instanceof HTMLSlotElement
+ ) {
+ return '';
+ }
+ let path = '';
+ if (tagName) {
+ path += tagName.toLowerCase();
+ }
+ if (id) {
+ path += `#${id}`;
+ }
+ if (className) {
+ path += `.${className.replace(/ /g, '.')}`;
+ }
+ return path;
+}
+
+/**
+ * Get computed style value.
+ */
+export function getComputedStyleValue(name: string, el: Element) {
+ return getComputedStyle(el).getPropertyValue(name).trim();
+}
+
+/**
+ * Query selector on a dom element.
+ *
+ * This is shadow DOM compatible, but only works when selector is within
+ * one shadow host, won't work if your selector is crossing
+ * multiple shadow hosts.
+ *
+ */
+export function querySelector(
+ el: Element | ShadowRoot,
+ selector: string
+): Element | null {
+ let nodes = [el];
+ let result = null;
+ while (nodes.length) {
+ const node = nodes.pop();
+
+ // Skip if it's an invalid node.
+ if (!node || !node.querySelector) continue;
+
+ // Try find it with native querySelector directly
+ result = node.querySelector(selector);
+
+ if (result) {
+ break;
+ }
+
+ // Add all nodes with shadowRoot and loop through
+ const allShadowNodes = [...node.querySelectorAll('*')]
+ .filter(isElementWithShadowRoot)
+ .map(child => child.shadowRoot);
+ nodes = nodes.concat(allShadowNodes);
+
+ // Add shadowRoot of current node if has one
+ // as its not included in node.querySelectorAll('*')
+ if (isElementWithShadowRoot(node)) {
+ nodes.push(node.shadowRoot);
+ }
+ }
+ return result;
+}
+
+/**
+ * Query selector all dom elements matching with certain selector.
+ *
+ * This is shadow DOM compatible, but only works when selector is within
+ * one shadow host, won't work if your selector is crossing
+ * multiple shadow hosts.
+ *
+ * Note: this can be very expensive, only use when have to.
+ */
+export function querySelectorAll(
+ el: Element | ShadowRoot,
+ selector: string
+): Element[] {
+ let nodes = [el];
+ const results = new Set<Element>();
+ while (nodes.length) {
+ const node = nodes.pop();
+
+ if (!node || !node.querySelectorAll) continue;
+
+ // Try find all from regular children
+ [...node.querySelectorAll(selector)].forEach(el => results.add(el));
+
+ // Add all nodes with shadowRoot and loop through
+ const allShadowNodes = [...node.querySelectorAll('*')]
+ .filter(isElementWithShadowRoot)
+ .map(child => child.shadowRoot);
+ nodes = nodes.concat(allShadowNodes);
+
+ // Add shadowRoot of current node if has one
+ // as its not included in node.querySelectorAll('*')
+ if (isElementWithShadowRoot(node)) {
+ nodes.push(node.shadowRoot);
+ }
+ }
+ return [...results];
+}
+
+/**
+ * Retrieves the dom path of the current event.
+ *
+ * If the event object contains a `path` property, then use it,
+ * otherwise, construct the dom path based on the event target.
+ *
+ * domNode.onclick = e => {
+ * getEventPath(e); // eg: div.class1>p#pid.class2
+ * }
+ */
+export function getEventPath<T extends PolymerEvent>(e?: T) {
+ if (!e) return '';
+
+ let path = e.path;
+ if (!path || !path.length) {
+ path = [];
+ let el = e.target;
+ while (el) {
+ path.push(el);
+ el = (el as Node).parentNode || (el as ShadowRoot).host;
+ }
+ }
+
+ return path.reduce<string>((domPath: string, curEl: EventTarget) => {
+ const pathForEl = getPathFromNode(curEl);
+ if (!pathForEl) return domPath;
+ return domPath ? `${pathForEl}>${domPath}` : pathForEl;
+ }, '');
+}
+
+/**
+ * Are any ancestors of the element (or the element itself) members of the
+ * given class.
+ *
+ */
+export function descendedFromClass(
+ element: Element,
+ className: string,
+ stopElement?: Element
+) {
+ let isDescendant = element.classList.contains(className);
+ while (
+ !isDescendant &&
+ element.parentElement &&
+ (!stopElement || element.parentElement !== stopElement)
+ ) {
+ isDescendant = element.classList.contains(className);
+ element = element.parentElement;
+ }
+ return isDescendant;
+}
+
+/**
+ * Convert any string into a valid class name.
+ *
+ * For class names, naming rules:
+ * Must begin with a letter A-Z or a-z
+ * Can be followed by: letters (A-Za-z), digits (0-9), hyphens ("-"), and underscores ("_")
+ */
+export function strToClassName(str = '', prefix = 'generated_') {
+ return `${prefix}${str.replace(/[^a-zA-Z0-9-_]/g, '_')}`;
+}
+
+// shared API element
+// TODO: Make this a proper service singleton. Move into AppContext?
+let _sharedApiEl: JsApiService;
+
+/**
+ * Retrieves the shared API element.
+ * We want to keep a single instance of API element instead of
+ * creating multiple elements.
+ */
+export function getSharedApiEl(): JsApiService {
+ if (!_sharedApiEl) {
+ _sharedApiEl = (document.createElement(
+ 'gr-js-api-interface'
+ ) as unknown) as JsApiService;
+ }
+ return _sharedApiEl;
+}
diff --git a/polygerrit-ui/app/utils/dom-util_test.js b/polygerrit-ui/app/utils/dom-util_test.js
index 4306cd2..e2d61ed 100644
--- a/polygerrit-ui/app/utils/dom-util_test.js
+++ b/polygerrit-ui/app/utils/dom-util_test.js
@@ -60,29 +60,33 @@
test('event with fake path', () => {
assert.equal(getEventPath({path: []}), '');
- assert.equal(getEventPath({path: [
- {tagName: 'dd'},
- ]}), 'dd');
+ const dd = document.createElement('dd');
+ assert.equal(getEventPath({path: [dd]}), 'dd');
});
test('event with fake complicated path', () => {
- assert.equal(getEventPath({path: [
- {tagName: 'dd', id: 'test', className: 'a b'},
- {tagName: 'DIV', id: 'test2', className: 'a b c'},
- ]}), 'div#test2.a.b.c>dd#test.a.b');
+ const dd = document.createElement('dd');
+ dd.setAttribute('id', 'test');
+ dd.className = 'a b';
+ const divNode = document.createElement('DIV');
+ divNode.id = 'test2';
+ divNode.className = 'a b c';
+ assert.equal(getEventPath(
+ {path: [dd, divNode]}),
+ 'div#test2.a.b.c>dd#test.a.b'
+ );
});
test('event with fake target', () => {
- const fakeTargetParent2 = {
- tagName: 'DIV', id: 'test2', className: 'a b c',
- };
- const fakeTargetParent1 = {
- parentNode: fakeTargetParent2,
- tagName: 'dd',
- id: 'test',
- className: 'a b',
- };
- const fakeTarget = {tagName: 'SPAN', parentNode: fakeTargetParent1};
+ const fakeTargetParent1 = document.createElement('dd');
+ fakeTargetParent1.setAttribute('id', 'test');
+ fakeTargetParent1.className = 'a b';
+ const fakeTargetParent2 = document.createElement('DIV');
+ fakeTargetParent2.id = 'test2';
+ fakeTargetParent2.className = 'a b c';
+ fakeTargetParent2.appendChild(fakeTargetParent1);
+ const fakeTarget = document.createElement('SPAN');
+ fakeTargetParent1.appendChild(fakeTarget);
assert.equal(
getEventPath({target: fakeTarget}),
'div#test2.a.b.c>dd#test.a.b>span'
diff --git a/polygerrit-ui/app/utils/inner-html-util.ts b/polygerrit-ui/app/utils/inner-html-util.ts
new file mode 100644
index 0000000..549f493
--- /dev/null
+++ b/polygerrit-ui/app/utils/inner-html-util.ts
@@ -0,0 +1,42 @@
+/**
+ * @license
+ * 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.
+ */
+
+// This file adds some simple checks to match internal google rules.
+// Internally in google it has different implementation
+
+import {BrandType} from '../types/common';
+
+export type SafeHtml = BrandType<string, '_safeHtml'>;
+export type SafeStyleSheet = BrandType<string, '_safeHtml'>;
+
+export function setInnerHtml(el: HTMLElement, innerHTML: SafeHtml) {
+ el.innerHTML = innerHTML;
+}
+
+export function createStyle(styleSheet: SafeStyleSheet): SafeHtml {
+ return `<style>${styleSheet}</style>` as SafeHtml;
+}
+
+export function safeStyleSheet(
+ templateObj: TemplateStringsArray
+): SafeStyleSheet {
+ const styleSheet = templateObj[0];
+ if (/[<>]/.test(styleSheet)) {
+ throw new Error('Forbidden characters in styleSheet string: ' + styleSheet);
+ }
+ return styleSheet as SafeStyleSheet;
+}
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
new file mode 100644
index 0000000..4313745
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -0,0 +1,44 @@
+/**
+ * @license
+ * 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.
+ */
+import {
+ ApprovalInfo,
+ isDetailedLabelInfo,
+ LabelInfo,
+ VotingRangeInfo,
+} from '../types/common';
+
+// Name of the standard Code-Review label.
+export const CODE_REVIEW = 'Code-Review';
+
+export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined {
+ if (!label || !isDetailedLabelInfo(label)) return undefined;
+ const values = Object.keys(label.values).map(v => Number(v));
+ values.sort((a, b) => a - b);
+ if (!values.length) return undefined;
+ return {min: values[0], max: values[values.length - 1]};
+}
+
+export function getVotingRangeOrDefault(label?: LabelInfo): VotingRangeInfo {
+ const range = getVotingRange(label);
+ return range ? range : {min: 0, max: 0};
+}
+
+export function getMaxAccounts(label?: LabelInfo): ApprovalInfo[] {
+ if (!label || !isDetailedLabelInfo(label) || !label.all) return [];
+ const votingRange = getVotingRangeOrDefault(label);
+ return label.all.filter(account => account.value === votingRange.max);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
new file mode 100644
index 0000000..d6f7b3e
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util_test.js
@@ -0,0 +1,90 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../test/common-test-setup-karma.js';
+import {
+ getVotingRange,
+ getVotingRangeOrDefault,
+ getMaxAccounts,
+} from './label-util.js';
+
+const VALUES_1 = {
+ '-1': 'bad',
+ '0': 'neutral',
+ '+1': 'good',
+};
+
+const VALUES_2 = {
+ '-1': 'bad',
+ '+2': 'perfect',
+ '0': 'neutral',
+ '-2': 'blocking',
+ '+1': 'good',
+};
+
+suite('label-util', () => {
+ test('getVotingRange -1 to +1', () => {
+ const label = {values: VALUES_1};
+ const expectedRange = {min: -1, max: 1};
+ assert.deepEqual(getVotingRange(label), expectedRange);
+ assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+ });
+
+ test('getVotingRange -2 to +2', () => {
+ const label = {values: VALUES_2};
+ const expectedRange = {min: -2, max: 2};
+ assert.deepEqual(getVotingRange(label), expectedRange);
+ assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+ });
+
+ test('getVotingRange empty values', () => {
+ const label = {
+ values: {},
+ };
+ const expectedRange = {min: 0, max: 0};
+ assert.isUndefined(getVotingRange(label));
+ assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+ });
+
+ test('getVotingRange no values', () => {
+ const label = {};
+ const expectedRange = {min: 0, max: 0};
+ assert.isUndefined(getVotingRange(label));
+ assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+ });
+
+ test('getMaxAccounts', () => {
+ const label = {
+ values: VALUES_2,
+ all: [
+ {value: 2, _account_id: 314},
+ {value: 1, _account_id: 777},
+ ],
+ };
+
+ const maxAccounts = getMaxAccounts(label);
+
+ assert.equal(maxAccounts.length, 1);
+ assert.equal(maxAccounts[0]._account_id, 314);
+ });
+
+ test('getMaxAccounts unset parameters', () => {
+ assert.isEmpty(getMaxAccounts());
+ assert.isEmpty(getMaxAccounts({}));
+ assert.isEmpty(getMaxAccounts({values: VALUES_2}));
+ });
+});
diff --git a/polygerrit-ui/app/utils/page-wrapper-utils.ts b/polygerrit-ui/app/utils/page-wrapper-utils.ts
new file mode 100644
index 0000000..19cfe48
--- /dev/null
+++ b/polygerrit-ui/app/utils/page-wrapper-utils.ts
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * 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.
+ */
+import 'page/page';
+
+// Reexport page.js. To make it work, karma, server.go and rollup patch
+// page.js and replace "this" to "window". Otherwise, it can't assign global
+// property. We can't import page.mjs because typescript doesn't support mjs
+// extensions
+export interface Page {
+ (pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
+ (pageCallback: PageCallback): void;
+ show(url: string): void;
+ redirect(url: string): void;
+ base(url: string): void;
+ start(): void;
+ exit(pattern: string | RegExp, ...pageCallback: PageCallback[]): void;
+}
+
+// See https://visionmedia.github.io/page.js/ for details
+export interface PageContext {
+ save(): void;
+ handled: boolean;
+ canonicalPath: string;
+ path: string;
+ querystring: string;
+ pathname: string;
+ state: unknown;
+ title: string;
+ hash: string;
+ params: {[paramIndex: string]: string};
+}
+
+export type PageNextCallback = () => void;
+
+export type PageCallback = (
+ context: PageContext,
+ next: PageNextCallback
+) => void;
+
+export const page = window['page'] as Page;
diff --git a/polygerrit-ui/app/utils/patch-set-util.js b/polygerrit-ui/app/utils/patch-set-util.js
deleted file mode 100644
index 562b8ee..0000000
--- a/polygerrit-ui/app/utils/patch-set-util.js
+++ /dev/null
@@ -1,265 +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.
- */
-
-// Tags identifying ChangeMessages that move change into WIP state.
-const WIP_TAGS = [
- 'autogenerated:gerrit:newWipPatchSet',
- 'autogenerated:gerrit:setWorkInProgress',
-];
-
-// Tags identifying ChangeMessages that move change out of WIP state.
-const READY_TAGS = [
- 'autogenerated:gerrit:setReadyForReview',
-];
-
-export const SPECIAL_PATCH_SET_NUM = {
- EDIT: 'edit',
- PARENT: 'PARENT',
-};
-
-/**
- * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
- * this function checks for patchNum equality.
- *
- * @param {string|number} a
- * @param {string|number|undefined} b Undefined sometimes because
- * computeLatestPatchNum can return undefined.
- * @return {boolean}
- */
-export function patchNumEquals(a, b) {
- return a + '' === b + '';
-}
-
-/**
- * Whether the given patch is a numbered parent of a merge (i.e. a negative
- * number).
- *
- * @param {string|number} n
- * @return {boolean}
- */
-export function isMergeParent(n) {
- return (n + '')[0] === '-';
-}
-
-/**
- * Given an object of revisions, get a particular revision based on patch
- * num.
- *
- * @param {Object} revisions The object of revisions given by the API
- * @param {number|string} patchNum The number index of the revision
- * @return {Object} The correspondent revision obj from {revisions}
- */
-export function getRevisionByPatchNum(revisions, patchNum) {
- for (const rev of Object.values(revisions || {})) {
- if (patchNumEquals(rev._number, patchNum)) {
- return rev;
- }
- }
-}
-
-/**
- * Find change edit base revision if change edit exists.
- *
- * @param {!Array<!Object>} revisions The revisions array.
- * @return {Object} change edit parent revision or null if change edit
- * doesn't exist.
- */
-export function findEditParentRevision(revisions) {
- const editInfo =
- revisions.find(info => info._number === SPECIAL_PATCH_SET_NUM.EDIT);
-
- if (!editInfo) { return null; }
-
- return revisions.find(info => info._number === editInfo.basePatchNum) ||
- null;
-}
-
-/**
- * Find change edit base patch set number if change edit exists.
- *
- * @param {!Array<!Object>} revisions The revisions array.
- * @return {number} Change edit patch set number or -1.
- */
-export function findEditParentPatchNum(revisions) {
- const revisionInfo = findEditParentRevision(revisions);
- return revisionInfo ? revisionInfo._number : -1;
-}
-
-/**
- * Sort given revisions array according to the patch set number, in
- * descending order.
- * The sort algorithm is change edit aware. Change edit has patch set number
- * equals 'edit', but must appear after the patch set it was based on.
- * Example: change edit is based on patch set 2, and another patch set was
- * uploaded after change edit creation, the sorted order should be:
- * 3, edit, 2, 1.
- *
- * @param {!Array<!Object>} revisions The revisions array
- * @return {!Array<!Object>} The sorted {revisions} array
- */
-export function sortRevisions(revisions) {
- const editParent = findEditParentPatchNum(revisions);
- // Map a normal patchNum to 2 * (patchNum - 1) + 1... I.e. 1 -> 1,
- // 2 -> 3, 3 -> 5, etc.
- // Map an edit to the patchNum of parent*2... I.e. edit on 2 -> 4.
- const num = r => (r._number === SPECIAL_PATCH_SET_NUM.EDIT ?
- 2 * editParent :
- 2 * (r._number - 1) + 1);
- return revisions.sort((a, b) => num(b) - num(a));
-}
-
-/**
- * Construct a chronological list of patch sets derived from change details.
- * Each element of this list is an object with the following properties:
- *
- * * num {number} The number identifying the patch set
- * * desc {!string} Optional patch set description
- * * wip {boolean} If true, this patch set was never subject to review.
- * * sha {string} hash of the commit
- *
- * The wip property is determined by the change's current work_in_progress
- * property and its log of change messages.
- *
- * @param {!Object} change The change details
- * @return {!Array<!Object>} Sorted list of patch set objects, as described
- * above
- */
-export function computeAllPatchSets(change) {
- if (!change) { return []; }
- let patchNums = [];
- if (change.revisions && Object.keys(change.revisions).length) {
- const revisions = Object.keys(change.revisions)
- .map(sha => Object.assign({sha}, change.revisions[sha]));
- patchNums = sortRevisions(revisions)
- .map(e => {
- // TODO(kaspern): Mark which patchset an edit was made on, if an
- // edit exists -- perhaps with a temporary description.
- return {
- num: e._number,
- desc: e.description,
- sha: e.sha,
- };
- });
- }
- return _computeWipForPatchSets(change, patchNums);
-}
-
-/**
- * Populate the wip properties of the given list of patch sets.
- *
- * @param {!Object} change The change details
- * @param {!Array<!Object>} patchNums Sorted list of patch set objects, as
- * generated by computeAllPatchSets
- * @return {!Array<!Object>} The given list of patch set objects, with the
- * wip property set on each of them
- */
-function _computeWipForPatchSets(change, patchNums) {
- if (!change.messages || !change.messages.length) {
- return patchNums;
- }
- const psWip = {};
- let wip = change.work_in_progress;
- for (let i = 0; i < change.messages.length; i++) {
- const msg = change.messages[i];
- if (WIP_TAGS.includes(msg.tag)) {
- wip = true;
- } else if (READY_TAGS.includes(msg.tag)) {
- wip = false;
- }
- if (psWip[msg._revision_number] !== false) {
- psWip[msg._revision_number] = wip;
- }
- }
-
- for (let i = 0; i < patchNums.length; i++) {
- patchNums[i].wip = psWip[patchNums[i].num];
- }
- return patchNums;
-}
-
-export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
-
-/** @return {number|undefined} */
-export function computeLatestPatchNum(allPatchSets) {
- if (!allPatchSets || !allPatchSets.length) { return undefined; }
- if (allPatchSets[0].num === SPECIAL_PATCH_SET_NUM.EDIT) {
- return allPatchSets[1].num;
- }
- return allPatchSets[0].num;
-}
-
-/** @return {boolean} */
-export function hasEditBasedOnCurrentPatchSet(allPatchSets) {
- if (!allPatchSets || allPatchSets.length < 2) { return false; }
- return allPatchSets[0].num === SPECIAL_PATCH_SET_NUM.EDIT;
-}
-
-/** @return {boolean} */
-export function hasEditPatchsetLoaded(patchRangeRecord) {
- const patchRange = patchRangeRecord.base;
- if (!patchRange) { return false; }
- return patchRange.patchNum === SPECIAL_PATCH_SET_NUM.EDIT ||
- patchRange.basePatchNum === SPECIAL_PATCH_SET_NUM.EDIT;
-}
-
-/**
- * Check whether there is no newer patch than the latest patch that was
- * available when this change was loaded.
- *
- * @return {Promise<!Object>} A promise that yields true if the latest patch
- * has been loaded, and false if a newer patch has been uploaded in the
- * meantime. The promise is rejected on network error.
- */
-export function fetchChangeUpdates(change, restAPI) {
- const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
- return restAPI.getChangeDetail(change._number)
- .then(detail => {
- if (!detail) {
- const error = new Error('Unable to check for latest patchset.');
- return Promise.reject(error);
- }
- const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
- return {
- isLatest: actualLatest <= knownLatest,
- newStatus: change.status !== detail.status ? detail.status : null,
- newMessages: change.messages.length < detail.messages.length,
- };
- });
-}
-
-/**
- * @param {number|string} patchNum
- * @param {!Array<!Object>} revisions A sorted array of revisions.
- *
- * @return {number} The index of the revision with the given patchNum.
- */
-export function findSortedIndex(patchNum, revisions) {
- revisions = revisions || [];
- const findNum = rev => rev._number + '' === patchNum + '';
- return revisions.findIndex(findNum);
-}
-
-/**
- * Convert parent indexes from patch range expressions to numbers.
- * For example, in a patch range expression `"-3"` becomes `3`.
- *
- * @param {number|string} rangeBase
- * @return {number}
- */
-export function getParentIndex(rangeBase) {
- return -parseInt(rangeBase + '', 10);
-}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
new file mode 100644
index 0000000..8974af8
--- /dev/null
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -0,0 +1,346 @@
+import {
+ RevisionInfo,
+ ChangeInfo,
+ PatchSetNum,
+ EditPatchSetNum,
+ BrandType,
+ ParentPatchSetNum,
+} from '../types/common';
+import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
+import {
+ EditRevisionInfo,
+ ParsedChangeInfo,
+} from '../elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser';
+
+/**
+ * @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.
+ */
+
+// Tags identifying ChangeMessages that move change into WIP state.
+const WIP_TAGS = [
+ 'autogenerated:gerrit:newWipPatchSet',
+ 'autogenerated:gerrit:setWorkInProgress',
+];
+
+// Tags identifying ChangeMessages that move change out of WIP state.
+const READY_TAGS = ['autogenerated:gerrit:setReadyForReview'];
+
+// TODO(TS): Replace usages of these constants by
+// EditPatchSetNum and ParentPatchSetNum in common.ts.
+export const SPECIAL_PATCH_SET_NUM = {
+ EDIT: 'edit',
+ PARENT: 'PARENT',
+};
+
+export interface PatchSet {
+ num: PatchSetNum;
+ desc: string | undefined;
+ sha: string;
+ wip?: boolean;
+}
+
+interface PatchRange {
+ patchNum?: PatchSetNum;
+ basePatchNum?: PatchSetNum;
+}
+
+/**
+ * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
+ * this function checks for patchNum equality.
+ *
+ */
+export function patchNumEquals(a?: PatchSetNum, b?: PatchSetNum) {
+ if (a === undefined) {
+ return a === b;
+ }
+ // TODO(TS): replace with a===b when the whole code is converted to ts
+ return `${a}` === `${b}`;
+}
+
+/**
+ * Whether the given patch is a numbered parent of a merge (i.e. a negative
+ * number).
+ */
+export function isMergeParent(n: PatchSetNum) {
+ return `${n}`[0] === '-';
+}
+
+export function isPatchSetNum(patchset: string) {
+ if (!isNaN(Number(patchset))) return true;
+ return patchset === EditPatchSetNum || patchset === ParentPatchSetNum;
+}
+
+export function convertToPatchSetNum(
+ patchset: string | undefined
+): PatchSetNum | undefined {
+ if (patchset === undefined) return patchset;
+ if (!isPatchSetNum(patchset)) {
+ console.error('string is not of type PatchSetNum');
+ }
+ const value = Number(patchset);
+ if (!isNaN(value)) return value as PatchSetNum;
+ return patchset as PatchSetNum;
+}
+
+export function isNumber(
+ psn: PatchSetNum
+): psn is BrandType<number, '_patchSet'> {
+ return typeof psn === 'number';
+}
+
+/**
+ * Given an object of revisions, get a particular revision based on patch
+ * num.
+ *
+ * @return The correspondent revision obj from {revisions}
+ */
+export function getRevisionByPatchNum(
+ revisions: RevisionInfo[],
+ patchNum: PatchSetNum
+) {
+ for (const rev of revisions) {
+ if (patchNumEquals(rev._number, patchNum)) {
+ return rev;
+ }
+ }
+ console.warn('no revision found');
+ return;
+}
+
+/**
+ * Find change edit base revision if change edit exists.
+ *
+ * @return change edit parent revision or null if change edit
+ * doesn't exist.
+ *
+ */
+export function findEditParentRevision(
+ revisions: Array<RevisionInfo | EditRevisionInfo>
+) {
+ const editInfo = revisions.find(info => info._number === EditPatchSetNum);
+
+ if (!editInfo) {
+ return null;
+ }
+
+ return revisions.find(info => info._number === editInfo.basePatchNum) || null;
+}
+
+/**
+ * Find change edit base patch set number if change edit exists.
+ *
+ * @return Change edit patch set number or -1.
+ *
+ */
+export function findEditParentPatchNum(
+ revisions: Array<RevisionInfo | EditRevisionInfo>
+) {
+ const revisionInfo = findEditParentRevision(revisions);
+ // finding parent of 'edit' patchset, hence revisionInfo._number cannot be
+ // 'edit' and must be a number
+ // TODO(TS): find a way to avoid 'as'
+ return revisionInfo ? (revisionInfo._number as number) : -1;
+}
+
+/**
+ * Sort given revisions array according to the patch set number, in
+ * descending order.
+ * The sort algorithm is change edit aware. Change edit has patch set number
+ * equals 'edit', but must appear after the patch set it was based on.
+ * Example: change edit is based on patch set 2, and another patch set was
+ * uploaded after change edit creation, the sorted order should be:
+ * 3, edit, 2, 1.
+ *
+ */
+export function sortRevisions<T extends RevisionInfo | EditRevisionInfo>(
+ revisions: T[]
+): T[] {
+ const editParent: number = findEditParentPatchNum(revisions);
+ // Map a normal patchNum to 2 * (patchNum - 1) + 1... I.e. 1 -> 1,
+ // 2 -> 3, 3 -> 5, etc.
+ // Map an edit to the patchNum of parent*2... I.e. edit on 2 -> 4.
+ // TODO(TS): find a way to avoid 'as'
+ const num = (r: T) =>
+ r._number === EditPatchSetNum
+ ? 2 * editParent
+ : 2 * ((r._number as number) - 1) + 1;
+ return revisions.sort((a, b) => num(b) - num(a));
+}
+
+/**
+ * Construct a chronological list of patch sets derived from change details.
+ * Each element of this list is an object with the following properties:
+ *
+ * * num The number identifying the patch set
+ * * desc Optional patch set description
+ * * wip If true, this patch set was never subject to review.
+ * * sha hash of the commit
+ *
+ * The wip property is determined by the change's current work_in_progress
+ * property and its log of change messages.
+ *
+ * @return Sorted list of patch set objects, as described
+ * above
+ */
+export function computeAllPatchSets(
+ change: ChangeInfo | ParsedChangeInfo
+): PatchSet[] {
+ if (!change) {
+ return [];
+ }
+
+ let patchNums: PatchSet[] = [];
+ if (change.revisions && Object.keys(change.revisions).length) {
+ const changeRevisions = change.revisions;
+ const revisions = Object.keys(change.revisions).map(sha => {
+ return {sha, ...changeRevisions[sha]};
+ });
+ patchNums = sortRevisions(revisions).map(e => {
+ // TODO(kaspern): Mark which patchset an edit was made on, if an
+ // edit exists -- perhaps with a temporary description.
+ return {
+ num: e._number,
+ desc: e.description,
+ sha: e.sha,
+ };
+ });
+ }
+ return _computeWipForPatchSets(change, patchNums);
+}
+
+/**
+ * Populate the wip properties of the given list of patch sets.
+ *
+ * @param change The change details
+ * @param patchNums Sorted list of patch set objects, as
+ * generated by computeAllPatchSets
+ * @return The given list of patch set objects, with the
+ * wip property set on each of them
+ */
+function _computeWipForPatchSets(
+ change: ChangeInfo | ParsedChangeInfo,
+ patchNums: PatchSet[]
+) {
+ if (!change.messages || !change.messages.length) {
+ return patchNums;
+ }
+ // TODO(TS): replace with Map<PatchNum, boolean>
+ const psWip: Map<string, boolean> = new Map();
+ let wip = !!change.work_in_progress;
+ for (let i = 0; i < change.messages.length; i++) {
+ const msg = change.messages[i];
+ if (msg.tag && WIP_TAGS.includes(msg.tag)) {
+ wip = true;
+ } else if (msg.tag && READY_TAGS.includes(msg.tag)) {
+ wip = false;
+ }
+ if (
+ msg._revision_number &&
+ psWip.get(`${msg._revision_number}`) !== false
+ ) {
+ psWip.set(`${msg._revision_number}`, wip);
+ }
+ }
+
+ for (let i = 0; i < patchNums.length; i++) {
+ patchNums[i].wip = psWip.get(`${patchNums[i].num}`);
+ }
+ return patchNums;
+}
+
+export const _testOnly_computeWipForPatchSets = _computeWipForPatchSets;
+
+export function computeLatestPatchNum(
+ allPatchSets?: PatchSet[]
+): PatchSetNum | undefined {
+ if (!allPatchSets || !allPatchSets.length) {
+ return undefined;
+ }
+ if (allPatchSets[0].num === EditPatchSetNum) {
+ return allPatchSets[1].num;
+ }
+ return allPatchSets[0].num;
+}
+
+export function hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
+ if (!allPatchSets || allPatchSets.length < 2) {
+ return false;
+ }
+ return allPatchSets[0].num === EditPatchSetNum;
+}
+
+export function hasEditPatchsetLoaded(patchRange: PatchRange) {
+ return (
+ patchRange.patchNum === EditPatchSetNum ||
+ patchRange.basePatchNum === EditPatchSetNum
+ );
+}
+
+/**
+ * Check whether there is no newer patch than the latest patch that was
+ * available when this change was loaded.
+ *
+ * @return A promise that yields true if the latest patch
+ * has been loaded, and false if a newer patch has been uploaded in the
+ * meantime. The promise is rejected on network error.
+ */
+export function fetchChangeUpdates(
+ change: ChangeInfo | ParsedChangeInfo,
+ restAPI: RestApiService
+) {
+ const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+ return restAPI.getChangeDetail(change._number).then(detail => {
+ if (!detail) {
+ const error = new Error('Change detail not found.');
+ return Promise.reject(error);
+ }
+ const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+ if (!actualLatest || !knownLatest) {
+ const error = new Error('Unable to check for latest patchset.');
+ return Promise.reject(error);
+ }
+ return {
+ isLatest: actualLatest <= knownLatest,
+ newStatus: change.status !== detail.status ? detail.status : null,
+ newMessages:
+ (change.messages || []).length < (detail.messages || []).length,
+ };
+ });
+}
+
+/**
+ * @param revisions A sorted array of revisions.
+ *
+ * @return the index of the revision with the given patchNum.
+ */
+export function findSortedIndex(
+ patchNum: PatchSetNum,
+ revisions: RevisionInfo[]
+) {
+ revisions = revisions || [];
+ const findNum = (rev: RevisionInfo) => `${rev._number}` === `${patchNum}`;
+ return revisions.findIndex(findNum);
+}
+
+/**
+ * Convert parent indexes from patch range expressions to numbers.
+ * For example, in a patch range expression `"-3"` becomes `3`.
+ *
+ */
+
+export function getParentIndex(rangeBase: PatchSetNum) {
+ return -Number(`${rangeBase}`);
+}
diff --git a/polygerrit-ui/app/utils/path-list-util.js b/polygerrit-ui/app/utils/path-list-util.js
deleted file mode 100644
index e408359..0000000
--- a/polygerrit-ui/app/utils/path-list-util.js
+++ /dev/null
@@ -1,120 +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 {SpecialFilePath} from '../constants/constants.js';
-
-/**
- * @param {string} a
- * @param {string} b
- * @return {number}
- */
-export function specialFilePathCompare(a, b) {
- // The commit message always goes first.
- if (a === SpecialFilePath.COMMIT_MESSAGE) {
- return -1;
- }
- if (b === SpecialFilePath.COMMIT_MESSAGE) {
- return 1;
- }
-
- // The merge list always comes next.
- if (a === SpecialFilePath.MERGE_LIST) {
- return -1;
- }
- if (b === SpecialFilePath.MERGE_LIST) {
- return 1;
- }
-
- const aLastDotIndex = a.lastIndexOf('.');
- const aExt = a.substr(aLastDotIndex + 1);
- const aFile = a.substr(0, aLastDotIndex) || a;
-
- const bLastDotIndex = b.lastIndexOf('.');
- const bExt = b.substr(bLastDotIndex + 1);
- const bFile = b.substr(0, bLastDotIndex) || b;
-
- // Sort header files above others with the same base name.
- const headerExts = ['h', 'hxx', 'hpp'];
- if (aFile.length > 0 && aFile === bFile) {
- if (headerExts.includes(aExt) && headerExts.includes(bExt)) {
- return a.localeCompare(b);
- }
- if (headerExts.includes(aExt)) {
- return -1;
- }
- if (headerExts.includes(bExt)) {
- return 1;
- }
- }
- return aFile.localeCompare(bFile) || a.localeCompare(b);
-}
-
-export function shouldHideFile(file) {
- return file === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-}
-
-export function addUnmodifiedFiles(files, commentedPaths) {
- Object.keys(commentedPaths).forEach(commentedPath => {
- if (files.hasOwnProperty(commentedPath) ||
- shouldHideFile(commentedPath)) { return; }
- files[commentedPath] = {status: 'U'};
- });
-}
-
-export function computeDisplayPath(path) {
- if (path === SpecialFilePath.COMMIT_MESSAGE) {
- return 'Commit message';
- } else if (path === SpecialFilePath.MERGE_LIST) {
- return 'Merge list';
- }
- return path;
-}
-
-export function isMagicPath(path) {
- return !!path &&
- (path === SpecialFilePath.COMMIT_MESSAGE || path ===
- SpecialFilePath.MERGE_LIST);
-}
-
-export function computeTruncatedPath(path) {
- return truncatePath(
- computeDisplayPath(path));
-}
-
-/**
- * Truncates URLs to display filename only
- * Example
- * // returns '.../text.html'
- * util.truncatePath.('dir/text.html');
- * Example
- * // returns 'text.html'
- * util.truncatePath.('text.html');
- *
- * @param {string} path
- * @param {number=} opt_threshold
- * @return {string} Returns the truncated value of a URL.
- */
-export function truncatePath(path, opt_threshold) {
- const threshold = opt_threshold || 1;
- const pathPieces = path.split('/');
-
- if (pathPieces.length <= threshold) { return path; }
-
- const index = pathPieces.length - threshold;
- // Character is an ellipsis.
- return `\u2026/${pathPieces.slice(index).join('/')}`;
-}
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
new file mode 100644
index 0000000..dda6031
--- /dev/null
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -0,0 +1,125 @@
+/**
+ * @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 {SpecialFilePath, FileInfoStatus} from '../constants/constants';
+import {FileInfo} from '../types/common';
+import {hasOwnProperty} from './common-util';
+
+export function specialFilePathCompare(a: string, b: string) {
+ // The commit message always goes first.
+ if (a === SpecialFilePath.COMMIT_MESSAGE) {
+ return -1;
+ }
+ if (b === SpecialFilePath.COMMIT_MESSAGE) {
+ return 1;
+ }
+
+ // The merge list always comes next.
+ if (a === SpecialFilePath.MERGE_LIST) {
+ return -1;
+ }
+ if (b === SpecialFilePath.MERGE_LIST) {
+ return 1;
+ }
+
+ const aLastDotIndex = a.lastIndexOf('.');
+ const aExt = a.substr(aLastDotIndex + 1);
+ const aFile = a.substr(0, aLastDotIndex) || a;
+
+ const bLastDotIndex = b.lastIndexOf('.');
+ const bExt = b.substr(bLastDotIndex + 1);
+ const bFile = b.substr(0, bLastDotIndex) || b;
+
+ // Sort header files above others with the same base name.
+ const headerExts = ['h', 'hxx', 'hpp'];
+ if (aFile.length > 0 && aFile === bFile) {
+ if (headerExts.includes(aExt) && headerExts.includes(bExt)) {
+ return a.localeCompare(b);
+ }
+ if (headerExts.includes(aExt)) {
+ return -1;
+ }
+ if (headerExts.includes(bExt)) {
+ return 1;
+ }
+ }
+ return aFile.localeCompare(bFile) || a.localeCompare(b);
+}
+
+export function shouldHideFile(file: string) {
+ return file === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
+}
+
+export function addUnmodifiedFiles(
+ files: {[filename: string]: FileInfo},
+ commentedPaths: {[fileName: string]: boolean}
+) {
+ if (!commentedPaths) return;
+ Object.keys(commentedPaths).forEach(commentedPath => {
+ if (hasOwnProperty(files, commentedPath) || shouldHideFile(commentedPath)) {
+ return;
+ }
+ // TODO(TS): either change FileInfo to mark delta and size optional
+ // or fill in 0 here
+ files[commentedPath] = {
+ status: FileInfoStatus.UNMODIFIED,
+ } as FileInfo;
+ });
+}
+
+export function computeDisplayPath(path: string) {
+ if (path === SpecialFilePath.COMMIT_MESSAGE) {
+ return 'Commit message';
+ } else if (path === SpecialFilePath.MERGE_LIST) {
+ return 'Merge list';
+ }
+ return path;
+}
+
+export function isMagicPath(path?: string) {
+ return (
+ !!path &&
+ (path === SpecialFilePath.COMMIT_MESSAGE ||
+ path === SpecialFilePath.MERGE_LIST)
+ );
+}
+
+export function computeTruncatedPath(path: string) {
+ return truncatePath(computeDisplayPath(path));
+}
+
+/**
+ * Truncates URLs to display filename only
+ * Example
+ * // returns '.../text.html'
+ * util.truncatePath.('dir/text.html');
+ * Example
+ * // returns 'text.html'
+ * util.truncatePath.('text.html');
+ *
+ */
+export function truncatePath(path: string, threshold = 1) {
+ const pathPieces = path.split('/');
+
+ if (pathPieces.length <= threshold) {
+ return path;
+ }
+
+ const index = pathPieces.length - threshold;
+ // Character is an ellipsis.
+ return `\u2026/${pathPieces.slice(index).join('/')}`;
+}
diff --git a/polygerrit-ui/app/utils/safe-types-util.js b/polygerrit-ui/app/utils/safe-types-util.js
deleted file mode 100644
index c4181db..0000000
--- a/polygerrit-ui/app/utils/safe-types-util.js
+++ /dev/null
@@ -1,71 +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.
- */
-
-const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|[^:/?#]*(?:[/?#]|$))/i;
-
-/**
- * Wraps a string to be used as a URL. An error is thrown if the string cannot
- * be considered safe.
- *
- * @constructor
- * @param {string} url the unwrapped, potentially unsafe URL.
- */
-class SafeUrl {
- constructor(url) {
- if (!SAFE_URL_PATTERN.test(url)) {
- throw new Error(`URL not marked as safe: ${url}`);
- }
- this._url = url;
- }
-
- toString() {
- return this._url;
- }
-}
-
-export const _testOnly_SafeUrl = SafeUrl;
-
-/**
- * Get the string representation of the safe URL.
- *
- * @returns {string}
- */
-export function safeTypesBridge(value, type) {
- // If the value is being bound to a URL, ensure the value is wrapped in the
- // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
- // to surface the error.
- if (type === 'URL') {
- let safeValue = null;
- if (value instanceof SafeUrl) {
- safeValue = value;
- } else if (typeof value === 'string') {
- safeValue = new SafeUrl(value);
- }
- if (safeValue) {
- return safeValue.toString();
- }
- }
-
- // If the value is being bound to a string or a constant, then the string
- // can be used as is.
- if (type === 'STRING' || type === 'CONSTANT') {
- return value;
- }
-
- // Otherwise fail.
- throw new Error(`Refused to bind value as ${type}: ${value}`);
-}
diff --git a/polygerrit-ui/app/utils/safe-types-util.ts b/polygerrit-ui/app/utils/safe-types-util.ts
new file mode 100644
index 0000000..18641de
--- /dev/null
+++ b/polygerrit-ui/app/utils/safe-types-util.ts
@@ -0,0 +1,68 @@
+/**
+ * @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.
+ */
+
+const SAFE_URL_PATTERN = /^(https?:\/\/|mailto:|[^:/?#]*(?:[/?#]|$))/i;
+
+/**
+ * Wraps a string to be used as a URL. An error is thrown if the string cannot
+ * be considered safe.
+ */
+class SafeUrl {
+ private readonly _url: string;
+
+ constructor(url: string) {
+ if (!SAFE_URL_PATTERN.test(url)) {
+ throw new Error(`URL not marked as safe: ${url}`);
+ }
+ this._url = url;
+ }
+
+ toString() {
+ return this._url;
+ }
+}
+
+export const _testOnly_SafeUrl = SafeUrl;
+
+/**
+ * Get the string representation of the safe URL.
+ */
+export function safeTypesBridge(value: unknown, type: string): unknown {
+ // If the value is being bound to a URL, ensure the value is wrapped in the
+ // SafeUrl type first. If the URL is not safe, allow the SafeUrl constructor
+ // to surface the error.
+ if (type === 'URL') {
+ let safeValue = null;
+ if (value instanceof SafeUrl) {
+ safeValue = value;
+ } else if (typeof value === 'string') {
+ safeValue = new SafeUrl(value);
+ }
+ if (safeValue) {
+ return safeValue.toString();
+ }
+ }
+
+ // If the value is being bound to a string or a constant, then the string
+ // can be used as is.
+ if (type === 'STRING' || type === 'CONSTANT') {
+ return value;
+ }
+
+ // Otherwise fail.
+ throw new Error(`Refused to bind value as ${type}: ${value}`);
+}
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 15ab75b..0c6fabc 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -1,3 +1,6 @@
+import {ServerInfo} from '../types/common';
+import {RestApiService} from '../services/services/gr-rest-api/gr-rest-api';
+
/**
* @license
* Copyright (C) 2020 The Android Open Source Project
@@ -17,24 +20,6 @@
const PROBE_PATH = '/Documentation/index.html';
const DOCS_BASE_PATH = '/Documentation';
-// NOTE: Below we define 2 types (DocUrlBehaviorConfig and RestApi) to avoid
-// type 'any'. These are temporary definitions and they must be
-// updated/moved/removed when we start converting our codebase to typescript.
-// Right now we are using these types here just for adding typescript support to
-// our build/test infrastructure. Doing so we avoid massive code updates at this
-// stage.
-
-// TODO: introduce global gerrit config type instead of DocUrlBehaviorConfig.
-// The DocUrlBehaviorConfig is a temporary type
-interface DocUrlBehaviorConfig {
- gerrit?: {doc_url?: string};
-}
-
-// TODO: implement RestApi type correctly and remove interface from this file
-interface RestApi {
- probePath(url: string): Promise<boolean>;
-}
-
export function getBaseUrl(): string {
return window.CANONICAL_PATH || '';
}
@@ -44,18 +29,15 @@
/**
* Get the docs base URL from either the server config or by probing.
*
- * @param {Object} config The server config.
- * @param {!Object} restApi A REST API instance
- * @return {!Promise<string>} A promise that resolves with the docs base
- * URL.
+ * @return A promise that resolves with the docs base URL.
*/
export function getDocsBaseUrl(
- config: DocUrlBehaviorConfig,
- restApi: RestApi
+ config: ServerInfo | undefined,
+ restApi: RestApiService
): Promise<string | null> {
if (!getDocsBaseUrlCachedPromise) {
getDocsBaseUrlCachedPromise = new Promise(resolve => {
- if (config && config.gerrit && config.gerrit.doc_url) {
+ if (config?.gerrit?.doc_url) {
resolve(config.gerrit.doc_url);
} else {
restApi.probePath(getBaseUrl() + PROBE_PATH).then(ok => {
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 8fb3eea..e5f0380 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,6 +2,13 @@
# yarn lockfile v1
+"@polymer/decorators@^3.0.0":
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/@polymer/decorators/-/decorators-3.0.0.tgz#e4212ac976d9abd1210f560b6e1be4165c1c0183"
+ integrity sha512-qh+VID9nDV9q3ABvIfWgm7/+udl7v2HKsMLPXFm8tj1fI7qr7yWJMFwS3xWBkMmuNPtmkS8MDP0vqLAQIEOWzg==
+ dependencies:
+ "@polymer/polymer" "^3.0.5"
+
"@polymer/font-roboto-local@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@polymer/font-roboto-local/-/font-roboto-local-3.0.2.tgz#563cd6cabbcaef54999d654c0f3d476bcc49ce58"
@@ -12,10 +19,10 @@
resolved "https://registry.yarnpkg.com/@polymer/font-roboto/-/font-roboto-3.0.2.tgz#80cdaa7225db2359130dfb2c6d9a3be1820020c3"
integrity sha512-tx5TauYSmzsIvmSqepUPDYbs4/Ejz2XbZ1IkD7JEGqkdNUJlh+9KU85G56Tfdk/xjEZ8zorFfN09OSwiMrIQWA==
-"@polymer/iron-a11y-announcer@^3.0.0-pre.26":
- version "3.0.2"
- resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-announcer/-/iron-a11y-announcer-3.0.2.tgz#730dd36ccb2e042ecd5160ba439c2bf2f8a97412"
- integrity sha512-LqnMF39mXyxSSRbTHRzGbcJS8nU0NVTo2raBOgOlpxw5yfGJUVcwaTJ/qy5NtWCZLRfa4suycf0oAkuUjHTXHQ==
+"@polymer/iron-a11y-announcer@^3.0.0-pre.26", "@polymer/iron-a11y-announcer@^3.0.1":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-announcer/-/iron-a11y-announcer-3.1.0.tgz#3d3712a165070ed3cdfc39e54f95515c913c9613"
+ integrity sha512-lc5i4NKB8kSQHH0Hwu8WS3ym93m+J69OHJWSSBxwd17FI+h2wmgxDzeG9LI4ojMMck17/uc2pLe7g/UHt5/K/A==
dependencies:
"@polymer/polymer" "^3.0.0"
@@ -26,10 +33,10 @@
dependencies:
"@polymer/polymer" "^3.0.0"
-"@polymer/iron-autogrow-textarea@^3.0.0-pre.26", "@polymer/iron-autogrow-textarea@^3.0.1":
- version "3.0.1"
- resolved "https://registry.yarnpkg.com/@polymer/iron-autogrow-textarea/-/iron-autogrow-textarea-3.0.1.tgz#0205d9c5ca16f3afd505f41e9037989707d59dce"
- integrity sha512-FgSL7APrOSL9Vu812sBCFlQ17hvnJsBAV2C2e1UAiaHhB+dyfLq8gGdGUpqVWuGJ50q4Y/49QwCNnLf85AdVYA==
+"@polymer/iron-autogrow-textarea@^3.0.0-pre.26", "@polymer/iron-autogrow-textarea@^3.0.3":
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/@polymer/iron-autogrow-textarea/-/iron-autogrow-textarea-3.0.3.tgz#b75dbebc23ce47d428a26156709d4a8a4c05823e"
+ integrity sha512-5r0VkWrIlm0JIp5E5wlnvkw7slK72lFRZXncmrsLZF+6n1dg2rI8jt7xpFzSmUWrqpcyXwyKaGaDvUjl3j4JLA==
dependencies:
"@polymer/iron-behaviors" "^3.0.0-pre.26"
"@polymer/iron-flex-layout" "^3.0.0-pre.26"
@@ -63,7 +70,7 @@
"@polymer/neon-animation" "^3.0.0-pre.26"
"@polymer/polymer" "^3.0.0"
-"@polymer/iron-fit-behavior@^3.0.0-pre.26", "@polymer/iron-fit-behavior@^3.0.1":
+"@polymer/iron-fit-behavior@^3.0.0-pre.26", "@polymer/iron-fit-behavior@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@polymer/iron-fit-behavior/-/iron-fit-behavior-3.0.2.tgz#2ec460d8a6b0151394b55631a72a68b92e14e2e0"
integrity sha512-JndryJYbBR3gSN5IlST4rCHsd01+OyvYpRO6z5Zd3C6u5V/m07TwAtcf3aXwZ8WBNt2eLG28OcvdSO7XR2v2pg==
@@ -127,7 +134,7 @@
dependencies:
"@polymer/polymer" "^3.0.0"
-"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.2":
+"@polymer/iron-overlay-behavior@^3.0.0-pre.27", "@polymer/iron-overlay-behavior@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@polymer/iron-overlay-behavior/-/iron-overlay-behavior-3.0.3.tgz#29c198e19e05bb2bcf7d86d3c11848cb93301d00"
integrity sha512-Q/Fp0+uOQQ145ebZ7T8Cxl4m1tUKYjyymkjcL2rXUm+aDQGb1wA1M1LYxUF5YBqd+9lipE0PTIiYwA2ZL/sznA==
@@ -227,10 +234,10 @@
"@polymer/paper-styles" "^3.0.0-pre.26"
"@polymer/polymer" "^3.0.0"
-"@polymer/paper-input@^3.0.2":
- version "3.2.0"
- resolved "https://registry.yarnpkg.com/@polymer/paper-input/-/paper-input-3.2.0.tgz#a07dbc1b009bac97a5a86eccb57d99b17bd96285"
- integrity sha512-vYEBxq6LDR+QGDrAO/il0JNhCd+31TwSnv58MVV+ijaGKz1qAuSJw4NSsgF3lrXCwomqnpME19vbp2ktrcluVA==
+"@polymer/paper-input@^3.2.1":
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/@polymer/paper-input/-/paper-input-3.2.1.tgz#0fd0d30de3b43ba7d2c8d5d76f870d257b667ebf"
+ integrity sha512-6ghgwQKM6mS0hAQxQqj+tkeEY1VUBqAsrasAm8V5RpNcfSWQC/hhRFxU0beGuKTAhndzezDzWYP6Zz4b8fExGg==
dependencies:
"@polymer/iron-a11y-keys-behavior" "^3.0.0-pre.26"
"@polymer/iron-autogrow-textarea" "^3.0.0-pre.26"
@@ -303,7 +310,14 @@
"@polymer/paper-styles" "^3.0.0-pre.26"
"@polymer/polymer" "^3.0.0"
-"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.2", "@polymer/polymer@^3.3.0":
+"@polymer/polymer@^3.0.0", "@polymer/polymer@^3.0.5", "@polymer/polymer@^3.4.1":
+ version "3.4.1"
+ resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
+ integrity sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==
+ dependencies:
+ "@webcomponents/shadycss" "^1.9.1"
+
+"@polymer/polymer@^3.0.2":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.3.1.tgz#9ad48992d2a96775f80b0673f3a615d6df8a3dfc"
integrity sha512-8KaB48tzyMjdsHdxo5KvCAaqmTe7rYDzQAoj/pyEfq9Fp4YfUaS+/xqwYj0GbiDAUNzwkmEQ7dw9cgnRNdKO8A==
@@ -333,6 +347,18 @@
resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
+lit-element@^2.4.0:
+ version "2.4.0"
+ resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-2.4.0.tgz#b22607a037a8fc08f5a80736dddf7f3f5d401452"
+ integrity sha512-pBGLglxyhq/Prk2H91nA0KByq/hx/wssJBQFiYqXhGDvEnY31PRGYf1RglVzyLeRysu0IHm2K0P196uLLWmwFg==
+ dependencies:
+ lit-html "^1.1.1"
+
+lit-html@^1.1.1:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-1.3.0.tgz#c80f3cc5793a6dea6c07172be90a70ab20e56034"
+ integrity sha512-0Q1bwmaFH9O14vycPHw8C/IeHMk/uSDldVLIefu/kfbTBGIc44KGH6A8p1bDfxUfHdc8q6Ct7kQklWoHgr4t1Q==
+
page@^1.11.5:
version "1.11.5"
resolved "https://registry.yarnpkg.com/page/-/page-1.11.5.tgz#0cfc8608be337f26f4377f31df0787aef0ca1af7"
@@ -357,3 +383,15 @@
dependencies:
"@polymer/polymer" "^3.0.2"
"@webcomponents/webcomponentsjs" "^2.0.3"
+
+rxjs@^6.6.2:
+ version "6.6.2"
+ resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2"
+ integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==
+ dependencies:
+ tslib "^1.9.0"
+
+tslib@^1.9.0:
+ version "1.13.0"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+ integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
diff --git a/polygerrit-ui/grep-patch-karma.js b/polygerrit-ui/grep-patch-karma.js
new file mode 100644
index 0000000..adf5171
--- /dev/null
+++ b/polygerrit-ui/grep-patch-karma.js
@@ -0,0 +1,47 @@
+/**
+ * @license
+ * 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.
+ */
+
+// The IntelliJ (and probably other IDEs) passes test names as a regexp in
+// the format:
+// --grep=/some regexp.../
+// But mochajs doesn't expect the '/' characters before and after the regexp.
+// The code below patches input args and removes '/' if they exists.
+function installPatch(karma) {
+ const originalKarmaStart = karma.start;
+
+ karma.start = function(config, ...args) {
+ const regexpGrepPrefix = '--grep=/';
+ const regexpGrepSuffix = '/';
+ if (config && config.args) {
+ for (let i = 0; i < config.args.length; i++) {
+ const arg = config.args[i];
+ if (arg.startsWith(regexpGrepPrefix) && arg.endsWith(regexpGrepSuffix)) {
+ const regexpText = arg.slice(regexpGrepPrefix.length, -regexpGrepPrefix.length);
+ config.args[i] = '--grep=' + regexpText;
+ }
+ }
+ }
+ originalKarmaStart.apply(this, [config, ...args]);
+ }
+
+}
+
+const karma = window.__karma__;
+if (karma && karma.start && !karma.__grep_patch_installed__) {
+ karma.__grep_patch_installed__ = true;
+ installPatch(karma);
+}
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index 8d302ef..fe3fa0c 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -43,6 +43,20 @@
}
}
+function runInIde() {
+ // A simple detection of IDE.
+ // Default browserNoActivityTimeout is 30 seconds. An IDE usually
+ // runs karma in background and send commands when a user wants to
+ // execute test. If interval between user executed tests is bigger than
+ // browserNoActivityTimeout, the IDE reports error and doesn't restart
+ // server.
+ // We want to increase browserNoActivityTimeout when tests run in IDE.
+ // Wd don't want to increase it in other cases, oterhise hanging tests
+ // can slow down CI.
+ return !runUnderBazel &&
+ process.argv.some(arg => arg.toLowerCase().contains('intellij'));
+}
+
module.exports = function(config) {
const localDirName = path.resolve(__dirname, '../.ts-out/polygerrit-ui/app');
const rootDir = runUnderBazel ?
@@ -58,7 +72,10 @@
const testFilesPattern = (typeof config.testFiles == 'string') ?
testFilesLocationPattern + config.testFiles :
testFilesLocationPattern + '*_test.js';
+ // Special patch for grep parameters (see details in the grep-patch-karam.js)
+ const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
config.set({
+ browserNoActivityTimeout: runInIde ? 60 * 60 * 1000 : 30 * 1000,
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: '../',
plugins: [
@@ -76,12 +93,21 @@
// list of files / patterns to load in the browser
files: [
+ ...additionalFiles,
+ getUiDevNpmFilePath('source-map-support/browser-source-map-support.js'),
getUiDevNpmFilePath('accessibility-developer-tools/dist/js/axs_testing.js'),
getUiDevNpmFilePath('sinon/pkg/sinon.js'),
{ pattern: testFilesPattern, type: 'module' },
],
esm: {
- nodeResolve: true,
+ nodeResolve: {
+ // By default, it tries to use page.mjs file instead of page.js
+ // when importing 'page/page', so we shouldn't use .mjs extension
+ // in node resolve.
+ // The .ts extension is required to display source code in browser
+ // (otherwise esm plugin crashes)
+ extensions: ['.js', '.ts'],
+ },
moduleDirs: getModulesDir(),
// Bazel and yarn uses symlinks for files.
// preserveSymlinks is necessary for correct modules paths resolving
@@ -91,12 +117,55 @@
// breaks tests in some browser versions
// (for example, Chrome 69 on gerrit-ci).
compatibility: 'none',
+ plugins: [
+ {
+ resolveImport(importSpecifier) {
+ // esm-dev-server interprets .ts files as .js files and
+ // tries to replace all module imports with relative/absolute
+ // paths. In most cases this works correctly. However if
+ // a ts file imports type from .d.ts and there is no
+ // associated .js file then the esm-dev-server responds with
+ // 500 error.
+ // For example the following import .ts file causes problem
+ // import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+ // To avoid problems, we don't resolve imports in .ts files
+ // and instead always return original path
+ if (importSpecifier.context.originalUrl.endsWith(".ts")) {
+ return importSpecifier.source;
+ }
+ return undefined;
+ }
+ },
+ {
+ transform(context) {
+ if (context.path.endsWith('/node_modules/page/page.js')) {
+ const orignalBody = context.body;
+ // Can't import page.js directly, because this is undefined.
+ // Replace it with window
+ // The same replace exists in server.go
+ // Rollup makes this replacement automatically
+ const transformedBody = orignalBody.replace(
+ '}(this, (function () { \'use strict\';',
+ '}(window, (function () { \'use strict\';'
+ );
+ if(orignalBody.length === transformedBody.length) {
+ console.error('The page.js was updated. Please update transform accordingly');
+ process.exit(1);
+ }
+ return {body: transformedBody};
+ }
+ },
+ }
+ ]
},
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['mocha'],
+ mochaReporter: {
+ showDiff: true
+ },
// web server port
port: 9876,
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 7de55aa..c01ef56 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -2,7 +2,12 @@
"name": "polygerrit-ui-dev-dependencies",
"description": "Gerrit Code Review - Polygerrit dev dependencies",
"browser": true,
- "dependencies": {},
+ "dependencies": {
+ "@types/chai": "^4.2.14",
+ "@types/lodash": "^4.14.162",
+ "@types/mocha": "^8.0.3",
+ "@types/sinon": "^9.0.8"
+ },
"devDependencies": {
"@open-wc/karma-esm": "^2.16.16",
"@polymer/iron-test-helpers": "^3.0.1",
@@ -15,7 +20,8 @@
"karma-mocha-reporter": "^2.2.5",
"lodash": "^4.17.15",
"mocha": "7.2.0",
- "sinon": "^9.0.2"
+ "sinon": "^9.0.2",
+ "source-map-support": "^0.5.19"
},
"license": "Apache-2.0",
"private": true
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index b2eb3dc..eaf2017 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -167,18 +167,55 @@
// with the import error, so we can catch this problem easily.
writer.Header().Set("Content-Type", "text/html")
} else if isJsFile {
- // The following code updates import statements.
- // 1. Keep all imports started with '.' character unchanged (i.e. all relative
- // imports like import ... from './a.js' or import ... from '../b/c/d.js'
- // 2. For other imports it adds '/node_modules/' prefix. Additionally,
- // if an in imported file has .js or .mjs extension, the code keeps
- // the file extension unchanged. Otherwise, it adds .js extension.
- // Examples:
- // '@polymer/polymer.js' -> '/node_modules/@polymer/polymer.js'
- // 'page/page.mjs' -> '/node_modules/page.mjs'
- // '@polymer/iron-icon' -> '/node_modules/@polymer/iron-icon.js'
- moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'([^/.].*?)(\\.(m?)js)?';$")
- data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '/node_modules/$2.${4}js';"))
+ // import ... from '@polymer/decorators'
+ // must be transformed into
+ // import ... from '@polymer/decorators/lib/decorators.js'
+ // The correct way to do it is to use value of the "main" property
+ // from the @polymer/decorators/package.json. However, parsing package.json
+ // is overcomplicated right now, hard-code exact path here.
+ moduleImportRegexp := regexp.MustCompile("(?m)^(import.*)'@polymer/decorators';$")
+ data = moduleImportRegexp.ReplaceAll(data, []byte("$1 '@polymer/decorators/lib/decorators.js';"))
+
+ // The following code updates import statements.
+ // 1. if an in imported file has .js or .mjs extension, the code keeps
+ // the file extension unchanged. Otherwise, it adds .js extension
+ // 2. For module imports it adds '/node_modules/' prefix.
+ // Examples:
+ // '@polymer/polymer.js' -> '/node_modules/@polymer/polymer.js'
+ // 'page/page.mjs' -> '/node_modules/page.mjs'
+ // '@polymer/iron-icon' -> '/node_modules/@polymer/iron-icon.js'
+ // './element/file' -> './element/file.js'
+ moduleImportRegexp = regexp.MustCompile(`(?m)^(import.*|export.* from )['"](.*?)(\.(m?)js)?['"];$`)
+ data = moduleImportRegexp.ReplaceAll(data, []byte("$1'$2.${4}js';"))
+
+ moduleImportRegexp = regexp.MustCompile(`(?m)^(import.*|export.* from )['"]([^/.].*)['"];$`)
+ data = moduleImportRegexp.ReplaceAll(data, []byte("$1'/node_modules/$2';"))
+
+ // The es module version of rxjs can be found in the _esm2015/ directory.
+ moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/rxjs)(.*).js(';)$")
+ data = moduleImportRegexp.ReplaceAll(data, []byte("$1/_esm2015$3/index.js$4"))
+
+ // The es module version of tslib.js can be found in tslib.es6.js.
+ moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)tslib.js';$")
+ data = moduleImportRegexp.ReplaceAll(data, []byte("${1}tslib/tslib.es6.js';"))
+
+ // 'lit-element' imports and exports have to be resolved to 'lit-element/lit-element.js'.
+ moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)lit-element.js';$")
+ data = moduleImportRegexp.ReplaceAll(data, []byte("${1}lit-element/lit-element.js';"))
+
+ if strings.HasSuffix(normalizedContentPath, "/node_modules/page/page.js") {
+ // Can't import page.js directly, because this is undefined.
+ // Replace it with window
+ // The same replace exists in karma.conf.js
+ // Rollup makes this replacement automatically
+ pageJsRegexp := regexp.MustCompile(`(?m)^}\(this, \(function \(\) { 'use strict';$`)
+ newData := pageJsRegexp.ReplaceAll(data, []byte("}(window, (function () { 'use strict';"))
+ if len(newData) == len(data) {
+ log.Fatal("The page.js was updated. Please update regexp/replace accordingly")
+ }
+ data = newData
+ }
+
writer.Header().Set("Content-Type", "application/javascript")
} else if strings.HasSuffix(normalizedContentPath, ".css") {
writer.Header().Set("Content-Type", "text/css")
@@ -457,7 +494,7 @@
// Any path prefixes that should resolve to index.html.
var (
- fePaths = []string{"/q/", "/c/", "/p/", "/x/", "/dashboard/", "/admin/", "/settings/"}
+ fePaths = []string{"/q/", "/c/", "/id/", "/p/", "/x/", "/dashboard/", "/admin/", "/settings/"}
issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
)
@@ -491,13 +528,26 @@
// Additionally, the code analyzes messages produced by the typescript compiler
// and allows to wait until compilation is finished.
var (
- tsStartingCompilation = "- Starting compilation in watch mode..."
- tsFileChangeDetectedMsg = "- File change detected. Starting incremental compilation..."
- tsStartWatchingMsg = regexp.MustCompile(`^.* - Found \d+ errors\. Watching for file changes\.$`)
+ tsStartingCompilation = "- Starting compilation in watch mode..."
+ tsFileChangeDetectedMsg = "- File change detected. Starting incremental compilation..."
+ // If there is only one error typescript outputs:
+ // Found 1 error
+ // In all other cases it outputs
+ // Found X errors
+ tsStartWatchingMsg = regexp.MustCompile(`^.* - Found \d+ error(s)?\. Watching for file changes\.$`)
waitForNextChangeInterval = 1 * time.Second
)
+// typescriptLogWriter implements Writer interface and receives output
+// (stdout and stderr) from the typescript compiler. It reads incoming
+// data line-by-line, analyzes each line and updates compilationDoneWaiter
+// according to the current compiler state. Additionally, the
+// typescriptLogWriter passes all incoming lines to the underlying logger.
type typescriptLogWriter struct {
+ // unfinishedLine stores the portion of line which was partially received
+ // (i.e. all text received after the last EOL (\n) mark.
+ unfinishedLine string
+ // logger is used to pass-through all received strings
logger *log.Logger
// when WaitGroup counter is 0 the compilation is complete
compilationDoneWaiter *sync.WaitGroup
@@ -511,25 +561,41 @@
}
func (lw typescriptLogWriter) Write(p []byte) (n int, err error) {
- text := strings.TrimSpace(string(p))
- if strings.HasSuffix(text, tsFileChangeDetectedMsg) ||
- strings.HasSuffix(text, tsStartingCompilation) {
- lw.compilationDoneWaiter.Add(1)
+ // The input p can contain several lines and/or the partial line
+ // Code splits the input by EOL marker (\n) and stores the unfinished line
+ // for the next call to Write.
+ partialText := lw.unfinishedLine + string(p)
+ lines := strings.Split(partialText, "\n")
+ fullLines := lines
+ if strings.HasSuffix(partialText, "\n") {
+ lw.unfinishedLine = ""
+ } else {
+ fullLines = lines[:len(lines)-1]
+ lw.unfinishedLine = lines[len(lines)-1]
}
- if tsStartWatchingMsg.MatchString(text) {
- // A source code can be changed while previous compiler run is in progress.
- // In this case typescript reruns compilation again almost immediately
- // after the previous run finishes. To detect this situation, we are
- // waiting waitForNextChangeInterval before decreasing the counter.
- // If another compiler run is started in this interval, we will wait
- // again until it finishes.
- go func() {
- time.Sleep(waitForNextChangeInterval)
- lw.compilationDoneWaiter.Add(-1)
- }()
-
+ for _, fullLine := range fullLines {
+ text := strings.TrimSpace(fullLine)
+ if text == "" {
+ continue
+ }
+ if strings.HasSuffix(text, tsFileChangeDetectedMsg) ||
+ strings.HasSuffix(text, tsStartingCompilation) {
+ lw.compilationDoneWaiter.Add(1)
+ }
+ if tsStartWatchingMsg.MatchString(text) {
+ // A source code can be changed while previous compiler run is in progress.
+ // In this case typescript reruns compilation again almost immediately
+ // after the previous run finishes. To detect this situation, we are
+ // waiting waitForNextChangeInterval before decreasing the counter.
+ // If another compiler run is started in this interval, we will wait
+ // again until it finishes.
+ go func() {
+ time.Sleep(waitForNextChangeInterval)
+ lw.compilationDoneWaiter.Done()
+ }()
+ }
+ lw.logger.Print(text)
}
- lw.logger.Print(text)
return len(p), nil
}
@@ -549,6 +615,20 @@
compilationCompleteWaiter := &sync.WaitGroup{}
logWriter := newTypescriptLogWriter(compilationCompleteWaiter)
+ // Note 1: (from https://golang.org/pkg/os/exec/#Cmd)
+ // If Stdout and Stderr are the same writer, and have a type that can
+ // be compared with ==, at most one goroutine at a time will call Write.
+ //
+ // Note 2: The typescript compiler reports all compilation errors to
+ // stdout by design (see https://github.com/microsoft/TypeScript/issues/615)
+ // It writes to stderr only when something unexpected happens (like internal
+ // exceptions). To print such errors in the same way as standard typescript
+ // error, the same logWriter is used both for Stdout and Stderr.
+ //
+ // If Stderr arrives in the middle of ordinary typescript output (i.e.
+ // something unexpected happens), the server.go can stop respond to http
+ // requests. However, this is not a problem for us: typescript compiler and
+ // server.go must be restarted anyway.
cmd.Stdout = logWriter
cmd.Stderr = logWriter
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index dfc5a43..2acd478 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -989,6 +989,11 @@
resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.0.tgz#af31cc52062be0ab24583be072fd49b634dcc2fe"
integrity sha512-wT1VfnScjAftZsvLYaefu/UuwYJdYBwD2JDL2OQd01plGmuAoir5V6HnVHgrfh7zEwcasoiyO2wQ+W58sNh2sw==
+"@types/chai@^4.2.14":
+ version "4.2.14"
+ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.14.tgz#44d2dd0b5de6185089375d976b4ec5caf6861193"
+ integrity sha512-G+ITQPXkwTrslfG5L/BksmbLUA0M1iybEsmCWPqzSxsRRhJZimBKJkoMi8fr/CPygPTj4zO5pJH7I2/cm9M7SQ==
+
"@types/command-line-args@^5.0.0":
version "5.0.0"
resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.0.0.tgz#484e704d20dbb8754a8f091eee45cdd22bcff28c"
@@ -1125,6 +1130,11 @@
dependencies:
"@types/koa" "*"
+"@types/lodash@^4.14.162":
+ version "4.14.162"
+ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.162.tgz#65d78c397e0d883f44afbf1f7ba9867022411470"
+ integrity sha512-alvcho1kRUnnD1Gcl4J+hK0eencvzq9rmzvFPRmP5rPHx9VVsJj6bKLTATPVf9ktgv4ujzh7T+XWKp+jhuODig==
+
"@types/lru-cache@^5.1.0":
version "5.1.0"
resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03"
@@ -1140,6 +1150,11 @@
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
+"@types/mocha@^8.0.3":
+ version "8.0.3"
+ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.0.3.tgz#51b21b6acb6d1b923bbdc7725c38f9f455166402"
+ integrity sha512-vyxR57nv8NfcU0GZu8EUXZLTbCMupIUwy95LJ6lllN+JRPG25CwMHoB1q5xKh8YKhQnHYRAn4yW2yuHbf/5xgg==
+
"@types/node@*":
version "14.0.14"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.14.tgz#24a0b5959f16ac141aeb0c5b3cd7a15b7c64cbce"
@@ -1175,6 +1190,18 @@
"@types/express-serve-static-core" "*"
"@types/mime" "*"
+"@types/sinon@^9.0.8":
+ version "9.0.8"
+ resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-9.0.8.tgz#1ed0038d356784f75b086104ef83bfd4130bb81b"
+ integrity sha512-IVnI820FZFMGI+u1R+2VdRaD/82YIQTdqLYC9DLPszZuynAJDtCvCtCs3bmyL66s7FqRM3+LPX7DhHnVTaagDw==
+ dependencies:
+ "@types/sinonjs__fake-timers" "*"
+
+"@types/sinonjs__fake-timers@*":
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.2.tgz#3a84cf5ec3249439015e14049bd3161419bf9eae"
+ integrity sha512-dIPoZ3g5gcx9zZEszaxLSVTvMReD3xxyyDnQUjA6IYDG9Ba2AV0otMPs+77sG9ojB4Qr2N2Vk5RnKeuA0X/0bg==
+
"@types/whatwg-url@^6.4.0":
version "6.4.0"
resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
@@ -3741,7 +3768,7 @@
socket.io-client "2.1.1"
socket.io-parser "~3.2.0"
-source-map-support@~0.5.12:
+source-map-support@^0.5.19, source-map-support@~0.5.12:
version "0.5.19"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
diff --git a/prolog/gerrit_common.pl b/prolog/gerrit_common.pl
index e2857d0..407e5d6 100644
--- a/prolog/gerrit_common.pl
+++ b/prolog/gerrit_common.pl
@@ -429,3 +429,19 @@
commit_message_matches(Pattern) :-
commit_message(Msg),
regex_matches(Pattern, Msg).
+
+
+%% member/2:
+%%
+:- public member/2.
+%%
+member(X,[X|_]).
+member(X,[Y|T]) :- member(X,T).
+
+%% includes_file/1:
+%%
+:- public includes_file/1.
+%%
+includes_file(File) :-
+ files(List),
+ member(File, List).
\ No newline at end of file
diff --git a/proto/cache.proto b/proto/cache.proto
index 29b5870..aa71b87 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -76,7 +76,7 @@
// Instead, we just take the tedious yet simple approach of having a "has_foo"
// field for each nullable field "foo", indicating whether or not foo is null.
//
-// Next ID: 24
+// Next ID: 25
message ChangeNotesStateProto {
// Effectively required, even though the corresponding ChangeNotesState field
// is optional, since the field is only absent when NoteDb is disabled, in
@@ -218,7 +218,11 @@
string operation = 3;
string reason = 4;
}
+ // Only includes the most recent attention set update for each user.
repeated AttentionSetUpdateProto attention_set_update = 23;
+
+ // Includes all attention set updates.
+ repeated AttentionSetUpdateProto all_attention_set_update = 24;
}
// Serialized form of com.google.gerrit.server.query.change.ConflictKey
@@ -463,3 +467,97 @@
bool enabled = 5;
bool override_only = 6;
}
+
+// Serialized form of com.google.gerrit.entities.CachedProjectConfigProto.
+// Next ID: 19
+message CachedProjectConfigProto {
+ ProjectProto project = 1;
+ repeated GroupReferenceProto group_list = 2;
+ repeated PermissionRuleProto accounts_section = 3;
+ repeated AccessSectionProto access_sections = 4;
+ BranchOrderSectionProto branch_order_section = 5;
+ repeated ContributorAgreementProto contributor_agreements = 6;
+ repeated NotifyConfigProto notify_configs = 7;
+ repeated LabelTypeProto label_sections = 8;
+ repeated ConfiguredMimeTypeProto mime_types = 9;
+ repeated SubscribeSectionProto subscribe_sections = 10;
+ repeated StoredCommentLinkInfoProto comment_links = 11;
+ bytes rules_id = 12;
+ bytes revision = 13;
+ int64 max_object_size_limit = 14;
+ bool check_received_objects = 15;
+ map<string, ExtensionPanelSectionProto> extension_panels = 16;
+ map<string, string> plugin_configs = 17;
+ map<string, string> project_level_configs = 18;
+
+ // Next ID: 2
+ message ExtensionPanelSectionProto {
+ repeated string section = 1;
+ }
+}
+
+// Serialized key for com.google.gerrit.server.project.ProjectCacheImpl.
+// Next ID: 4
+message ProjectCacheKeyProto {
+ string project = 1;
+ bytes revision = 2;
+ bytes global_config_revision = 3; // Hash of All-Projects-projects.config. This
+ // will only be populated for All-Projects.
+}
+
+// Serialized form of com.google.gerrit.server.comment.CommentContextCacheImpl.Key
+// Next ID: 6
+message CommentContextKeyProto {
+ string project = 1;
+ string change_id = 2;
+ int32 patchset = 3;
+ string commentId = 4;
+
+ // hashed with the murmur3_128 hash function
+ string path_hash = 5;
+}
+
+// Serialized form of a list of com.google.gerrit.extensions.common.ContextLineInfo
+// Next ID: 2
+message AllCommentContextProto {
+ message CommentContextProto {
+ int32 line_number = 1;
+ string context_line = 2;
+ }
+ repeated CommentContextProto context = 1;
+}
+
+// Serialized key for
+// com.google.gerrit.server.patch.gitdiff.GitModifiedFilesCacheKey
+// Next ID: 5
+message GitModifiedFilesKeyProto {
+ string project = 1;
+ bytes a_tree = 2; // SHA-1 hash of the left git tree ID in the diff
+ bytes b_tree = 3; // SHA-1 hash of the right git tree ID in the diff
+ int32 rename_score = 4;
+}
+
+// Serialized key for
+// com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey
+// Next ID: 5
+message ModifiedFilesKeyProto {
+ string project = 1;
+ bytes a_commit = 2; // SHA-1 hash of the left commit ID in the diff
+ bytes b_commit = 3; // SHA-1 hash of the right commit ID in the diff
+ int32 rename_score = 4;
+}
+
+// Serialized form of com.google.gerrit.server.patch.gitdiff.ModifiedFile
+// Next ID: 4
+message ModifiedFileProto {
+ string change_type = 1; // ENUM as string
+ string old_path = 2;
+ string new_path = 3;
+}
+
+// Serialized form of a collection of
+// com.google.gerrit.server.patch.gitdiff.ModifiedFile
+// Next ID: 2
+message ModifiedFilesProto {
+ repeated ModifiedFileProto modifiedFile = 1;
+}
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 32ba0bc..000f4e2 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -30,8 +30,8 @@
{@param? changeRequestsPath: ?}
{@param? defaultChangeDetailHex: ?}
{@param? defaultDiffDetailHex: ?}
- {@param? preloadChangePage: ?}
- {@param? preloadDiffPage: ?}
+ {@param? defaultDashboardHex: ?}
+ {@param? dashboardQuery: ?}
{@param? userIsAuthenticated: ?}
<!DOCTYPE html>{\n}
<html lang="en">{\n}
@@ -55,6 +55,14 @@
{if $defaultDiffDetailHex}
diffPage: '{$defaultDiffDetailHex}',
{/if}
+ {if $defaultDashboardHex}
+ dashboardPage: '{$defaultDashboardHex}',
+ {/if}
+ {rb};
+ window.PRELOADED_QUERIES = {lb}
+ {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
+ dashboardQuery: [{for $query in $dashboardQuery}{$query},{/for}],
+ {/if}
{rb};
{if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
{if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
@@ -85,17 +93,18 @@
<link rel="icon" type="image/x-icon" href="{$canonicalPath}/favicon.ico">{\n}
{/if}
{if $changeRequestsPath}
- {if $preloadChangePage and $defaultChangeDetailHex}
+ {if $defaultChangeDetailHex}
<link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultChangeDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
{if $userIsAuthenticated}
<link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/?download-commands=true" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
{/if}
{/if}
- {if $preloadDiffPage and $defaultDiffDetailHex}
+ {if $defaultDiffDetailHex}
<link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/detail?O={$defaultDiffDetailHex}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
{if $userIsAuthenticated}
<link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/edit/" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
{/if}
+ <link rel="preload" href="{$staticResourcePath}/bower_components/highlightjs/highlight.min.js" as="script"/>
{/if}
<link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/comments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
<link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/robotcomments" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
@@ -103,6 +112,9 @@
<link rel="preload" href="{$canonicalPath}/{$changeRequestsPath}/drafts" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
{/if}
{/if}
+ {if $userIsAuthenticated and $defaultDashboardHex and $dashboardQuery}
+ <link rel="preload" href="{$canonicalPath}/changes/?O={$defaultDashboardHex}&S=0{for $query in $dashboardQuery}&q={$query}{/for}" as="fetch" type="application/json" crossorigin="anonymous"/>{\n}
+ {/if}
{if $useGoogleFonts}
<link rel="preload" as="style" href="https://fonts.googleapis.com/css?family=Roboto+Mono:400,500,700|Roboto:400,500,700|Open+Sans:400,600,700&display=swap">
diff --git a/resources/com/google/gerrit/server/commit-msg_test.sh b/resources/com/google/gerrit/server/commit-msg_test.sh
index d797be3..4f1a3f7 100755
--- a/resources/com/google/gerrit/server/commit-msg_test.sh
+++ b/resources/com/google/gerrit/server/commit-msg_test.sh
@@ -110,6 +110,25 @@
fi
}
+# gerrit.reviewUrl causes us to create Link instead of Change-Id.
+function test_link {
+ cat << EOF > input
+bla bla
+EOF
+
+ git config gerrit.reviewUrl https://myhost/
+ ${hook} input || fail "failed hook execution"
+ git config --unset gerrit.reviewUrl
+ found=$(grep -c '^Change-Id' input || true)
+ if [[ "${found}" != "0" ]]; then
+ fail "got ${found} Change-Ids, want 0"
+ fi
+ found=$(grep -c '^Link: https://myhost/id/I' input || true)
+ if [[ "${found}" != "1" ]]; then
+ fail "got ${found} Link footers, want 1"
+ fi
+}
+
# Change-Id goes after existing trailers.
function test_at_end {
cat << EOF > input
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
new file mode 100644
index 0000000..5ea41b2
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSet.soy
@@ -0,0 +1,46 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .AddToAttentionSet template will determine the contents of the email related to a
+ * user being added to the attention set.
+ */
+{template .AddToAttentionSet kind="text"}
+ {@param change: ?}
+ {@param coverLetter: ?}
+ {@param email: ?}
+ {@param fromName: ?}
+ {@param attentionSetUser: ?}
+ {@param reason: ?}
+ {if $fromName == $attentionSetUser}
+ {$fromName} added themselves to the attention set of this change.
+ {else}
+ {$fromName} requires the attention of {$attentionSetUser} to this change.
+ {/if}
+ {\n} The reason is: {$reason}.
+ {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+ {\n}
+ Change subject: {$change.subject}{\n}
+ ......................................................................{\n}
+ {if $coverLetter}
+ {\n}
+ {\n}
+ {$coverLetter}
+ {\n}
+ {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
new file mode 100644
index 0000000..bac180a
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/AddToAttentionSetHtml.soy
@@ -0,0 +1,43 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .AddToAttentionSetHtml}
+ {@param coverLetter: ?}
+ {@param email: ?}
+ {@param fromName: ?}
+ {@param attentionSetUser: ?}
+ {@param reason: ?}
+ <p>
+ {if $fromName == $attentionSetUser}
+ {$fromName} added themselves to the attention set of this change.
+ {else}
+ {$fromName} requires the attention of {$attentionSetUser} to this change.
+ {/if}
+ {\n} The reason is: {$reason}.
+ </p>
+
+ {if $email.changeUrl}
+ <p>
+ {call .ViewChangeButton data="all" /}
+ </p>
+ {/if}
+
+ {if $coverLetter}
+ <div style="white-space:pre-wrap">{$coverLetter}</div>
+ {/if}
+{/template}
\ No newline at end of file
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeader.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
new file mode 100644
index 0000000..fde69f1
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -0,0 +1,32 @@
+/**
+ * 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.
+*/
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .ChangeHeader kind="text"}
+ {@param attentionSet: ?}
+ {if $attentionSet}
+ Attention is currently required from:{sp}
+ {for $attentionSetUser in $attentionSet}
+ {$attentionSetUser}
+ // add commas or dot.
+ {if isLast($attentionSetUser)}.
+ {else},{sp}
+ {/if}
+ {/for}
+ {\n}
+ {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
new file mode 100644
index 0000000..ea12455
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
@@ -0,0 +1,33 @@
+/**
+ * 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.
+*/
+
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .ChangeHeaderHtml}
+ {@param attentionSet: ?}
+ {if $attentionSet}
+ <p> Attention is currently required from:{sp}
+ {for $attentionSetUser in $attentionSet}
+ {$attentionSetUser}
+ //add commas or dot.
+ {if isLast($attentionSetUser)}.
+ {else},{sp}
+ {/if}
+ {/for} </p>
+ {\n}
+ {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index fc92b31..893ef6f 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -39,9 +39,6 @@
{/if}
{for $group in $commentFiles}
- // Insert a space before the newline so that Gmail does not mistakenly link
- // the following line with the file link. See issue 9201.
- {if $group.link}{$group.link}{sp}{/if}{\n}
{$group.title}:{\n}
{\n}
diff --git a/resources/com/google/gerrit/server/mail/CommentHtml.soy b/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 617c8d17..21fee18 100644
--- a/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -111,9 +111,7 @@
{for $group in $commentFiles}
<li style="{$fileLiStyle}">
<p>
- {if $group.link}<a href="{$group.link}">{/if}
{$group.title}:
- {if $group.link}</a>{/if}
</p>
<ul style="{$ulStyle}">
diff --git a/resources/com/google/gerrit/server/mail/HeaderHtml.soy b/resources/com/google/gerrit/server/mail/HeaderHtml.soy
deleted file mode 100644
index 4710d8c..0000000
--- a/resources/com/google/gerrit/server/mail/HeaderHtml.soy
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * 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.
-*/
-
-{namespace com.google.gerrit.server.mail.template}
-
-{template .HeaderHtml}
-{/template}
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index 9de8707..e16b213 100644
--- a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -32,7 +32,8 @@
{/if}
{$reviewerName}
{/for}{sp}
- to <strong>review</strong> this change.
+ to <strong>review</strong> this change
+ {if $fromName != $ownerName}{sp}authored by {$ownerName}{/if}.
{else}
{$ownerName} has uploaded this change for <strong>review</strong>.
{/if}
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
new file mode 100644
index 0000000..f116adb
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSet.soy
@@ -0,0 +1,46 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * The .RemoveFromAttentionSet template will determine the contents of the email related to a
+ * user being added to the attention set.
+ */
+{template .RemoveFromAttentionSet kind="text"}
+ {@param change: ?}
+ {@param coverLetter: ?}
+ {@param email: ?}
+ {@param fromName: ?}
+ {@param attentionSetUser: ?}
+ {@param reason: ?}
+ {if $fromName == $attentionSetUser}
+ {$fromName} removed themselves from the attention set of this change.
+ {else}
+ {$fromName} doesn't require the attention of {$attentionSetUser} to this change.
+ {/if}
+ {\n} The reason is: {$reason}.
+ {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
+ {\n}
+ Change subject: {$change.subject}{\n}
+ ......................................................................{\n}
+ {if $coverLetter}
+ {\n}
+ {\n}
+ {$coverLetter}
+ {\n}
+ {/if}
+{/template}
diff --git a/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
new file mode 100644
index 0000000..55eef13
--- /dev/null
+++ b/resources/com/google/gerrit/server/mail/RemoveFromAttentionSetHtml.soy
@@ -0,0 +1,43 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+{template .RemoveFromAttentionSetHtml}
+ {@param coverLetter: ?}
+ {@param email: ?}
+ {@param fromName: ?}
+ {@param attentionSetUser: ?}
+ {@param reason: ?}
+ <p>
+ {if $fromName == $attentionSetUser}
+ {$fromName} removed themselves from the attention set of this change.
+ {else}
+ {$fromName} doesn't require the attention of {$attentionSetUser} to this change.
+ {/if}
+ {\n} The reason is: {$reason}.
+ </p>
+
+ {if $email.changeUrl}
+ <p>
+ {call .ViewChangeButton data="all" /}
+ </p>
+ {/if}
+
+ {if $coverLetter}
+ <div style="white-space:pre-wrap">{$coverLetter}</div>
+ {/if}
+{/template}
\ No newline at end of file
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 84eef77..6ab682c 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -21,6 +21,7 @@
cljs = text/x-clojurescript
cmake = text/x-cmake
cmake.in = text/x-cmake
+cml = application/json
contributing.md = text/x-gfm
CMakeLists.txt = text/x-cmake
CONTRIBUTING.md = text/x-gfm
@@ -231,6 +232,7 @@
toml = text/x-toml
tpl = text/x-smarty
ts = application/typescript
+tsx = text/tsx
ttcn = text/x-ttcn
ttcnpp = text/x-ttcn
ttcn3 = text/x-ttcn
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 2901232..3a40d22 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -48,12 +48,23 @@
exit 1
fi
-# Avoid the --in-place option which only appeared in Git 2.8
-# Avoid the --if-exists option which only appeared in Git 2.15
-if ! git -c trailer.ifexists=doNothing interpret-trailers \
- --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then
- echo "cannot insert change-id line in $1"
- exit 1
+reviewurl="$(git config --get gerrit.reviewUrl)"
+if test -n "${reviewurl}" ; then
+ if ! git interpret-trailers --parse < "$1" | grep -q '^Link:.*/id/I[0-9a-f]\{40\}$' ; then
+ if ! git interpret-trailers \
+ --trailer "Link: ${reviewurl%/}/id/I${random}" < "$1" > "${dest}" ; then
+ echo "cannot insert link footer in $1"
+ exit 1
+ fi
+ fi
+else
+ # Avoid the --in-place option which only appeared in Git 2.8
+ # Avoid the --if-exists option which only appeared in Git 2.15
+ if ! git -c trailer.ifexists=doNothing interpret-trailers \
+ --trailer "Change-Id: I${random}" < "$1" > "${dest}" ; then
+ echo "cannot insert change-id line in $1"
+ exit 1
+ fi
fi
if ! mv "${dest}" "$1" ; then
diff --git a/tools/BUILD b/tools/BUILD
index 5159177..be12735 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -17,6 +17,54 @@
visibility = ["//visibility:public"],
)
+JDK11_JVM_OPTS = select({
+ "@bazel_tools//src/conditions:openbsd": ["-Xbootclasspath/p:$(location @bazel_tools//tools/jdk:javac_jar)"],
+ "//conditions:default": [
+ "--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
+ "--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED",
+ "--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
+ "--patch-module=java.compiler=$(location @bazel_tools//tools/jdk:java_compiler_jar)",
+ "--patch-module=jdk.compiler=$(location @bazel_tools//tools/jdk:jdk_compiler_jar)",
+ "--add-opens=java.base/java.nio=ALL-UNNAMED",
+ "--add-opens=java.base/java.lang=ALL-UNNAMED",
+ ],
+})
+
+default_java_toolchain(
+ name = "error_prone_warnings_toolchain_java11",
+ bootclasspath = ["@bazel_tools//tools/jdk:platformclasspath.jar"],
+ forcibly_disable_header_compilation = False,
+ genclass = ["@bazel_tools//tools/jdk:genclass"],
+ header_compiler = ["@bazel_tools//tools/jdk:turbine"],
+ header_compiler_direct = ["@bazel_tools//tools/jdk:turbine_direct"],
+ ijar = ["@bazel_tools//tools/jdk:ijar"],
+ javabuilder = ["@bazel_tools//tools/jdk:javabuilder"],
+ javac = ["@bazel_tools//tools/jdk:javac_jar"],
+ javac_supports_workers = True,
+ jvm_opts = JDK11_JVM_OPTS,
+ misc = [
+ "-XDskipDuplicateBridges=true",
+ "-g",
+ "-parameters",
+ ],
+ package_configuration = [
+ ":error_prone",
+ ],
+ singlejar = ["@bazel_tools//tools/jdk:singlejar"],
+ source_version = "11",
+ target_version = "11",
+ tools = [
+ "@bazel_tools//tools/jdk:java_compiler_jar",
+ "@bazel_tools//tools/jdk:jdk_compiler_jar",
+ ],
+ visibility = ["//visibility:public"],
+)
+
# Error Prone errors enabled by default; see ../.bazelrc for how this is
# enabled. This warnings list is originally based on:
# https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index eeb5e6b..bbb1432 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -1,4 +1,4 @@
-load("@npm_bazel_terser//:index.bzl", "terser_minified")
+load("@npm//@bazel/terser:index.bzl", "terser_minified")
load("//lib/js:npm.bzl", "NPM_SHA1S", "NPM_VERSIONS")
NPMJS = "NPMJS"
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index c32579c..221ae2f 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -238,7 +238,7 @@
key=lambda package: get_package_display_name(
package)),
))
- return result
+ return sorted(result, key=lambda license: license.name)
def get_licensed_files(json_licensed_file_dict):
"""Convert json dictionary to LicensedFiles"""
@@ -305,4 +305,4 @@
return result
if __name__ == "__main__":
- main()
\ No newline at end of file
+ main()
diff --git a/tools/dev-hooks/pre-commit b/tools/dev-hooks/pre-commit
deleted file mode 100755
index af87b7e..0000000
--- a/tools/dev-hooks/pre-commit
+++ /dev/null
@@ -1,47 +0,0 @@
-#!/bin/sh
-#
-# Part of Gerrit Code Review (https://www.gerritcodereview.com/)
-#
-# Copyright (C) 2019 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.
-
-
-# To enable this hook:
-# - copy this file or content to ".git/hooks/pre-commit"
-# - (optional if you copied this file) make it executable: `chmod +x .git/hooks/pre-commit`
-
-set -ue
-
-# gitroot, default to .
-gitroot=$(git rev-parse --show-cdup)
-gitroot=${gitroot:-.};
-
-# eslint
-eslint=${gitroot}/node_modules/eslint/bin/eslint.js
-
-# Run eslint over changed frontend code
-CHANGED_UI_FILES=$(git diff --cached --name-only --diff-filter=ACM -- '*.js' '*.html' | grep 'polygerrit-ui') && true
-if [ "${CHANGED_UI_FILES}" ]; then
- if $eslint --fix ${CHANGED_UI_FILES}; then
- # Add again in case lint fix modified some files
- git add ${CHANGED_UI_FILES}
- exit 0
- else
- echo "Failed to fix all linter issues.";
- exit 1
- fi
-else
- echo "No UI files changed"
- exit 0
-fi
\ No newline at end of file
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index b1d5242..acb5346 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -23,11 +23,14 @@
MAIN = '//tools/eclipse:classpath'
AUTO = '//lib/auto:auto-value'
-JRE = '/'.join([
- 'org.eclipse.jdt.launching.JRE_CONTAINER',
- 'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
- 'JavaSE-1.8',
-])
+
+def JRE(java_vers = '11'):
+ return '/'.join([
+ 'org.eclipse.jdt.launching.JRE_CONTAINER',
+ 'org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType',
+ "JavaSE-%s" % java_vers,
+ ])
+
# Map of targets to corresponding classpath collector rules
cp_targets = {
AUTO: '//tools/eclipse:autovalue_classpath_collect',
@@ -46,9 +49,9 @@
opts.add_argument('-b', '--batch', action='store_true',
dest='batch', help='Bazel batch option')
opts.add_argument('-j', '--java', action='store',
- dest='java', help='Post Java 8 support (9)')
+ dest='java', help='Legacy Java 1.8 or post Java 11')
opts.add_argument('-e', '--edge_java', action='store',
- dest='edge_java', help='Post Java 9 support (10|11|...)')
+ dest='edge_java', help='Post Java 11 support (14|...)')
opts.add_argument('--bazel',
help=('name of the bazel executable. Defaults to using'
' bazelisk if found, or bazel if bazelisk is not'
@@ -95,7 +98,9 @@
if arg == "build":
build = True
cmd.append(arg)
- if custom_java and not edge_java:
+ if custom_java == '1.8':
+ cmd.append('--java_toolchain=//tools:error_prone_warnings_toolchain')
+ elif custom_java and not edge_java:
cmd.append('--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
cmd.append('--java_toolchain=@bazel_tools//tools/jdk:toolchain_java%s' % custom_java)
if edge_java and build:
@@ -312,7 +317,7 @@
s = s.replace('.jar', '-src.jar')
classpathentry('lib', p, s)
- classpathentry('con', JRE)
+ classpathentry('con', JRE(custom_java) if custom_java else JRE())
classpathentry('output', 'eclipse-out/classes')
classpathentry('src', '.apt_generated')
classpathentry('src', '.apt_generated_tests', out="eclipse-out/test")
diff --git a/tools/js/eslint-rules/goog-module-id.js b/tools/js/eslint-rules/goog-module-id.js
deleted file mode 100644
index 56cd645..0000000
--- a/tools/js/eslint-rules/goog-module-id.js
+++ /dev/null
@@ -1,160 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-const fs = require('fs');
-const path = require('path');
-const jsExt = '.js';
-
-class NonJsValidator {
- onProgramEnd(context, node) {
- }
- onGoogDeclareModuleId(context, node) {
- context.report({
- message: 'goog.declareModuleId is allowed only in .js files',
- node: node,
- });
- }
-}
-
-class JsOnlyValidator {
- onProgramEnd(context, node) {
- }
- onGoogDeclareModuleId(context, node) {
- context.report({
- message: 'goog.declareModuleId present, but .d.ts file doesn\'t exist. '
- + 'Either remove goog.declareModuleId or add the .d.ts file.',
- node: node,
- });
- }
-}
-
-class JsWithDtsValidator {
- constructor() {
- this._googDeclareModuleIdExists = false;
- }
- onProgramEnd(context, node) {
- if(!this._googDeclareModuleIdExists) {
- context.report({
- message: 'goog.declareModuleId(...) is missed. ' +
- 'Either add it or remove the associated .d.ts file.',
- node: node,
- })
- }
- }
- onGoogDeclareModuleId(context, node) {
- if(this._googDeclareModuleIdExists) {
- context.report({
- message: 'Duplicated goog.declareModuleId.',
- node: node,
- });
- return;
- }
-
- const filename = context.getFilename();
- this._googDeclareModuleIdExists = true;
-
- const scope = context.getScope();
- if(scope.type !== 'global' && scope.type !== 'module') {
- context.report({
- message: 'goog.declareModuleId is allowed only at the root level.',
- node: node,
- });
- // no return - other problems are possible
- }
- if(node.arguments.length !== 1) {
- context.report({
- message: 'goog.declareModuleId must have exactly one parameter.',
- node: node,
- });
- if(node.arguments.length === 0) {
- return;
- }
- }
-
- const argument = node.arguments[0];
- if(argument.type !== 'Literal') {
- context.report({
- message: 'The argument for the declareModuleId method '
- + 'must be a string literal.',
- node: argument,
- });
- return;
- }
- const pathStart = '/polygerrit-ui/app/';
- const index = filename.lastIndexOf(pathStart);
- if(index < 0) {
- context.report({
- message: 'The file located outside of polygerrit-ui/app directory. ' +
- 'Please check eslint config.',
- node: argument,
- });
- return;
- }
- const expectedName = 'polygerrit.' +
- filename.slice(index + pathStart.length, -jsExt.length)
- .replace(/\//g, '.') // Replace all occurrences of '/' with '.'
- .replace(/-/g, '$2d'); // Replace all occurrences of '-' with '$2d'
- if(argument.value !== expectedName) {
- context.report({
- message: `Invalid module id. It must be '${expectedName}'.`,
- node: argument,
- fix: function(fixer) {
- return fixer.replaceText(argument, `'${expectedName}'`);
- },
- });
- }
- }
-}
-
-module.exports = {
- meta: {
- type: 'problem',
- docs: {
- description: 'Check that goog.declareModuleId is valid',
- category: 'TS imports JS errors',
- recommended: false,
- },
- fixable: "code",
- schema: [],
- },
- create: function (context) {
- let fileValidator;
- return {
- Program: function(node) {
- const filename = context.getFilename();
- if(filename.endsWith(jsExt)) {
- const dtsFilename = filename.slice(0, -jsExt.length) + ".d.ts";
- if(fs.existsSync(dtsFilename)) {
- fileValidator = new JsWithDtsValidator();
- } else {
- fileValidator = new JsOnlyValidator();
- }
- }
- else {
- fileValidator = new NonJsValidator();
- }
- },
- "Program:exit": function(node) {
- fileValidator.onProgramEnd(context, node);
- fileValidator = null;
- },
- 'ExpressionStatement > CallExpression[callee.property.name="declareModuleId"][callee.object.name="goog"]': function(node) {
- fileValidator.onGoogDeclareModuleId(context, node);
- }
- };
- },
-};
diff --git a/tools/js/eslint-rules/report-ts-error.js b/tools/js/eslint-rules/report-ts-error.js
deleted file mode 100644
index 48dddf4..0000000
--- a/tools/js/eslint-rules/report-ts-error.js
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-// While we are migrating to typescript, gerrit can have .d.ts files.
-// The option "skipLibCheck" is set to true In the tsconfig.json.
-// This is required, because we want to skip type checking in node_modules
-// directory - some .d.ts files in 3rd-party modules are incorrect.
-// Unfortunately, this options also excludes our own .d.ts files from type
-// checking. This rule reports all .ts errors in a file as tslint errors.
-
-function getMassageTextFromChain(chainNode, prefix) {
- let nestedMessages = prefix + chainNode.messageText;
- if (chainNode.next && chainNode.next.length > 0) {
- nestedMessages += "\n";
- for (const node of chainNode.next) {
- nestedMessages +=
- getMassageTextFromChain(node, prefix + " ");
- if(!nestedMessages.endsWith('\n')) {
- nestedMessages += "\n";
- }
- }
- }
- return nestedMessages;
-}
-
-function getMessageText(diagnostic) {
- if (typeof diagnostic.messageText === 'string') {
- return diagnostic.messageText;
- }
- return getMassageTextFromChain(diagnostic.messageText, "");
-}
-
-function getDiagnosticStartAndEnd(diagnostic) {
- if(diagnostic.start) {
- const file = diagnostic.file;
- const start = file.getLineAndCharacterOfPosition(diagnostic.start);
- const length = diagnostic.length ? diagnostic.length : 0;
- return {
- start,
- end: file.getLineAndCharacterOfPosition(diagnostic.start + length),
- };
- }
- return {
- start: {line:0, character: 0},
- end: {line:0, character: 0},
- }
-}
-
-module.exports = {
- meta: {
- type: "problem",
- docs: {
- description: "Reports all typescript problems as linter problems",
- category: ".d.ts",
- recommended: false
- },
- schema: [],
- },
- create: function (context) {
- const program = context.parserServices.program;
- return {
- Program: function(node) {
- const sourceFile =
- context.parserServices.esTreeNodeToTSNodeMap.get(node);
- const allDiagnostics = [
- ...program.getDeclarationDiagnostics(sourceFile),
- ...program.getSemanticDiagnostics(sourceFile)];
- for(const diagnostic of allDiagnostics) {
- const {start, end } = getDiagnosticStartAndEnd(diagnostic);
- context.report({
- message: getMessageText(diagnostic),
- loc: {
- start: {
- line: start.line + 1,
- column: start.character,
- },
- end: {
- line: end.line + 1,
- column: end.character,
- }
- }
- });
- }
- },
- };
- }
-};
diff --git a/tools/js/eslint-rules/ts-imports-js.js b/tools/js/eslint-rules/ts-imports-js.js
deleted file mode 100644
index 69155ea..0000000
--- a/tools/js/eslint-rules/ts-imports-js.js
+++ /dev/null
@@ -1,100 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-const path = require('path');
-const fs = require('fs');
-
-function checkImportValid(context, node) {
- const file = context.getFilename();
- const importSource = node.source.value;
-
- if(importSource.startsWith('/')) {
- return {
- message: 'Do not use absolute path for import.',
- };
- }
- if(!importSource.startsWith('./') && !importSource.startsWith('../')) {
- // Import from node_modules - nothing to check
- return null;
- }
-
- const targetFile = path.resolve(path.dirname(file), importSource);
- if(path.extname(targetFile) !== '') {
- return {
- message: 'Do not specify extensions for import path.'
- };
- }
-
- if(fs.existsSync(targetFile + ".ts")) {
- // .ts file exists - nothing to check
- return null;
- }
-
- const jsFileExists = fs.existsSync(targetFile + '.js');
- const dtsFileExists = fs.existsSync(targetFile + '.d.ts');
-
- if(jsFileExists && !dtsFileExists) {
- return {
- message: `The '${importSource}.d.ts' file doesn't exist.`
- };
- }
-
- if(!jsFileExists && dtsFileExists) {
- return {
- message: `The '${importSource}.js' file doesn't exist.`
- };
- }
- // If both files (.js and .d.ts) don't exist, the error is reported by
- // the typescript compiler. Do not report anything from the rule.
- return null;
-}
-
-module.exports = {
- meta: {
- type: "problem",
- docs: {
- description: "Check that TS file can import specific JS file",
- category: "TS imports JS errors",
- recommended: false
- },
- schema: [],
- },
- create: function (context) {
- return {
- Program: function(node) {
- const filename = context.getFilename();
- if(filename.endsWith('.ts') && !filename.endsWith('.d.ts')) {
- return;
- }
- context.report({
- message: 'The rule must be used only with .ts files. ' +
- 'Check eslint settings.',
- node: node,
- });
- },
- ImportDeclaration: function (node) {
- const importProblem = checkImportValid(context, node);
- if(importProblem) {
- context.report({
- message: importProblem.message,
- node: node.source,
- });
- }
- }
- };
- }
-};
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 14c726e..970a4a9 100644
--- a/tools/maven/gerrit-acceptance-framework_pom.xml
+++ b/tools/maven/gerrit-acceptance-framework_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-acceptance-framework</artifactId>
- <version>3.3.0-SNAPSHOT</version>
+ <version>3.4.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Acceptance Test Framework</name>
<description>Framework for Gerrit's acceptance tests</description>
@@ -35,12 +35,18 @@
<name>David Pursehouse</name>
</developer>
<developer>
+ <name>Dmitrii Filippov</name>
+ </developer>
+ <developer>
<name>Edwin Kempin</name>
</developer>
<developer>
<name>Han-Wen Nienhuys</name>
</developer>
<developer>
+ <name>Joerg Zieren</name>
+ </developer>
+ <developer>
<name>Luca Milanesio</name>
</developer>
<developer>
@@ -53,7 +59,7 @@
<name>Matthias Sohn</name>
</developer>
<developer>
- <name>Ole Rehmsen</name>
+ <name>Nasser Grainawi</name>
</developer>
<developer>
<name>Patrick Hiesel</name>
@@ -64,6 +70,9 @@
<developer>
<name>Sven Selberg</name>
</developer>
+ <developer>
+ <name>Tao Zhou</name>
+ </developer>
</developers>
<mailingLists>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index bd323ba..74c4769 100644
--- a/tools/maven/gerrit-extension-api_pom.xml
+++ b/tools/maven/gerrit-extension-api_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-extension-api</artifactId>
- <version>3.3.0-SNAPSHOT</version>
+ <version>3.4.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Extension API</name>
<description>API for Gerrit Extensions</description>
@@ -35,12 +35,18 @@
<name>David Pursehouse</name>
</developer>
<developer>
+ <name>Dmitrii Filippov</name>
+ </developer>
+ <developer>
<name>Edwin Kempin</name>
</developer>
<developer>
<name>Han-Wen Nienhuys</name>
</developer>
<developer>
+ <name>Joerg Zieren</name>
+ </developer>
+ <developer>
<name>Luca Milanesio</name>
</developer>
<developer>
@@ -53,7 +59,7 @@
<name>Matthias Sohn</name>
</developer>
<developer>
- <name>Ole Rehmsen</name>
+ <name>Nasser Grainawi</name>
</developer>
<developer>
<name>Patrick Hiesel</name>
@@ -64,6 +70,9 @@
<developer>
<name>Sven Selberg</name>
</developer>
+ <developer>
+ <name>Tao Zhou</name>
+ </developer>
</developers>
<mailingLists>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 3b059e5..e8fae82 100644
--- a/tools/maven/gerrit-plugin-api_pom.xml
+++ b/tools/maven/gerrit-plugin-api_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-plugin-api</artifactId>
- <version>3.3.0-SNAPSHOT</version>
+ <version>3.4.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Gerrit Code Review - Plugin API</name>
<description>API for Gerrit Plugins</description>
@@ -35,12 +35,18 @@
<name>David Pursehouse</name>
</developer>
<developer>
+ <name>Dmitrii Filippov</name>
+ </developer>
+ <developer>
<name>Edwin Kempin</name>
</developer>
<developer>
<name>Han-Wen Nienhuys</name>
</developer>
<developer>
+ <name>Joerg Zieren</name>
+ </developer>
+ <developer>
<name>Luca Milanesio</name>
</developer>
<developer>
@@ -53,7 +59,7 @@
<name>Matthias Sohn</name>
</developer>
<developer>
- <name>Ole Rehmsen</name>
+ <name>Nasser Grainawi</name>
</developer>
<developer>
<name>Patrick Hiesel</name>
@@ -64,6 +70,9 @@
<developer>
<name>Sven Selberg</name>
</developer>
+ <developer>
+ <name>Tao Zhou</name>
+ </developer>
</developers>
<mailingLists>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index b8fa132..be6688a 100644
--- a/tools/maven/gerrit-war_pom.xml
+++ b/tools/maven/gerrit-war_pom.xml
@@ -2,7 +2,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.google.gerrit</groupId>
<artifactId>gerrit-war</artifactId>
- <version>3.3.0-SNAPSHOT</version>
+ <version>3.4.0-SNAPSHOT</version>
<packaging>war</packaging>
<name>Gerrit Code Review - WAR</name>
<description>Gerrit WAR</description>
@@ -35,12 +35,18 @@
<name>David Pursehouse</name>
</developer>
<developer>
+ <name>Dmitrii Filippov</name>
+ </developer>
+ <developer>
<name>Edwin Kempin</name>
</developer>
<developer>
<name>Han-Wen Nienhuys</name>
</developer>
<developer>
+ <name>Joerg Zieren</name>
+ </developer>
+ <developer>
<name>Luca Milanesio</name>
</developer>
<developer>
@@ -53,7 +59,7 @@
<name>Matthias Sohn</name>
</developer>
<developer>
- <name>Ole Rehmsen</name>
+ <name>Nasser Grainawi</name>
</developer>
<developer>
<name>Patrick Hiesel</name>
@@ -64,6 +70,9 @@
<developer>
<name>Sven Selberg</name>
</developer>
+ <developer>
+ <name>Tao Zhou</name>
+ </developer>
</developers>
<mailingLists>
diff --git a/tools/node_tools/node_modules_licenses/BUILD b/tools/node_tools/node_modules_licenses/BUILD
index bd7e854..581b3a9 100644
--- a/tools/node_tools/node_modules_licenses/BUILD
+++ b/tools/node_tools/node_modules_licenses/BUILD
@@ -1,6 +1,6 @@
-load("@npm_bazel_typescript//:index.bzl", "ts_library")
load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
-load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
package(default_visibility = ["//visibility:public"])
diff --git a/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
index 3f4955e..49beda3 100644
--- a/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
+++ b/tools/node_tools/node_modules_licenses/installed-node-modules-map.ts
@@ -45,8 +45,12 @@
export class InsalledPackagesBuilder {
private readonly rootPathToPackageMap: Map<DirPath, InstalledPackage> = new Map();
+ public constructor(private readonly nonPackages: Set<string>) {
+ }
+
public addPackageJson(packageJsonPath: string) {
const pack = this.createInstalledPackage(packageJsonPath);
+ if (!pack) return;
this.rootPathToPackageMap.set(pack.rootPath, pack)
}
public addFile(file: string) {
@@ -60,19 +64,23 @@
* For example for the packageJsonFile='/a/node_modules/b/node_modules/d/e/package.json'
* the package name is 'd/e'
*/
- private createInstalledPackage(packageJsonFile: string): InstalledPackage {
+ private createInstalledPackage(packageJsonFile: string): InstalledPackage | undefined {
const nameParts: Array<string> = [];
const rootPath = path.dirname(packageJsonFile);
let currentDir = rootPath;
while(currentDir != "") {
const partName = path.basename(currentDir);
if(partName === "node_modules") {
+ const packageName = nameParts.reverse().join("/");
const version = JSON.parse(fs.readFileSync(packageJsonFile, {encoding: 'utf-8'}))["version"];
if(!version) {
+ if (this.nonPackages.has(packageName)) {
+ return undefined;
+ }
fail(`Can't get version for ${packageJsonFile}`)
}
return {
- name: nameParts.reverse().join("/"),
+ name: packageName,
rootPath: rootPath,
version: version,
files: []
diff --git a/tools/node_tools/node_modules_licenses/licenses-map.ts b/tools/node_tools/node_modules_licenses/licenses-map.ts
index 9f277e5..7dfb23e 100644
--- a/tools/node_tools/node_modules_licenses/licenses-map.ts
+++ b/tools/node_tools/node_modules_licenses/licenses-map.ts
@@ -216,7 +216,13 @@
/** getInstalledPackages Collects information about all installed packages */
private getInstalledPackages(nodeModulesFiles: ReadonlyArray<string>): InstalledPackage[] {
- const builder = new InsalledPackagesBuilder();
+ const fullNonPackageNames: string[] = [];
+ for (const p of this.packages) {
+ if (p.nonPackages) {
+ fullNonPackageNames.push(...p.nonPackages.map(name => `${p.name}/${name}`));
+ }
+ }
+ const builder = new InsalledPackagesBuilder(new Set(fullNonPackageNames));
// Register all package.json files - such files exists in the root folder of each module
nodeModulesFiles.filter(f => path.basename(f) === "package.json")
.forEach(packageJsonFile => builder.addPackageJson(packageJsonFile));
diff --git a/tools/node_tools/node_modules_licenses/package-license-info.ts b/tools/node_tools/node_modules_licenses/package-license-info.ts
index c5cdb0f..79dea09 100644
--- a/tools/node_tools/node_modules_licenses/package-license-info.ts
+++ b/tools/node_tools/node_modules_licenses/package-license-info.ts
@@ -67,4 +67,6 @@
versions?: string[];
/** Predicate to select files to apply license. */
filesFilter?: FilesFilter;
+ /** List of nested directories with package.json files, that are not real packages*/
+ nonPackages?: string[];
}
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index 6fafe63..36a10d3 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -3,8 +3,8 @@
"description": "Gerrit Build Tools",
"browser": false,
"dependencies": {
- "@bazel/rollup": "^1.6.1",
- "@bazel/typescript": "^1.6.1",
+ "@bazel/rollup": "^2.2.2",
+ "@bazel/typescript": "^2.2.2",
"@types/node": "^10.17.12",
"@types/parse5": "^4.0.0",
"@types/parse5-html-rewriting-stream": "^5.1.2",
@@ -16,7 +16,7 @@
"rollup": "^1.27.5",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^5.1.3",
- "typescript": "3.8.2"
+ "typescript": "3.9.5"
},
"devDependencies": {},
"license": "Apache-2.0",
diff --git a/tools/node_tools/polygerrit_app_preprocessor/BUILD b/tools/node_tools/polygerrit_app_preprocessor/BUILD
index b031293..b5ee34f 100644
--- a/tools/node_tools/polygerrit_app_preprocessor/BUILD
+++ b/tools/node_tools/polygerrit_app_preprocessor/BUILD
@@ -1,6 +1,6 @@
-load("@npm_bazel_typescript//:index.bzl", "ts_library")
load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
-load("@npm_bazel_rollup//:index.bzl", "rollup_bundle")
+load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
package(default_visibility = ["//visibility:public"])
diff --git a/tools/node_tools/utils/BUILD b/tools/node_tools/utils/BUILD
index fca3c12..5c407ca 100644
--- a/tools/node_tools/utils/BUILD
+++ b/tools/node_tools/utils/BUILD
@@ -1,4 +1,4 @@
-load("@npm_bazel_typescript//:index.bzl", "ts_library")
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
package(default_visibility = ["//visibility:public"])
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 78349fa..988deb7 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -492,15 +492,15 @@
lodash "^4.17.13"
to-fast-properties "^2.0.0"
-"@bazel/rollup@^1.6.1":
- version "1.6.1"
- resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-1.6.1.tgz#7ec9d39a3fca23256fca55410339724804802616"
- integrity sha512-FhblJkpd8VKl9txhAAIotSsIOHRpPd2FgJG7Op3uV7LfaCVBmUs3XDBZCgfwt5wmEpd3lwCHA1Ei+O/URS2+5w==
+"@bazel/rollup@^2.2.2":
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-2.2.2.tgz#1abfc5cbf5eb65db2aa145e584d225684d961055"
+ integrity sha512-z3sK0dt7pftjxlLuo66e3PMMGyjq6vD/8B+OEFN3LD3GjE34e8X0/KeRX5lXWs1ecVlrnTroiBxLCJSHwqBrEA==
-"@bazel/typescript@^1.6.1":
- version "1.6.1"
- resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-1.6.1.tgz#1bf83c20021d359bc9b532181981ac540584a30c"
- integrity sha512-wQ9AASRcG1jLQOpJfNOMjZzPpwIV/9qTOxCFvp55ga6A5a2qveQr8JJ7jHHbBM0LtK+slEPixXmVmtEOwfKsIg==
+"@bazel/typescript@^2.2.2":
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-2.2.2.tgz#c7cd49cb630ca3720c04c94046ba8ca4c0d5b0aa"
+ integrity sha512-hkx/7L3s8q5gIgaSFmkUZWPqdKmdJmQ04GaLnsI/YEp9EhPObqATSKnOHeDdT7bzqLO7giDAwAiXhEmsO1Smcw==
dependencies:
protobufjs "6.8.8"
semver "5.6.0"
@@ -950,9 +950,9 @@
integrity sha512-N33cKXGSqhOYaPiT4xUGsYlPPDwFtQM/6QxJxuMXA/7BcySW+lkn2yigWP7vfs4daiL/7NJNU6DMCqg5N4B+xQ==
"@types/node@^10.1.0":
- version "10.17.13"
- resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.13.tgz#ccebcdb990bd6139cd16e84c39dc2fb1023ca90c"
- integrity sha512-pMCcqU2zT4TjqYFrWtYHKal7Sl30Ims6ulZ4UFXxI4xbtQqK/qqKwkDoBFCfooRqqmRu9vY3xaJRwxSh673aYg==
+ version "10.17.42"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.42.tgz#90dd71b26fe4f4e2929df6b07e72ef2e9648a173"
+ integrity sha512-HElxYF7C/MSkuvlaHB2c+82zhXiuO49Cq056Dol8AQuTph7oJtduo2n6J8rFa+YhJyNgQ/Lm20ZaxqD0vxU0+Q==
"@types/node@^10.17.12":
version "10.17.24"
@@ -7834,7 +7834,12 @@
resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
-tslib@^1.8.1, tslib@^1.9.0:
+tslib@^1.8.1:
+ version "1.14.1"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+ integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+
+tslib@^1.9.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
@@ -7876,10 +7881,10 @@
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
-typescript@3.8.2:
- version "3.8.2"
- resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.2.tgz#91d6868aaead7da74f493c553aeff76c0c0b1d5a"
- integrity sha512-EgOVgL/4xfVrCMbhYKUQTdF37SQn4Iw73H5BgCrF1Abdun7Kwy/QZsE/ssAy0y4LxBbvua3PIbFsbRczWWnDdQ==
+typescript@3.9.5:
+ version "3.9.5"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36"
+ integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==
typical@^2.6.0, typical@^2.6.1:
version "2.6.1"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index 5934512..a3cc66e 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -23,8 +23,8 @@
maven_jar(
name = "dropwizard-core",
- artifact = "io.dropwizard.metrics:metrics-core:4.1.10.1",
- sha1 = "e55d1e4de0ccec6f404dbf775c62626d8b9f79a4",
+ artifact = "io.dropwizard.metrics:metrics-core:4.1.14",
+ sha1 = "14cf9dd67619a0390812dddb232df339e3383d35",
)
SSHD_VERS = "2.4.0"
@@ -96,14 +96,14 @@
# and httpasyncclient as necessary.
maven_jar(
name = "elasticsearch-rest-client",
- artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.8.0",
- sha1 = "ab28f6110bdc7d2ec886e1d6ff29a6c8ee30b883",
+ artifact = "org.elasticsearch.client:elasticsearch-rest-client:7.8.1",
+ sha1 = "59feefe006a96a39f83b0dfb6780847e06c1d0a8",
)
maven_jar(
name = "jackson-core",
- artifact = "com.fasterxml.jackson.core:jackson-core:2.11.1",
- sha1 = "8b02908d53183fdf9758e7e20f2fdee87613a962",
+ artifact = "com.fasterxml.jackson.core:jackson-core:2.11.3",
+ sha1 = "c2351800432bdbdd8284c3f5a7f0782a352aa84a",
)
# Google internal dependencies: these are developed at Google, so there is
diff --git a/tools/release_noter/.editorconfig b/tools/release_noter/.editorconfig
new file mode 100644
index 0000000..9d2865f
--- /dev/null
+++ b/tools/release_noter/.editorconfig
@@ -0,0 +1,2 @@
+[*.py]
+indent_size = 4
diff --git a/tools/release_noter/.flake8 b/tools/release_noter/.flake8
new file mode 100644
index 0000000..24f2db7
--- /dev/null
+++ b/tools/release_noter/.flake8
@@ -0,0 +1,5 @@
+[flake8]
+max-line-length = 100
+extend-ignore =
+ # https://github.com/PyCQA/pycodestyle/issues/373
+ E203,
diff --git a/tools/release_noter/.gitignore b/tools/release_noter/.gitignore
new file mode 100644
index 0000000..c791f63
--- /dev/null
+++ b/tools/release_noter/.gitignore
@@ -0,0 +1,2 @@
+/.idea/
+/release_noter*.md
diff --git a/tools/release_noter/Makefile b/tools/release_noter/Makefile
new file mode 100644
index 0000000..f18a814
--- /dev/null
+++ b/tools/release_noter/Makefile
@@ -0,0 +1,26 @@
+COMMITS := 10
+
+.PHONY: all clean
+
+all: deploy black flake test
+
+clean:
+ rm -f release_noter*.md
+
+setup:
+ pipenv install --dev
+
+deploy:
+ pipenv install --dev --deploy
+
+black:
+ pipenv run black release_noter.py
+
+flake:
+ pipenv run flake8 release_noter.py
+
+help:
+ pipenv run python release_noter.py -h
+
+test:
+ pipenv run python release_noter.py HEAD~$(COMMITS)..HEAD -l
diff --git a/tools/release_noter/Pipfile b/tools/release_noter/Pipfile
new file mode 100644
index 0000000..8e67cf8
--- /dev/null
+++ b/tools/release_noter/Pipfile
@@ -0,0 +1,15 @@
+[[source]]
+name = "pypi"
+url = "https://pypi.org/simple"
+verify_ssl = true
+
+[dev-packages]
+black = { version = "==20.8b1", markers = "python_version >= '3.8'" }
+flake8 = { version = "==3.8.4", markers = "python_version >= '3.8'" }
+
+[packages]
+jinja2 = { version = "==2.11.2", markers = "python_version >= '3.8'" }
+pygerrit2 = { version = "==2.0.13", markers = "python_version >= '3.8'" }
+
+[requires]
+python_version = "3.8"
diff --git a/tools/release_noter/Pipfile.lock b/tools/release_noter/Pipfile.lock
new file mode 100644
index 0000000..7454fe7
--- /dev/null
+++ b/tools/release_noter/Pipfile.lock
@@ -0,0 +1,266 @@
+{
+ "_meta": {
+ "hash": {
+ "sha256": "66a7d7fdb0a62b702f5414852b80c579a3c16d7a4ed1f3b5344943437c6157ee"
+ },
+ "pipfile-spec": 6,
+ "requires": {
+ "python_version": "3.8"
+ },
+ "sources": [
+ {
+ "name": "pypi",
+ "url": "https://pypi.org/simple",
+ "verify_ssl": true
+ }
+ ]
+ },
+ "default": {
+ "certifi": {
+ "hashes": [
+ "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3",
+ "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"
+ ],
+ "version": "==2020.6.20"
+ },
+ "chardet": {
+ "hashes": [
+ "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
+ "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
+ ],
+ "version": "==3.0.4"
+ },
+ "idna": {
+ "hashes": [
+ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6",
+ "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.10"
+ },
+ "jinja2": {
+ "hashes": [
+ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0",
+ "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.8'",
+ "version": "==2.11.2"
+ },
+ "markupsafe": {
+ "hashes": [
+ "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
+ "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
+ "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
+ "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
+ "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
+ "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
+ "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
+ "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
+ "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
+ "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
+ "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
+ "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
+ "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
+ "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
+ "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
+ "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
+ "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
+ "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
+ "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
+ "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
+ "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
+ "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
+ "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
+ "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
+ "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
+ "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
+ "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
+ "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
+ "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
+ "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
+ "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
+ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
+ "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==1.1.1"
+ },
+ "pbr": {
+ "hashes": [
+ "sha256:14bfd98f51c78a3dd22a1ef45cf194ad79eee4a19e8e1a0d5c7f8e81ffe182ea",
+ "sha256:5adc0f9fc64319d8df5ca1e4e06eea674c26b80e6f00c530b18ce6a6592ead15"
+ ],
+ "markers": "python_version >= '2.6'",
+ "version": "==5.5.0"
+ },
+ "pygerrit2": {
+ "hashes": [
+ "sha256:4e3c66017e02833bb9302f98fca47fb21cc01d5d2281d62eaefa18e8bd2c2c08"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.8'",
+ "version": "==2.0.13"
+ },
+ "requests": {
+ "hashes": [
+ "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b",
+ "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==2.24.0"
+ },
+ "urllib3": {
+ "hashes": [
+ "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a",
+ "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
+ "version": "==1.25.10"
+ }
+ },
+ "develop": {
+ "appdirs": {
+ "hashes": [
+ "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41",
+ "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"
+ ],
+ "version": "==1.4.4"
+ },
+ "black": {
+ "hashes": [
+ "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.8'",
+ "version": "==20.8b1"
+ },
+ "click": {
+ "hashes": [
+ "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a",
+ "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
+ "version": "==7.1.2"
+ },
+ "flake8": {
+ "hashes": [
+ "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839",
+ "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.8'",
+ "version": "==3.8.4"
+ },
+ "mccabe": {
+ "hashes": [
+ "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
+ "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
+ ],
+ "version": "==0.6.1"
+ },
+ "mypy-extensions": {
+ "hashes": [
+ "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
+ "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
+ ],
+ "version": "==0.4.3"
+ },
+ "pathspec": {
+ "hashes": [
+ "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
+ "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
+ ],
+ "version": "==0.8.0"
+ },
+ "pycodestyle": {
+ "hashes": [
+ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367",
+ "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.6.0"
+ },
+ "pyflakes": {
+ "hashes": [
+ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92",
+ "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"
+ ],
+ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
+ "version": "==2.2.0"
+ },
+ "regex": {
+ "hashes": [
+ "sha256:02686a2f0b1a4be0facdd0d3ad4dc6c23acaa0f38fb5470d892ae88584ba705c",
+ "sha256:137da580d1e6302484be3ef41d72cf5c3ad22a076070051b7449c0e13ab2c482",
+ "sha256:20cdd7e1736f4f61a5161aa30d05ac108ab8efc3133df5eb70fe1e6a23ea1ca6",
+ "sha256:25991861c6fef1e5fd0a01283cf5658c5e7f7aa644128e85243bc75304e91530",
+ "sha256:26b85672275d8c7a9d4ff93dbc4954f5146efdb2ecec89ad1de49439984dea14",
+ "sha256:2f60ba5c33f00ce9be29a140e6f812e39880df8ba9cb92ad333f0016dbc30306",
+ "sha256:3dd952f3f8dc01b72c0cf05b3631e05c50ac65ddd2afdf26551638e97502107b",
+ "sha256:578ac6379e65eb8e6a85299b306c966c852712c834dc7eef0ba78d07a828f67b",
+ "sha256:5d4a3221f37520bb337b64a0632716e61b26c8ae6aaffceeeb7ad69c009c404b",
+ "sha256:608d6c05452c0e6cc49d4d7407b4767963f19c4d2230fa70b7201732eedc84f2",
+ "sha256:65b6b018b07e9b3b6a05c2c3bb7710ed66132b4df41926c243887c4f1ff303d5",
+ "sha256:698f8a5a2815e1663d9895830a063098ae2f8f2655ae4fdc5dfa2b1f52b90087",
+ "sha256:6c72adb85adecd4522a488a751e465842cdd2a5606b65464b9168bf029a54272",
+ "sha256:6d4cdb6c20e752426b2e569128488c5046fb1b16b1beadaceea9815c36da0847",
+ "sha256:6e9f72e0ee49f7d7be395bfa29e9533f0507a882e1e6bf302c0a204c65b742bf",
+ "sha256:828618f3c3439c5e6ef8621e7c885ca561bbaaba90ddbb6a7dfd9e1ec8341103",
+ "sha256:85b733a1ef2b2e7001aff0e204a842f50ad699c061856a214e48cfb16ace7d0c",
+ "sha256:8958befc139ac4e3f16d44ec386c490ea2121ed8322f4956f83dd9cad8e9b922",
+ "sha256:a51e51eecdac39a50ede4aeed86dbef4776e3b73347d31d6ad0bc9648ba36049",
+ "sha256:aeac7c9397480450016bc4a840eefbfa8ca68afc1e90648aa6efbfe699e5d3bb",
+ "sha256:aef23aed9d4017cc74d37f703d57ce254efb4c8a6a01905f40f539220348abf9",
+ "sha256:af1f5e997dd1ee71fb6eb4a0fb6921bf7a778f4b62f1f7ef0d7445ecce9155d6",
+ "sha256:b5eeaf4b5ef38fab225429478caf71f44d4a0b44d39a1aa4d4422cda23a9821b",
+ "sha256:d25f5cca0f3af6d425c9496953445bf5b288bb5b71afc2b8308ad194b714c159",
+ "sha256:d81be22d5d462b96a2aa5c512f741255ba182995efb0114e5a946fe254148df1",
+ "sha256:e935a166a5f4c02afe3f7e4ce92ce5a786f75c6caa0c4ce09c922541d74b77e8",
+ "sha256:ef3a55b16c6450574734db92e0a3aca283290889934a23f7498eaf417e3af9f0"
+ ],
+ "version": "==2020.10.15"
+ },
+ "toml": {
+ "hashes": [
+ "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f",
+ "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"
+ ],
+ "version": "==0.10.1"
+ },
+ "typed-ast": {
+ "hashes": [
+ "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
+ "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
+ "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
+ "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
+ "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
+ "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
+ "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
+ "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
+ "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
+ "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
+ "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
+ "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
+ "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
+ "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
+ "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
+ "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
+ "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
+ "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
+ "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
+ "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
+ "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
+ ],
+ "version": "==1.4.1"
+ },
+ "typing-extensions": {
+ "hashes": [
+ "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918",
+ "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c",
+ "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"
+ ],
+ "version": "==3.7.4.3"
+ }
+ }
+}
diff --git a/tools/release_noter/README.md b/tools/release_noter/README.md
new file mode 100644
index 0000000..449522b
--- /dev/null
+++ b/tools/release_noter/README.md
@@ -0,0 +1,53 @@
+# Release Noter
+
+## Setup
+
+```bash
+make setup
+make deploy
+```
+
+* The `deploy` target may not succeed if `Pipfile.lock` is out of date.
+ * The `setup` target can be used first in such a case.
+* Using `make all` will run the `deploy` target, among the other key targets.
+
+## Warning
+
+The make `clean` target removes any previously made `release_noter*.md` file(s).
+
+Running `release_noter.py` multiple times without cleaning creates the next `N`
+`release_noter-N.md` file, without overwriting the previous one(s).
+
+## Usage
+
+```bash
+make help
+```
+
+* The resulting `release_noter*.md` file(s) can be edited then copied over to the `homepage`.
+ * The markdown file name should be `x.y.md`, where `x.y` is the major release version.
+ * Alternatively, an existing `x.y.md` can be edited with `release_noter*.md` snippets.
+
+## Testing
+
+```bash
+make test
+make test COMMITS=100
+```
+
+This target will use the `-l` option, which takes more time as `COMMITS` increases.
+
+## Examples
+
+```bash
+pipenv run python release_noter.py v3.2.3..HEAD
+pipenv run python release_noter.py v3.2.3..v3.3.0-rc0
+pipenv run python release_noter.py v3.2.3..v3.3.0-rc0 -l
+```
+
+## Coding
+
+```bash
+make black
+make flake
+```
diff --git a/tools/release_noter/release_noter.md.template b/tools/release_noter/release_noter.md.template
new file mode 100644
index 0000000..06399a1
--- /dev/null
+++ b/tools/release_noter/release_noter.md.template
@@ -0,0 +1,36 @@
+---
+title: "Gerrit {{ data.new }} release (in development)"
+permalink: {{ data.major }}.html
+hide_sidebar: true
+hide_navtoggle: true
+toc: true
+---
+
+Download: **[{{ data.new }}](https://gerrit-releases.storage.googleapis.com/gerrit-{{ data.new }}.war)**
+| [{{ data.previous }}](https://gerrit-releases.storage.googleapis.com/gerrit-{{ data.previous }}.war)
+
+Documentation: **[{{ data.new }}](https://gerrit-documentation.storage.googleapis.com/Documentation/{{ data.doc }}/index.html)**
+| [{{ data.previous }}](https://gerrit-documentation.storage.googleapis.com/Documentation/{{ data.previous }}/index.html)
+
+## Release highlights
+
+## Important notes
+
+### Schema changes
+
+### Breaking changes
+
+## Native packaging
+
+## New features
+
+### REST APIs
+
+* Accounts
+* Changes
+* Groups
+* Projects
+
+## End-to-end tests
+
+## Plugin changes
diff --git a/tools/release_noter/release_noter.py b/tools/release_noter/release_noter.py
new file mode 100644
index 0000000..05fa023
--- /dev/null
+++ b/tools/release_noter/release_noter.py
@@ -0,0 +1,363 @@
+#!/usr/bin/env python
+
+import argparse
+import os
+import re
+import subprocess
+
+from enum import Enum
+from jinja2 import Template
+from os import path
+from pygerrit2 import Anonymous, GerritRestAPI
+
+EXCLUDED_SUBJECTS = {
+ "annotat",
+ "assert",
+ "AutoValue",
+ "avadoc", # Javadoc &co.
+ "avaDoc",
+ "ava-doc",
+ "baz", # bazel, bazlet(s)
+ "Baz",
+ "circular",
+ "class",
+ "common.ts",
+ "construct",
+ "controls",
+ "debounce",
+ "Debounce",
+ "decorat",
+ "efactor", # Refactor &co.
+ "format",
+ "Format",
+ "getter",
+ "gr-",
+ "hide",
+ "icon",
+ "ignore",
+ "immutab",
+ "import",
+ "inject",
+ "iterat",
+ "IT",
+ "js",
+ "label",
+ "licence",
+ "license",
+ "lint",
+ "listener",
+ "Listener",
+ "lock",
+ "method",
+ "metric",
+ "mock",
+ "module",
+ "naming",
+ "nits",
+ "nongoogle",
+ "prone", # error prone &co.
+ "Prone",
+ "register",
+ "Register",
+ "remove",
+ "Remove",
+ "rename",
+ "Rename",
+ "Revert",
+ "serializ",
+ "Serializ",
+ "server.go",
+ "setter",
+ "spell",
+ "Spell",
+ "test", # testing, tests; unit or else
+ "Test",
+ "thread",
+ "tsetse",
+ "type",
+ "Type",
+ "typo",
+ "util",
+ "variable",
+ "version",
+ "warning",
+}
+
+COMMIT_SHA1_PATTERN = r"^commit ([a-z0-9]+)$"
+DATE_HEADER_PATTERN = r"Date: .+"
+SUBJECT_SUBMODULES_PATTERN = r"^Update git submodules$"
+ISSUE_ID_PATTERN = r"[a-zA-Z]+: [Ii]ssue ([0-9]+)"
+CHANGE_ID_PATTERN = r"^Change-Id: [I0-9a-z]+$"
+PLUGIN_PATTERN = r"plugins/([a-z\-]+)"
+RELEASE_VERSIONS_PATTERN = r"v([0-9\.\-rc]+)\.\.v([0-9\.\-rc]+)"
+RELEASE_MAJOR_PATTERN = r"^([0-9]+\.[0-9]+).+"
+RELEASE_DOC_PATTERN = r"^([0-9]+\.[0-9]+\.[0-9]+).*"
+
+CHANGE_URL = "/c/gerrit/+/"
+COMMIT_URL = "/changes/?q=commit%3A"
+GERRIT_URL = "https://gerrit-review.googlesource.com"
+ISSUE_URL = "https://bugs.chromium.org/p/gerrit/issues/detail?id="
+
+MARKDOWN = "release_noter"
+GIT_COMMAND = "git"
+GIT_PATH = "../.."
+PLUGINS = "plugins/"
+UTF8 = "UTF-8"
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description="Generate an initial release notes markdown file.",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ parser.add_argument(
+ "-l",
+ "--link",
+ dest="link",
+ required=False,
+ default=False,
+ action="store_true",
+ help="link commits to change in Gerrit; slower as it gets each _number from gerrit",
+ )
+ parser.add_argument("range", help="git log revision range")
+ return parser.parse_args()
+
+
+def list_submodules():
+ submodule_names = [
+ GIT_COMMAND,
+ "submodule",
+ "foreach",
+ "--quiet",
+ "echo $name",
+ ]
+ return subprocess.check_output(submodule_names, cwd=f"{GIT_PATH}", encoding=UTF8)
+
+
+def open_git_log(options, cwd=os.getcwd()):
+ git_log = [
+ GIT_COMMAND,
+ "log",
+ "--no-merges",
+ options.range,
+ ]
+ return subprocess.check_output(git_log, cwd=cwd, encoding=UTF8)
+
+
+class Component:
+ name = None
+ sentinels = set()
+
+ def __init__(self, name, sentinels):
+ self.name = name
+ self.sentinels = sentinels
+
+
+class Components(Enum):
+ plugin_ce = Component("Codemirror-editor", {PLUGINS})
+ plugin_cm = Component("Commit-message-length-validator", {PLUGINS})
+ plugin_dp = Component("Delete-project", {PLUGINS})
+ plugin_dc = Component("Download-commands", {PLUGINS})
+ plugin_gt = Component("Gitiles", {PLUGINS})
+ plugin_ho = Component("Hooks", {PLUGINS})
+ plugin_pm = Component("Plugin-manager", {PLUGINS})
+ plugin_re = Component("Replication", {PLUGINS})
+ plugin_rn = Component("Reviewnotes", {PLUGINS})
+ plugin_su = Component("Singleusergroup", {PLUGINS})
+ plugin_wh = Component("Webhooks", {PLUGINS})
+
+ ui = Component(
+ "Polygerrit UI",
+ {"poly", "gwt", "button", "dialog", "icon", "hover", "menu", "ux"},
+ )
+ doc = Component("Documentation", {"document"})
+ jgit = Component("JGit", {"jgit"})
+ elastic = Component("Elasticsearch", {"elastic"})
+ deps = Component("Other dependency", {"upgrade", "dependenc"})
+ otherwise = Component("Other core", {})
+
+
+class Task(Enum):
+ start_commit = 1
+ finish_headers = 2
+ capture_subject = 3
+ finish_commit = 4
+
+
+class Commit:
+ sha1 = None
+ subject = None
+ component = None
+ issues = set()
+
+ def reset(self, signature, task):
+ if signature is not None:
+ self.sha1 = signature.group(1)
+ self.subject = None
+ self.component = None
+ self.issues = set()
+ return Task.finish_headers
+ return task
+
+
+def parse_log(process, gerrit, options, commits, cwd=os.getcwd()):
+ commit = Commit()
+ task = Task.start_commit
+ for line in process.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ if task == Task.start_commit:
+ task = commit.reset(re.search(COMMIT_SHA1_PATTERN, line), task)
+ elif task == Task.finish_headers:
+ if re.match(DATE_HEADER_PATTERN, line):
+ task = Task.capture_subject
+ elif task == Task.capture_subject:
+ commit.subject = line
+ task = Task.finish_commit
+ elif task == Task.finish_commit:
+ commit_issue = re.search(ISSUE_ID_PATTERN, line)
+ if commit_issue is not None:
+ commit.issues.add(commit_issue.group(1))
+ else:
+ commit_end = re.match(CHANGE_ID_PATTERN, line)
+ if commit_end is not None:
+ commit = finish(commit, commits, gerrit, options, cwd)
+ task = Task.start_commit
+ else:
+ raise RuntimeError("FIXME")
+
+
+def finish(commit, commits, gerrit, options, cwd):
+ if re.match(SUBJECT_SUBMODULES_PATTERN, commit.subject):
+ return Commit()
+ if len(commit.issues) == 0:
+ for exclusion in EXCLUDED_SUBJECTS:
+ if exclusion in commit.subject:
+ return Commit()
+ for component in commits:
+ for noted_commit in commits[component]:
+ if noted_commit.subject == commit.subject:
+ return Commit()
+ set_component(commit, commits, cwd)
+ link_subject(commit, gerrit, options, cwd)
+ escape_these(commit)
+ return Commit()
+
+
+def set_component(commit, commits, cwd):
+ component_found = None
+ for component in Components:
+ for sentinel in component.value.sentinels:
+ if component_found is None:
+ if re.match(f"{GIT_PATH}/{PLUGINS}{component.value.name.lower()}", cwd):
+ component_found = component
+ elif sentinel.lower() in commit.subject.lower():
+ component_found = component
+ if component_found is not None:
+ commits[component].append(commit)
+ if component_found is None:
+ commits[Components.otherwise].append(commit)
+ commit.component = component_found
+
+
+def init_components():
+ components = dict()
+ for component in Components:
+ components[component] = []
+ return components
+
+
+def link_subject(commit, gerrit, options, cwd):
+ if options.link:
+ gerrit_change = gerrit.get(f"{COMMIT_URL}{commit.sha1}")
+ if not gerrit_change:
+ return
+ change_number = gerrit_change[0]["_number"]
+ plugin_wd = re.search(f"{GIT_PATH}/({PLUGINS}.+)", cwd)
+ if plugin_wd is not None:
+ change_address = f"{GERRIT_URL}/c/{plugin_wd.group(1)}/+/{change_number}"
+ else:
+ change_address = f"{GERRIT_URL}{CHANGE_URL}{change_number}"
+ short_sha1 = commit.sha1[0:7]
+ commit.subject = f"[{short_sha1}]({change_address})\n {commit.subject}"
+
+
+def escape_these(in_change):
+ in_change.subject = in_change.subject.replace("<", "\\<")
+ in_change.subject = in_change.subject.replace(">", "\\>")
+
+
+def print_commits(commits, md):
+ for component in commits:
+ if len(commits[component]) > 0:
+ if PLUGINS in component.value.sentinels:
+ md.write(f"\n### {component.value.name}\n")
+ else:
+ md.write(f"\n## {component.value.name} changes\n")
+ for commit in commits[component]:
+ print_from(commit, md)
+
+
+def print_from(this_change, md):
+ md.write("\n*")
+ for issue in sorted(this_change.issues):
+ md.write(f" [Issue {issue}]({ISSUE_URL}{issue});\n ")
+ md.write(f" {this_change.subject}\n")
+
+
+def print_template(md, options):
+ previous = "0.0.0"
+ new = "0.1.0"
+ versions = re.search(RELEASE_VERSIONS_PATTERN, options.range)
+ if versions is not None:
+ previous = versions.group(1)
+ new = versions.group(2)
+ data = {
+ "previous": previous,
+ "new": new,
+ "major": re.search(RELEASE_MAJOR_PATTERN, new).group(1),
+ "doc": re.search(RELEASE_DOC_PATTERN, new).group(1),
+ }
+ template = Template(open(f"{MARKDOWN}.md.template").read())
+ md.write(f"{template.render(data=data)}\n")
+
+
+def print_notes(commits, options):
+ markdown = f"{MARKDOWN}.md"
+ next_md = 2
+ while path.exists(markdown):
+ markdown = f"{MARKDOWN}-{next_md}.md"
+ next_md += 1
+ with open(markdown, "w") as md:
+ print_template(md, options)
+ print_commits(commits, md)
+ md.write("\n## Bugfix releases\n")
+
+
+def plugin_changes():
+ plugin_commits = init_components()
+ for submodule_name in list_submodules().splitlines():
+ plugin_name = re.search(PLUGIN_PATTERN, submodule_name)
+ if plugin_name is not None:
+ plugin_wd = f"{GIT_PATH}/{PLUGINS}{plugin_name.group(1)}"
+ plugin_log = open_git_log(script_options, plugin_wd)
+ parse_log(
+ plugin_log,
+ gerrit_api,
+ script_options,
+ plugin_commits,
+ plugin_wd,
+ )
+ return plugin_commits
+
+
+if __name__ == "__main__":
+ gerrit_api = GerritRestAPI(url=GERRIT_URL, auth=Anonymous())
+ script_options = parse_args()
+ if script_options.link:
+ print("Link option used; slower.")
+ noted_changes = plugin_changes()
+ change_log = open_git_log(script_options)
+ parse_log(change_log, gerrit_api, script_options, noted_changes)
+ print_notes(noted_changes, script_options)
diff --git a/tools/workspace_status.py b/tools/workspace_status.py
index 86df519..443c2f0 100644
--- a/tools/workspace_status.py
+++ b/tools/workspace_status.py
@@ -36,9 +36,11 @@
print("STABLE_BUILD_GERRIT_LABEL %s" % revision(ROOT, ROOT))
-for d in os.listdir(os.path.join(ROOT, 'plugins')):
- p = os.path.join('plugins', d)
- if os.path.isdir(p):
- v = revision(p, ROOT)
- print('STABLE_BUILD_%s_LABEL %s' % (os.path.basename(p).upper(),
- v if v else 'unknown'))
+for kind in ['modules', 'plugins']:
+ kind_dir = os.path.join(ROOT, kind)
+ for d in os.listdir(kind_dir):
+ p = os.path.join(kind_dir, d)
+ if os.path.isdir(p):
+ v = revision(p, ROOT)
+ print('STABLE_BUILD_%s_LABEL %s' % (os.path.basename(p).upper(),
+ v if v else 'unknown'))
diff --git a/tools/workspace_status_release.py b/tools/workspace_status_release.py
new file mode 100644
index 0000000..36535fb
--- /dev/null
+++ b/tools/workspace_status_release.py
@@ -0,0 +1,195 @@
+#!/usr/bin/env python
+
+# This is a variant of the `workspace_status.py` script that in addition to
+# plain `git describe` implements a few heuristics to arrive at more to the
+# point stamps for directories. But due to the implemented heuristics, it will
+# typically take longer to run (especially if you use lots of plugins that
+# come without tags) and might slow down your development cycle when used
+# as default.
+#
+# To use it, simply add
+#
+# --workspace_status_command="python ./tools/workspace_status_release.py"
+#
+# to your bazel command. So for example instead of
+#
+# bazel build release.war
+#
+# use
+#
+# bazel build --workspace_status_command="python ./tools/workspace_status_release.py" release.war
+#
+# Alternatively, you can add
+#
+# build --workspace_status_command="python ./tools/workspace_status_release.py"
+#
+# to `.bazelrc` in your home directory.
+#
+# If the script exits with non-zero code, it's considered as a failure
+# and the output will be discarded.
+
+from __future__ import print_function
+import os
+import subprocess
+import sys
+import re
+
+ROOT = os.path.abspath(__file__)
+while not os.path.exists(os.path.join(ROOT, 'WORKSPACE')):
+ ROOT = os.path.dirname(ROOT)
+REVISION_CMD = ['git', 'describe', '--always', '--dirty']
+
+
+def run(command):
+ try:
+ return subprocess.check_output(command).strip().decode("utf-8")
+ except OSError as err:
+ print('could not invoke %s: %s' % (command[0], err), file=sys.stderr)
+ sys.exit(1)
+ except subprocess.CalledProcessError:
+ # ignore "not a git repository error" to report unknown version
+ return None
+
+
+def revision_with_match(pattern=None, prefix=False, all_refs=False,
+ return_unmatched=False):
+ """Return a description of the current commit
+
+ Keyword arguments:
+ pattern -- (Default: None) Use only refs that match this pattern.
+ prefix -- (Default: False) If True, the pattern is considered a prefix
+ and does not require an exact match.
+ all_refs -- (Default: False) If True, consider all refs, not just tags.
+ return_unmatched -- (Default: False) If False and a pattern is given that
+ cannot be matched, return the empty string. If True, return
+ the unmatched description nonetheless.
+ """
+
+ command = REVISION_CMD[:]
+ if pattern:
+ command += ['--match', pattern + ('*' if prefix else '')]
+ if all_refs:
+ command += ['--all', '--long']
+
+ description = run(command)
+
+ if pattern and not return_unmatched and not description.startswith(pattern):
+ return ''
+ return description
+
+
+def branch_with_match(pattern):
+ for ref_kind in ['origin/', 'gerrit/', '']:
+ description = revision_with_match(ref_kind + pattern, all_refs=True,
+ return_unmatched=True)
+ for cutoff in ['heads/', 'remotes/', ref_kind]:
+ if description.startswith(cutoff):
+ description = description[len(cutoff):]
+ if description.startswith(pattern):
+ return description
+ return ''
+
+
+def revision(template=None):
+ if template:
+ # We use the version `v2.16.19-1-gec686a6352` as running example for the
+ # below comments. First, we split into ['v2', '16', '19']
+ parts = template.split('-')[0].split('.')
+
+ # Although we have releases with version tags containing 4 numbers, we
+ # treat only the first three numbers for simplicity. See discussion on
+ # Ib1681b2730cf2c443a3cb55fe6e282f6484e18de.
+
+ if len(parts) >= 3:
+ # Match for v2.16.19
+ version_part = '.'.join(parts[0:3])
+ description = revision_with_match(version_part)
+ if description:
+ return description
+
+ if len(parts) >= 2:
+ version_part = '.'.join(parts[0:2])
+
+ # Match for v2.16.*
+ description = revision_with_match(version_part + '.', prefix=True)
+ if description:
+ return description
+
+ # Match for v2.16
+ description = revision_with_match(version_part)
+ if description.startswith(version_part):
+ return description
+
+ if template.startswith('v'):
+ # Match for stable-2.16 branches
+ branch = 'stable-' + version_part[1:]
+ description = branch_with_match(branch)
+ if description:
+ return description
+
+ # None of the template based methods worked out, so we're falling back to
+ # generic matches.
+
+ # Match for master branch
+ description = branch_with_match('master')
+ if description:
+ return description
+
+ # Match for anything that looks like a version tag
+ description = revision_with_match('v[0-9].', return_unmatched=True)
+ if description.startswith('v'):
+ return description
+
+ # Still no good tag, so we re-try without any matching
+ return revision_with_match()
+
+
+# prints the stamps for the current working directory
+def print_stamps_for_cwd(name, template):
+ workspace_status_script = os.path.join(
+ 'tools', 'workspace_status_release.py')
+ if os.path.isfile(workspace_status_script):
+ # directory has own workspace_status_command, so we use stamps from that
+ for line in run(["python", workspace_status_script]).split('\n'):
+ if re.search("^STABLE_[a-zA-Z0-9().:@/_ -]*$", line):
+ print(line)
+ else:
+ # directory lacks own workspace_status_command, so we create a default
+ # stamp
+ v = revision(template)
+ print('STABLE_BUILD_%s_LABEL %s' % (name.upper(),
+ v if v else 'unknown'))
+
+
+# os.chdir is different from plain `cd` in shells in that it follows symlinks
+# and does not update the PWD environment. So when using os.chdir to change into
+# a symlinked directory from gerrit's `plugins` or `modules` directory, we
+# cannot recover gerrit's directory. This prevents the plugins'/modules'
+# `workspace_status_release.py` scripts to detect the name they were symlinked
+# as (E.g.: it-* plugins sometimes get linked in more than once under different
+# names) and to detect gerrit's root directory. To work around this problem, we
+# mimic the `cd` of ordinary shells. By using this function, symlink information
+# is preserved in the `PWD` environment variable (as it is for example also done
+# in bash) and plugin/module `workspace_status_release.py` scripts can pick up
+# the needed information from there.
+def cd(absolute_path):
+ os.environ['PWD'] = absolute_path
+ os.chdir(absolute_path)
+
+
+def print_stamps():
+ cd(ROOT)
+ gerrit_version = revision()
+ print("STABLE_BUILD_GERRIT_LABEL %s" % gerrit_version)
+ for kind in ['modules', 'plugins']:
+ kind_dir = os.path.join(ROOT, kind)
+ for d in os.listdir(kind_dir) if os.path.isdir(kind_dir) else []:
+ p = os.path.join(kind_dir, d)
+ if os.path.isdir(p):
+ cd(p)
+ name = os.path.basename(p)
+ print_stamps_for_cwd(name, gerrit_version)
+
+
+if __name__ == '__main__':
+ print_stamps()
diff --git a/version.bzl b/version.bzl
index 78b286b..066d07e 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
# Used by :api_install and :api_deploy targets
# when talking to the destination repository.
#
-GERRIT_VERSION = "3.3.0-SNAPSHOT"
+GERRIT_VERSION = "3.4.0-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index a4f83bb..34f761f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -485,20 +485,20 @@
lodash "^4.17.11"
to-fast-properties "^2.0.0"
-"@bazel/rollup@^1.6.1":
- version "1.6.1"
- resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-1.6.1.tgz#7ec9d39a3fca23256fca55410339724804802616"
- integrity sha512-FhblJkpd8VKl9txhAAIotSsIOHRpPd2FgJG7Op3uV7LfaCVBmUs3XDBZCgfwt5wmEpd3lwCHA1Ei+O/URS2+5w==
+"@bazel/rollup@^2.2.2":
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/@bazel/rollup/-/rollup-2.2.2.tgz#1abfc5cbf5eb65db2aa145e584d225684d961055"
+ integrity sha512-z3sK0dt7pftjxlLuo66e3PMMGyjq6vD/8B+OEFN3LD3GjE34e8X0/KeRX5lXWs1ecVlrnTroiBxLCJSHwqBrEA==
-"@bazel/terser@^1.7.0":
- version "1.7.0"
- resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-1.7.0.tgz#c43e711e13b9a71c7abd3ade04fb4650d547ad01"
- integrity sha512-u/UXk0WUinvkk1g5xxfqGieBz3r12Bj2y2m25lC5GjHBgCpGk7DyeGGi9H3QQNO1Wmpw51QSE9gaPzKzjUVGug==
+"@bazel/terser@^2.2.2":
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/@bazel/terser/-/terser-2.2.2.tgz#2a72b739de8a12ab9ca1cfe60c6c118215acc10f"
+ integrity sha512-pPhNr21g8PN0jGhzQHOIL9pOicMgU1Jfrh+liI4PVBfSFrJbTjJw3iNRDX0skYAlsR0WG433kn8CkEjY4IvJVw==
-"@bazel/typescript@^1.6.1":
- version "1.6.1"
- resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-1.6.1.tgz#1bf83c20021d359bc9b532181981ac540584a30c"
- integrity sha512-wQ9AASRcG1jLQOpJfNOMjZzPpwIV/9qTOxCFvp55ga6A5a2qveQr8JJ7jHHbBM0LtK+slEPixXmVmtEOwfKsIg==
+"@bazel/typescript@^2.2.2":
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/@bazel/typescript/-/typescript-2.2.2.tgz#c7cd49cb630ca3720c04c94046ba8ca4c0d5b0aa"
+ integrity sha512-hkx/7L3s8q5gIgaSFmkUZWPqdKmdJmQ04GaLnsI/YEp9EhPObqATSKnOHeDdT7bzqLO7giDAwAiXhEmsO1Smcw==
dependencies:
protobufjs "6.8.8"
semver "5.6.0"
@@ -937,9 +937,9 @@
integrity sha512-rp7La3m845mSESCgsJePNL/JQyhkOJA6G4vcwvVgkDAwHhGdq5GCumxmPjEk1MZf+8p5ZQAUE7tqgQRQTXN7uQ==
"@types/node@^10.1.0":
- version "10.17.24"
- resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944"
- integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA==
+ version "10.17.42"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.42.tgz#90dd71b26fe4f4e2929df6b07e72ef2e9648a173"
+ integrity sha512-HElxYF7C/MSkuvlaHB2c+82zhXiuO49Cq056Dol8AQuTph7oJtduo2n6J8rFa+YhJyNgQ/Lm20ZaxqD0vxU0+Q==
"@types/node@^4.0.30":
version "4.9.3"
@@ -9330,10 +9330,10 @@
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
-typescript@3.8.2:
- version "3.8.2"
- resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.2.tgz#91d6868aaead7da74f493c553aeff76c0c0b1d5a"
- integrity sha512-EgOVgL/4xfVrCMbhYKUQTdF37SQn4Iw73H5BgCrF1Abdun7Kwy/QZsE/ssAy0y4LxBbvua3PIbFsbRczWWnDdQ==
+typescript@3.9.5:
+ version "3.9.5"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.5.tgz#586f0dba300cde8be52dd1ac4f7e1009c1b13f36"
+ integrity sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==
typical@^2.6.1:
version "2.6.1"